fix(sse): loop instead of recursing when skipping SSE events (#1146)

`SseAutoReconnectStream::poll_next` recursed into itself for every event it
skips — control frames, data-less frames, and frames whose data fails to
deserialize — plus once more on every state transition.

The inner stream returns `Poll::Ready` for each event it can parse out of
already-buffered bytes, so a burst of skipped events has no yield point
between them. Each one adds a stack frame, and `poll_next` is a large frame.
A client connected to a server that emits a run of non-JSON `message` frames
overflows the stack and aborts the process.

Wrap the body in a `loop` and replace the four `self.poll_next(cx)` tail calls
with `continue`. `this` is re-derived from `self.as_mut().project()` at the top
of each iteration, so the borrows end cleanly per iteration; no other logic
changes. The diff is mostly the resulting re-indent — review with `?w=1`.

Adds `skipped_events_do_not_grow_the_stack`, which feeds 50,000 undeserializable
frames through the stream. Before this change it aborts with
`fatal runtime error: stack overflow` (SIGABRT); after, it passes.
This commit is contained in:
Jeremy Schoemaker 2026-08-07 09:34:18 -05:00 • committed by GitHub
commit 3c8fb2a7e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -376,6 +376,7 @@ where
mut self: Pin<&mut Self>, mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>, cx: &mut std::task::Context<'_>,
) -> Poll<Option<Self::Item>> { ) -> Poll<Option<Self::Item>> {
loop {
let mut this = self.as_mut().project(); let mut this = self.as_mut().project();
// let this_state = this.state.as_mut().project() // let this_state = this.state.as_mut().project()
let state = this.state.as_mut().project(); let state = this.state.as_mut().project();
@ -397,7 +398,7 @@ where
matches!(sse.event.as_deref(), None | Some("") | Some("message")); matches!(sse.event.as_deref(), None | Some("") | Some("message"));
if !is_message_event { if !is_message_event {
match this.connector.handle_control_event(&sse) { match this.connector.handle_control_event(&sse) {
Ok(()) => return self.poll_next(cx), Ok(()) => continue,
Err(e) => { Err(e) => {
this.state.set(SseAutoReconnectStreamState::Terminated); this.state.set(SseAutoReconnectStreamState::Terminated);
return Poll::Ready(Some(Err(e))); return Poll::Ready(Some(Err(e)));
@ -412,24 +413,28 @@ where
// to aid troubleshooting while keeping default behaviour. // to aid troubleshooting while keeping default behaviour.
let last_id = this.last_event_id.as_deref().unwrap_or(""); let last_id = this.last_event_id.as_deref().unwrap_or("");
tracing::debug!(last_event_id=%last_id, "failed to deserialize server message: {e}"); tracing::debug!(last_event_id=%last_id, "failed to deserialize server message: {e}");
return self.poll_next(cx); continue;
} }
Ok(message) => { Ok(message) => {
return Poll::Ready(Some(Ok(message))); return Poll::Ready(Some(Ok(message)));
} }
}; };
} else { } else {
return self.poll_next(cx); continue;
} }
} }
Some(Err(e)) => { Some(Err(e)) => {
if is_event_too_large_error(&e) { if is_event_too_large_error(&e) {
this.state.set(SseAutoReconnectStreamState::Terminated); this.state.set(SseAutoReconnectStreamState::Terminated);
return Poll::Ready(this.connector.map_fatal_stream_error(e).map(Err)); return Poll::Ready(
this.connector.map_fatal_stream_error(e).map(Err),
);
} }
if *this.reconnect_only_after_event_id && this.last_event_id.is_none() { if *this.reconnect_only_after_event_id && this.last_event_id.is_none() {
this.state.set(SseAutoReconnectStreamState::Terminated); this.state.set(SseAutoReconnectStreamState::Terminated);
return Poll::Ready(this.connector.map_fatal_stream_error(e).map(Err)); return Poll::Ready(
this.connector.map_fatal_stream_error(e).map(Err),
);
} }
this.connector this.connector
.handle_stream_error(&e, this.last_event_id.as_deref()); .handle_stream_error(&e, this.last_event_id.as_deref());
@ -457,7 +462,10 @@ where
.take() .take()
.or_else(|| this.retry_policy.retry(0)); .or_else(|| this.retry_policy.retry(0));
if let Some(interval) = interval { if let Some(interval) = interval {
tracing::debug!(?interval, "sse stream ended gracefully, reconnecting"); tracing::debug!(
?interval,
"sse stream ended gracefully, reconnecting"
);
SseAutoReconnectStreamState::WaitingNextRetry { SseAutoReconnectStreamState::WaitingNextRetry {
sleep: tokio::time::sleep(interval), sleep: tokio::time::sleep(interval),
retry_times: 0, retry_times: 0,
@ -475,14 +483,18 @@ where
} => { } => {
let retry_result = ready!(retrying.poll(cx)); let retry_result = ready!(retrying.poll(cx));
match retry_result { match retry_result {
Ok(new_stream) => SseAutoReconnectStreamState::Connected { stream: new_stream }, Ok(new_stream) => {
SseAutoReconnectStreamState::Connected { stream: new_stream }
}
Err(e) => { Err(e) => {
tracing::debug!("retry sse stream error: {e}"); tracing::debug!("retry sse stream error: {e}");
*retry_times += 1; *retry_times += 1;
if let Some(interval) = this.retry_policy.retry(*retry_times) { if let Some(interval) = this.retry_policy.retry(*retry_times) {
let interval = this let interval = this
.server_retry_interval .server_retry_interval
.map(|server_retry_interval| server_retry_interval.max(interval)) .map(|server_retry_interval| {
server_retry_interval.max(interval)
})
.unwrap_or(interval); .unwrap_or(interval);
let sleep = tokio::time::sleep(interval); let sleep = tokio::time::sleep(interval);
SseAutoReconnectStreamState::WaitingNextRetry { SseAutoReconnectStreamState::WaitingNextRetry {
@ -514,7 +526,7 @@ where
}; };
// update the state // update the state
this.state.set(next_state); this.state.set(next_state);
self.poll_next(cx) }
} }
} }
@ -716,6 +728,34 @@ mod tests {
); );
} }
#[tokio::test]
async fn skipped_events_do_not_grow_the_stack() {
// Every frame here fails to deserialize, so each one takes the "skip this
// event" path. The inner stream is always ready, so nothing yields in
// between: when that path recursed into poll_next instead of looping, this
// overflowed the stack and aborted the process.
const SKIPPED_EVENTS: usize = 50_000;
let mut payload = Vec::with_capacity(SKIPPED_EVENTS * 9);
for _ in 0..SKIPPED_EVENTS {
payload.extend_from_slice(b"data: x\n\n");
}
let source = futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from(payload))]);
let attempts = Arc::new(AtomicUsize::new(0));
let connector = CountingReconnect {
attempts: attempts.clone(),
};
let stream = SseAutoReconnectStream::new(
bounded_sse_stream(source, 1024),
connector,
Arc::new(NeverRetry),
);
let mut stream = std::pin::pin!(stream);
assert!(stream.next().await.is_none());
assert_eq!(attempts.load(Ordering::Relaxed), 0);
}
#[tokio::test] #[tokio::test]
async fn response_without_event_id_does_not_reconnect() { async fn response_without_event_id_does_not_reconnect() {
let attempts = Arc::new(AtomicUsize::new(0)); let attempts = Arc::new(AtomicUsize::new(0));