diff --git a/pingora-core/src/protocols/http/v1/body.rs b/pingora-core/src/protocols/http/v1/body.rs index e2ee27a0..300aa46b 100644 --- a/pingora-core/src/protocols/http/v1/body.rs +++ b/pingora-core/src/protocols/http/v1/body.rs @@ -1246,6 +1246,15 @@ impl BodyWriter { BM::ChunkedEncoding(written) => { let chunk_size = buf.len(); + // A zero-length chunk encodes as "0\r\n\r\n", which is the + // chunked terminator: the peer would treat it as end of body + // and parse whatever follows (including the terminator that + // finish() writes) as the start of the next message. Skip it; + // finish() is the only place that ends the body. + if chunk_size == 0 { + return Ok(Some(0)); + } + let chuck_size_buf = format!("{:X}\r\n", chunk_size); let mut output_buf = Bytes::from(chuck_size_buf).chain(buf).chain(&b"\r\n"[..]); stream @@ -1737,6 +1746,15 @@ impl BodyWriter { if let Some(bytes) = self.send_body_state.pending_bytes.take() { let application_bytes_size = bytes.len(); + // A zero-length chunk encodes as "0\r\n\r\n", the chunked + // terminator: writing it here would end the body early and + // desync the connection (see do_write_chunked_body). Skip it; + // only the finish path writes the terminator. + if application_bytes_size == 0 { + self.send_body_state.write_state = WriteState::Done(0); + return Poll::Ready(Ok(Some(0))); + } + // Format the chunk: size\r\ndata\r\n let chunk_size_header = format!("{:X}\r\n", application_bytes_size); let output_buf = Bytes::from(chunk_size_header) @@ -3084,6 +3102,70 @@ mod tests { assert_eq!(body_writer.body_mode, BodyMode::Complete(data.len() * 2)); } + #[tokio::test] + async fn write_body_chunked_ignores_empty_chunk() { + init_log(); + let data = b"abcdefghij"; + let output = b"A\r\nabcdefghij\r\n"; + // The mock expects no write between the data chunk and finish(): + // an empty chunk would encode as "0\r\n\r\n" — the terminator — + // ending the body early and desyncing the peer's parser. + let mut mock_io = Builder::new() + .write(&output[..]) + .write(&LAST_CHUNK[..]) + .build(); + let mut body_writer = BodyWriter::new(); + body_writer.init_chunked(); + let res = body_writer + .write_body(&mut mock_io, &data[..]) + .await + .unwrap() + .unwrap(); + assert_eq!(res, data.len()); + let res = body_writer + .write_body(&mut mock_io, b"") + .await + .unwrap() + .unwrap(); + assert_eq!(res, 0); + assert_eq!(body_writer.body_mode, BodyMode::ChunkedEncoding(data.len())); + let res = body_writer.finish(&mut mock_io).await.unwrap().unwrap(); + assert_eq!(res, data.len()); + assert_eq!(body_writer.body_mode, BodyMode::Complete(data.len())); + } + + #[tokio::test] + async fn write_body_task_chunked_ignores_empty_chunk() { + init_log(); + let data = b"abcdefghij"; + let output = b"A\r\nabcdefghij\r\n"; + let mut mock_io = Builder::new() + .write(&output[..]) + .write(&LAST_CHUNK[..]) + .build(); + let mut body_writer = BodyWriter::new(); + body_writer.init_chunked(); + body_writer.send_body_task(Bytes::from_static(data), None); + let res = body_writer + .write_current_body_task(&mut mock_io) + .await + .unwrap() + .unwrap(); + assert_eq!(res, data.len()); + // an empty task must complete without emitting the terminator + body_writer.send_body_task(Bytes::new(), None); + let res = body_writer + .write_current_body_task(&mut mock_io) + .await + .unwrap() + .unwrap(); + assert_eq!(res, 0); + assert_eq!(body_writer.body_mode, BodyMode::ChunkedEncoding(data.len())); + let res = body_writer.finish(&mut mock_io).await.unwrap().unwrap(); + assert_eq!(res, data.len()); + assert_eq!(body_writer.body_mode, BodyMode::Complete(data.len())); + } + #[tokio::test] async fn write_body_until_close() { init_log();