Skip to main content

featherbit/outbound/
idle.rs

1//! Body wrappers for streaming upstream responses.
2//!
3//! Both operate at the `http_body::Body` frame level so they compose with any
4//! `BoxBody<Bytes, BoxError>` — the streaming body type used throughout
5//! `src/outbound` and `src/context/stream.rs` — without re-buffering it.
6//! `BoxError` (`crate::outbound::BoxError`) is a boxed `std::error::Error`,
7//! not `hyper::Error`: see that type alias's doc comment for why. In short,
8//! `hyper::Error` has no public constructor anywhere in the `hyper` crate, so
9//! a body wrapper built on it could never report its own failure. `BoxError`
10//! has no such restriction, which is what lets [`idle_timeout_body`] below
11//! actually error the stream on reap, rather than merely ending it.
12
13use std::future::Future;
14use std::pin::Pin;
15use std::task::{Context, Poll};
16use std::time::Duration;
17
18use bytes::Bytes;
19use http_body::{Body, Frame, SizeHint};
20use http_body_util::combinators::BoxBody;
21use http_body_util::BodyExt;
22
23use super::BoxError;
24
25/// The error [`idle_timeout_body`] reports when a stream is reaped: no frame
26/// arrived for the configured idle bound.
27#[derive(Debug)]
28pub struct IdleTimeoutError {
29    /// The idle bound that elapsed with no frame arriving.
30    pub idle: Duration,
31}
32
33impl std::fmt::Display for IdleTimeoutError {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        write!(
36            f,
37            "idle timeout: no response body frame for {:?}",
38            self.idle
39        )
40    }
41}
42
43impl std::error::Error for IdleTimeoutError {}
44
45/// Wraps `body` so it errors when no frame arrives for `idle`. The timer
46/// resets on every frame, so a steady stream survives indefinitely while a
47/// silent one is reaped — and reaped as a real failure
48/// (`Poll::Ready(Some(Err(IdleTimeoutError)))`), not a clean end: ending
49/// cleanly would write the chunked terminator (HTTP/1.1) or `END_STREAM`
50/// (h2) exactly as a legitimately complete response would, making a
51/// truncated body indistinguishable from a complete one on the wire — on
52/// exactly the unbounded bodies (NDJSON exports, log tails, bulk proxied
53/// data) this feature targets. A real upstream error — the wrapped body
54/// itself yielding `Err`, e.g. a connection reset — is forwarded unchanged;
55/// the reap only fires when nothing else has.
56pub fn idle_timeout_body(
57    body: BoxBody<Bytes, BoxError>,
58    idle: Duration,
59) -> BoxBody<Bytes, BoxError> {
60    IdleTimeoutBody {
61        inner: body,
62        idle,
63        sleep: Box::pin(tokio::time::sleep(idle)),
64    }
65    .boxed()
66}
67
68struct IdleTimeoutBody {
69    inner: BoxBody<Bytes, BoxError>,
70    idle: Duration,
71    sleep: Pin<Box<tokio::time::Sleep>>,
72}
73
74impl Body for IdleTimeoutBody {
75    type Data = Bytes;
76    type Error = BoxError;
77
78    fn poll_frame(
79        self: Pin<&mut Self>,
80        cx: &mut Context<'_>,
81    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
82        let this = self.get_mut();
83
84        match Pin::new(&mut this.inner).poll_frame(cx) {
85            Poll::Ready(frame) => {
86                // Any activity — a frame, the body's own clean end, or its
87                // own error — pushes the deadline out. A body that has
88                // already reported it's finished is never polled again, so
89                // resetting here on the terminal poll is a harmless no-op.
90                this.sleep
91                    .as_mut()
92                    .reset(tokio::time::Instant::now() + this.idle);
93                return Poll::Ready(frame);
94            }
95            Poll::Pending => {}
96        }
97
98        match this.sleep.as_mut().poll(cx) {
99            Poll::Ready(()) => {
100                // The only place this fires: the caller (a hyper connection
101                // writing this body to the client) sees only an ordinary
102                // `Err` frame, not a log line, so this is the one place in
103                // the process a silent-upstream reap is ever recorded.
104                tracing::warn!(
105                    idle_ms = this.idle.as_millis() as u64,
106                    "streamed response body idle timeout: no frame for {:?}, reaping the stream",
107                    this.idle
108                );
109                Poll::Ready(Some(Err(Box::new(IdleTimeoutError { idle: this.idle }))))
110            }
111            Poll::Pending => Poll::Pending,
112        }
113    }
114
115    fn is_end_stream(&self) -> bool {
116        self.inner.is_end_stream()
117    }
118
119    fn size_hint(&self) -> SizeHint {
120        self.inner.size_hint()
121    }
122}
123
124/// Wraps `body` so `guards` are dropped only when the body reports it has
125/// finished (cleanly or with an error) or the returned body is itself
126/// dropped — never merely because the node that started the stream returned.
127/// This is what keeps balancer in-flight counters and `limit-conn` permits
128/// held for the stream's real lifetime.
129pub fn body_holding(
130    body: BoxBody<Bytes, BoxError>,
131    guards: Vec<Box<dyn Send + 'static>>,
132) -> BoxBody<Bytes, BoxError> {
133    BodyHolding {
134        inner: body,
135        guards: std::sync::Mutex::new(guards),
136    }
137    .boxed()
138}
139
140struct BodyHolding {
141    inner: BoxBody<Bytes, BoxError>,
142    // `Box<dyn Send>` guards are `Send`-only, not `Sync`, so a bare `Vec`
143    // here would make `BodyHolding` (and, boxed, the `BoxBody` it returns)
144    // lose `Sync` — `BoxBody`'s trait object requires `Send + Sync`.
145    // `Mutex<T>` is `Sync` whenever `T: Send`, and `poll_frame` always has
146    // exclusive access (`&mut self` via `get_mut`), so the lock never
147    // contends. Same trick as `context::stream::ResponseStream`.
148    //
149    // Cleared as soon as the body reports it is finished (see `poll_frame`);
150    // otherwise dropped along with `self` when `self` is dropped — ordinary
151    // field-drop order gives us that half for free, no `Drop` impl needed.
152    guards: std::sync::Mutex<Vec<Box<dyn Send + 'static>>>,
153}
154
155impl Body for BodyHolding {
156    type Data = Bytes;
157    type Error = BoxError;
158
159    fn poll_frame(
160        self: Pin<&mut Self>,
161        cx: &mut Context<'_>,
162    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
163        let this = self.get_mut();
164        match Pin::new(&mut this.inner).poll_frame(cx) {
165            Poll::Ready(None) => {
166                this.guards.get_mut().unwrap().clear();
167                Poll::Ready(None)
168            }
169            Poll::Ready(Some(Err(e))) => {
170                this.guards.get_mut().unwrap().clear();
171                Poll::Ready(Some(Err(e)))
172            }
173            other => other,
174        }
175    }
176
177    fn is_end_stream(&self) -> bool {
178        self.inner.is_end_stream()
179    }
180
181    fn size_hint(&self) -> SizeHint {
182        self.inner.size_hint()
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use std::collections::VecDeque;
190
191    /// A minimal hand-rolled body over a queue of scheduled data frames, each
192    /// available after its own delay. Never errors on its own — only the
193    /// wrappers under test (`idle_timeout_body`) synthesize errors; this body
194    /// exists to produce plain successful frames on a schedule. After the
195    /// queue drains it either ends the stream (`stall = false`) or stays
196    /// `Pending` forever (`stall = true`), simulating an upstream that goes
197    /// silent without closing the connection.
198    struct ScriptedBody {
199        items: VecDeque<Duration>,
200        stall: bool,
201        pending: Option<Pin<Box<tokio::time::Sleep>>>,
202    }
203
204    impl ScriptedBody {
205        fn new(delays: Vec<Duration>, stall: bool) -> Self {
206            Self {
207                items: delays.into(),
208                stall,
209                pending: None,
210            }
211        }
212    }
213
214    impl Body for ScriptedBody {
215        type Data = Bytes;
216        type Error = BoxError;
217
218        fn poll_frame(
219            self: Pin<&mut Self>,
220            cx: &mut Context<'_>,
221        ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
222            let this = self.get_mut();
223            // A single pass suffices: either a pending timer is already
224            // armed, or one is armed here and polled immediately after —
225            // never a second iteration, so no `loop` is needed.
226            if this.pending.is_none() {
227                match this.items.pop_front() {
228                    Some(delay) => this.pending = Some(Box::pin(tokio::time::sleep(delay))),
229                    None => {
230                        return if this.stall {
231                            Poll::Pending
232                        } else {
233                            Poll::Ready(None)
234                        };
235                    }
236                }
237            }
238            let sleep = this.pending.as_mut().unwrap();
239            match sleep.as_mut().poll(cx) {
240                Poll::Ready(()) => {
241                    this.pending = None;
242                    Poll::Ready(Some(Ok(Frame::data(Bytes::from_static(b"x")))))
243                }
244                Poll::Pending => Poll::Pending,
245            }
246        }
247    }
248
249    /// A body that yields one frame and then goes silent must be reaped —
250    /// not left to hang forever — once the idle bound elapses with no
251    /// further frame, and the reap must surface as a real error, not a
252    /// clean end (see the module doc: a clean end is indistinguishable from
253    /// a legitimately complete response on the wire).
254    #[tokio::test]
255    async fn test_idle_timeout_reaps_a_silent_stream_as_an_error() {
256        let body = ScriptedBody::new(vec![Duration::ZERO], true);
257        let mut wrapped = idle_timeout_body(body.boxed(), Duration::from_millis(150));
258
259        // First frame arrives normally.
260        let first = tokio::time::timeout(
261            Duration::from_millis(500),
262            std::future::poll_fn(|cx| Pin::new(&mut wrapped).poll_frame(cx)),
263        )
264        .await
265        .expect("first frame must arrive promptly");
266        assert!(matches!(first, Some(Ok(_))), "expected the first frame");
267
268        // Then silence: the idle bound must reap the stream as an error, not
269        // hang and not end cleanly.
270        let second = tokio::time::timeout(
271            Duration::from_millis(800),
272            std::future::poll_fn(|cx| Pin::new(&mut wrapped).poll_frame(cx)),
273        )
274        .await
275        .expect("a silent stream must be reaped, not hang past the idle bound");
276        match second {
277            Some(Err(e)) => {
278                assert!(
279                    e.downcast_ref::<IdleTimeoutError>().is_some(),
280                    "expected an IdleTimeoutError, got: {e}"
281                );
282            }
283            other => panic!("expected the reap to error the stream, got: {other:?}"),
284        }
285    }
286
287    /// A body that keeps sending frames inside the idle bound must be left
288    /// alone and allowed to finish on its own, however long that takes in
289    /// total. 15 frames * 50ms = 750ms of total elapsed time against a
290    /// 500ms idle bound: a naive total-deadline implementation (arms the
291    /// timer once, never resets it) fails this around frame 10, so this is
292    /// the case that actually exercises the reset — 5 frames * 50ms = 250ms
293    /// stays under a 500ms bound even without ever resetting anything.
294    #[tokio::test]
295    async fn test_idle_timeout_lets_a_steady_stream_finish() {
296        let delays = vec![Duration::from_millis(50); 15];
297        let body = ScriptedBody::new(delays, false);
298        let wrapped = idle_timeout_body(body.boxed(), Duration::from_millis(500));
299
300        let collected = tokio::time::timeout(Duration::from_secs(3), wrapped.collect())
301            .await
302            .expect("a steady stream (every gap well under the idle bound) must not be reaped")
303            .expect("no error expected");
304
305        assert_eq!(collected.to_bytes().as_ref(), b"xxxxxxxxxxxxxxx");
306    }
307
308    struct DropGuard(std::sync::Arc<std::sync::atomic::AtomicUsize>);
309    impl Drop for DropGuard {
310        fn drop(&mut self) {
311            self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
312        }
313    }
314
315    /// The guard must not be dropped while the body still has frames to
316    /// deliver, and must be dropped once the body reports it is finished.
317    #[tokio::test]
318    async fn test_body_holding_holds_guards_until_body_finishes() {
319        use std::sync::atomic::{AtomicUsize, Ordering};
320        use std::sync::Arc;
321
322        let dropped = Arc::new(AtomicUsize::new(0));
323        let body = ScriptedBody::new(vec![Duration::ZERO, Duration::ZERO], false);
324        let mut wrapped = body_holding(body.boxed(), vec![Box::new(DropGuard(dropped.clone()))]);
325
326        // First frame: the body isn't finished yet, so the guard must survive.
327        let _ = std::future::poll_fn(|cx| Pin::new(&mut wrapped).poll_frame(cx)).await;
328        assert_eq!(
329            dropped.load(Ordering::SeqCst),
330            0,
331            "guard released too early"
332        );
333
334        // Drain the rest of the body to completion.
335        let _ = wrapped.collect().await.unwrap();
336        assert_eq!(
337            dropped.load(Ordering::SeqCst),
338            1,
339            "guard not released once the body finished"
340        );
341    }
342
343    /// Dropping the wrapped body early (before it finishes) must also
344    /// release the guard — the fallback half of the contract that doesn't
345    /// depend on the body running to completion.
346    #[tokio::test]
347    async fn test_body_holding_drops_guards_when_body_dropped_early() {
348        use std::sync::atomic::{AtomicUsize, Ordering};
349        use std::sync::Arc;
350
351        let dropped = Arc::new(AtomicUsize::new(0));
352        let body = ScriptedBody::new(vec![Duration::ZERO], true);
353        let wrapped = body_holding(body.boxed(), vec![Box::new(DropGuard(dropped.clone()))]);
354
355        assert_eq!(dropped.load(Ordering::SeqCst), 0);
356        drop(wrapped);
357        assert_eq!(
358            dropped.load(Ordering::SeqCst),
359            1,
360            "guard not released when the body was dropped early"
361        );
362    }
363
364    /// The guard must also release when the body ends with an error (e.g.
365    /// the idle reap above), not only on a clean end — `body_holding` treats
366    /// both as "finished".
367    #[tokio::test]
368    async fn test_body_holding_drops_guards_on_body_error() {
369        use std::sync::atomic::{AtomicUsize, Ordering};
370        use std::sync::Arc;
371
372        let dropped = Arc::new(AtomicUsize::new(0));
373        let body = ScriptedBody::new(vec![Duration::ZERO], true);
374        let idled = idle_timeout_body(body.boxed(), Duration::from_millis(50));
375        let mut wrapped = body_holding(idled, vec![Box::new(DropGuard(dropped.clone()))]);
376
377        // First frame.
378        let _ = std::future::poll_fn(|cx| Pin::new(&mut wrapped).poll_frame(cx)).await;
379        assert_eq!(dropped.load(Ordering::SeqCst), 0);
380
381        // The idle timeout fires and errors the (inner) body; body_holding
382        // must treat that as "finished" and release the guard.
383        let second = tokio::time::timeout(
384            Duration::from_millis(500),
385            std::future::poll_fn(|cx| Pin::new(&mut wrapped).poll_frame(cx)),
386        )
387        .await
388        .expect("idle reap must fire");
389        assert!(matches!(second, Some(Err(_))));
390        assert_eq!(
391            dropped.load(Ordering::SeqCst),
392            1,
393            "guard not released when the body ended with an error"
394        );
395    }
396}