From 803349de4f5cd2acba21d2cdd253fe58fd43189e Mon Sep 17 00:00:00 2001 From: Blake Emerson Date: Fri, 21 Aug 2026 18:03:59 +0000 Subject: [PATCH] fix(web): emit buffered client trailers when the response body ends The client decode path in GrpcWebCall::poll_frame buffers body bytes and, when a message frame and the gRPC-Web trailers frame land in the same buffer, parses the trailers into self.trailers and returns the data frame first. On the next poll the buffer is empty, find_trailers reports Done(0), and the stream ended with None without ever emitting the stored trailers, so tonic failed every such call with "protocol error: missing grpc-status trailer", even though the server sent a well-formed response. This is the common framing for unary responses (observed deterministically against Envoy's grpc-web filter), where the whole body arrives as one chunk. Take and emit the stored trailers in the Done(0) arm before ending the stream. Adds a regression test that polls a client_response body whose single chunk carries a message frame followed by the trailers frame, and asserts the data frame is followed by the trailers rather than end of stream. --- tonic-web/src/call.rs | 63 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/tonic-web/src/call.rs b/tonic-web/src/call.rs index 7afee2643..92914e0ef 100644 --- a/tonic-web/src/call.rs +++ b/tonic-web/src/call.rs @@ -313,7 +313,14 @@ where } FindTrailers::IncompleteBuf => continue, FindTrailers::Done(len) => Poll::Ready(match len { - 0 => None, + // Emit trailers that were decoded alongside data on an + // earlier poll before ending the stream + 0 => me + .as_mut() + .project() + .trailers + .take() + .map(|trailers| Ok(Frame::trailers(trailers))), _ => Some(Ok(Frame::data(buf.split_to(len).freeze()))), }), }; @@ -633,6 +640,60 @@ mod tests { assert_eq!(out.code(), Code::Internal); } + #[tokio::test] + async fn client_response_emits_trailers_buffered_behind_data() { + struct ChunkedBody(Vec); + + impl Body for ChunkedBody { + type Data = Bytes; + type Error = std::convert::Infallible; + + fn poll_frame( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + Poll::Ready(if self.0.is_empty() { + None + } else { + Some(Ok(Frame::data(self.0.remove(0)))) + }) + } + } + + let mut trailers = HeaderMap::new(); + trailers.insert(Status::GRPC_STATUS, 0.into()); + + // A message frame and the trailers frame in a single chunk, as servers + // commonly flush a unary response + let message: &[u8] = b"\0\0\0\0\x05hello"; + let mut chunk = BytesMut::from(message); + chunk.put(make_trailers_frame(trailers.clone())); + + let mut call = GrpcWebCall::client_response(ChunkedBody(vec![chunk.freeze()])); + + let data = std::future::poll_fn(|cx| Pin::new(&mut call).poll_frame(cx)) + .await + .expect("missing data frame") + .unwrap() + .into_data() + .unwrap(); + assert_eq!(&data[..], message); + + let got = std::future::poll_fn(|cx| Pin::new(&mut call).poll_frame(cx)) + .await + .expect("trailers were dropped at end of stream") + .unwrap() + .into_trailers() + .unwrap(); + assert_eq!(got, trailers); + + assert!( + std::future::poll_fn(|cx| Pin::new(&mut call).poll_frame(cx)) + .await + .is_none() + ); + } + #[test] fn decode_multiple_trailers() { let buf = b"\x80\0\0\0\x0fgrpc-status:0\r\ngrpc-message:\r\na:1\r\nb:2\r\n";