use std::pin::pin;
use std::sync::atomic::{AtomicBool, Ordering};
use futures_core::Stream;
use futures_util::stream::StreamExt;
pub(crate) const STREAM_IDLE_TIMEOUT_MS: u32 = 120_000;
pub(crate) fn idle_timeout_ms() -> u32 {
#[cfg(not(target_arch = "wasm32"))]
{
if let Ok(v) = std::env::var("LH_STREAM_IDLE_TIMEOUT_MS") {
if let Ok(n) = v.trim().parse::<u32>() {
if n > 0 {
return n;
}
}
}
}
STREAM_IDLE_TIMEOUT_MS
}
pub(crate) enum NextChunk<T> {
Item(T),
End,
IdleTimeout,
Cancelled,
}
pub(crate) const CANCEL_POLL_MS: u32 = 100;
pub(crate) async fn next_with_idle_timeout_or_cancel<S, T>(
stream: &mut S,
idle_ms: u32,
cancel: &AtomicBool,
) -> NextChunk<T>
where
S: Stream<Item = T> + Unpin,
{
let mut waited: u32 = 0;
loop {
if cancel.load(Ordering::Acquire) {
return NextChunk::Cancelled;
}
let slice = CANCEL_POLL_MS.min(idle_ms - waited).max(1);
match next_with_idle_timeout(stream, slice).await {
NextChunk::IdleTimeout => {
waited = waited.saturating_add(slice);
if waited >= idle_ms {
return NextChunk::IdleTimeout;
}
}
other => return other,
}
}
}
pub(crate) async fn next_with_idle_timeout<S, T>(stream: &mut S, idle_ms: u32) -> NextChunk<T>
where
S: Stream<Item = T> + Unpin,
{
let next = stream.next();
let sleep = crate::runtime::sleep_ms(idle_ms);
let next = pin!(next);
let sleep = pin!(sleep);
match futures_util::future::select(next, sleep).await {
futures_util::future::Either::Left((Some(item), _sleep)) => NextChunk::Item(item),
futures_util::future::Either::Left((None, _sleep)) => NextChunk::End,
futures_util::future::Either::Right((_elapsed, _next)) => NextChunk::IdleTimeout,
}
}
#[cfg(test)]
mod tests {
use super::*;
use futures_util::stream;
#[tokio::test]
async fn ready_item_beats_the_timer_then_end() {
let mut s = stream::iter(vec![42i32]);
match next_with_idle_timeout(&mut s, 60_000).await {
NextChunk::Item(v) => assert_eq!(v, 42),
_ => panic!("a ready chunk must win over a 60s timer"),
}
assert!(matches!(next_with_idle_timeout(&mut s, 60_000).await, NextChunk::End));
}
#[tokio::test]
async fn empty_stream_is_end_not_timeout() {
let mut s = stream::iter(Vec::<i32>::new());
assert!(matches!(next_with_idle_timeout(&mut s, 60_000).await, NextChunk::End));
}
#[tokio::test]
async fn a_silent_stream_trips_the_idle_timeout() {
let mut s = stream::pending::<i32>();
assert!(matches!(next_with_idle_timeout(&mut s, 5).await, NextChunk::IdleTimeout));
}
#[test]
fn idle_window_is_two_minutes() {
assert_eq!(STREAM_IDLE_TIMEOUT_MS, 120_000);
}
#[tokio::test]
async fn preset_cancel_wins_over_a_ready_item() {
let cancel = AtomicBool::new(true);
let mut s = stream::iter(vec![42i32]);
assert!(matches!(
next_with_idle_timeout_or_cancel(&mut s, 60_000, &cancel).await,
NextChunk::Cancelled
));
}
#[tokio::test]
async fn cancel_mid_silence_interrupts_promptly() {
use std::sync::Arc;
let cancel = Arc::new(AtomicBool::new(false));
let flip = cancel.clone();
tokio::spawn(async move {
crate::runtime::sleep_ms(30).await;
flip.store(true, Ordering::Release);
});
let mut s = stream::pending::<i32>();
let t0 = std::time::Instant::now();
let out = next_with_idle_timeout_or_cancel(&mut s, 60_000, &cancel).await;
assert!(matches!(out, NextChunk::Cancelled), "cancel must break a silent stream");
assert!(
t0.elapsed() < std::time::Duration::from_secs(5),
"cancel latency is bounded by the poll slice, not the 60s idle window"
);
}
#[tokio::test]
async fn uncancelled_semantics_match_the_plain_helper() {
let cancel = AtomicBool::new(false);
let mut s = stream::iter(vec![7i32]);
assert!(matches!(
next_with_idle_timeout_or_cancel(&mut s, 60_000, &cancel).await,
NextChunk::Item(7)
));
assert!(matches!(
next_with_idle_timeout_or_cancel(&mut s, 60_000, &cancel).await,
NextChunk::End
));
let mut p = stream::pending::<i32>();
assert!(matches!(
next_with_idle_timeout_or_cancel(&mut p, 25, &cancel).await,
NextChunk::IdleTimeout
));
let mut p2 = stream::pending::<i32>();
assert!(matches!(
next_with_idle_timeout_or_cancel(&mut p2, 220, &cancel).await,
NextChunk::IdleTimeout
));
}
}