From 845fe1b98f0368b35f2d613c87e27f37f044cd76 Mon Sep 17 00:00:00 2001 From: Till Rohrmann Date: Mon, 8 Jun 2026 15:49:02 +0200 Subject: [PATCH] fix: skip empty output buffers to avoid empty HTTP/2 DATA frames take_output returns an empty buffer when there is nothing to send but the output stream is not closed (e.g. while replaying already-completed entries on resume-after-suspend). The SDK forwarded this empty buffer to the response channel unconditionally, emitting one empty HTTP/2 DATA frame per replayed await. Proxies such as Envoy terminate connections with more than one consecutive empty-payload frame, so suspended invocations never resumed. Guard the three send sites (async_result_poll, select_poll, consume_to_end) to skip empty buffers. The EOF arm is unchanged, so a closed output still maps to UnexpectedOutputClosed. Fixes #114 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/endpoint/context.rs | 1 + src/endpoint/futures/async_result_poll.rs | 6 +++++- src/endpoint/futures/select_poll.rs | 6 +++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/endpoint/context.rs b/src/endpoint/context.rs index 0c43e36..9b65e64 100644 --- a/src/endpoint/context.rs +++ b/src/endpoint/context.rs @@ -757,6 +757,7 @@ impl ContextInternal { let out = inner_lock.vm.take_output(); if let TakeOutputResult::Buffer(b) = out + && !b.is_empty() && !inner_lock.write.send(b) { // Nothing we can do anymore here diff --git a/src/endpoint/futures/async_result_poll.rs b/src/endpoint/futures/async_result_poll.rs index e5baa37..80faf7d 100644 --- a/src/endpoint/futures/async_result_poll.rs +++ b/src/endpoint/futures/async_result_poll.rs @@ -59,7 +59,11 @@ impl Future for VmAsyncResultPollFuture { let out = inner_lock.vm.take_output(); match out { TakeOutputResult::Buffer(b) => { - if !inner_lock.write.send(b) { + // Skip empty buffers: take_output returns an empty buffer when there's + // nothing to send (e.g. while replaying completed entries). Sending it + // would emit an empty HTTP/2 DATA frame per replayed await, which some + // proxies (e.g. Envoy) reject when consecutive. See sdk-rust#114. + if !b.is_empty() && !inner_lock.write.send(b) { return Poll::Ready(Err(ErrorInner::Suspended)); } } diff --git a/src/endpoint/futures/select_poll.rs b/src/endpoint/futures/select_poll.rs index 7ec52a2..ea35b9a 100644 --- a/src/endpoint/futures/select_poll.rs +++ b/src/endpoint/futures/select_poll.rs @@ -60,7 +60,11 @@ impl Future for VmSelectAsyncResultPollFuture { let out = inner_lock.vm.take_output(); match out { TakeOutputResult::Buffer(b) => { - if !inner_lock.write.send(b) { + // Skip empty buffers: take_output returns an empty buffer when there's + // nothing to send (e.g. while replaying completed entries). Sending it + // would emit an empty HTTP/2 DATA frame per replayed await, which some + // proxies (e.g. Envoy) reject when consecutive. See sdk-rust#114. + if !b.is_empty() && !inner_lock.write.send(b) { return Poll::Ready(Err(ErrorInner::Suspended)); } }