Brotli response compression never finishes the stream (flush instead of finish), strict decoders reject every `br` body
Describe the bug
Compressor::encode in pingora-core/src/protocols/http/compression/brotli.rs handles end == true by calling flush() on the brotli::CompressorWriter.
flush() runs BROTLI_OPERATION_FLUSH, which emits the pending data but never the final ISLAST meta-block.
Nothing ever runs BROTLI_OPERATION_FINISH (that only happens in into_inner() or Drop, and the output Vec has already been taken with mem::take by then).
The result is that every Content-Encoding: br body Pingora produces is a valid prefix of a brotli stream with no end-of-stream marker.
Lenient decoders (curl, Chrome, Firefox) return the full body and never complain, so this is easy to miss. Strict decoders reject it:
- Google's
brotliCLI:corrupt input - Node
zlib.brotliDecompressSync:Z_BUF_ERROR - Node
zlib.createBrotliDecompress()(whatfetch/ undici use):unexpected end of file
Node's fetch sends Accept-Encoding: gzip, deflate, br by default, so any Node client behind a Pingora proxy with ResponseCompressionBuilder enabled gets a failed body read on every response.
Gzip (try_finish()) and zstd finish their streams correctly; only brotli is affected.
The code on main today is unchanged from 0.9.0 in this respect.
Pingora info
pingora-core 0.9.0 (also main at the time of filing), brotli 8.0.4, Linux and macOS.
Steps to reproduce
Standalone, without a running proxy.
This does exactly what Compressor::encode does on end, and compares it with a finished stream.
Cargo.toml
[package]
name = "br-repro"
version = "0.0.0"
edition = "2021"
[dependencies]
brotli = "=8.0.4"
src/main.rs
use std::io::Write;
use brotli::CompressorWriter;
fn main() {
let body = b"<html><body>hello, world</body></html>".repeat(20);
// What pingora-core 0.9.0 does on `end`: write_all + flush, then take the Vec.
let mut w = CompressorWriter::new(Vec::new(), 4096, 6, 19);
w.write_all(&body).unwrap();
w.flush().unwrap();
let flushed = std::mem::take(w.get_mut());
// A finished stream: into_inner runs BROTLI_OPERATION_FINISH.
let mut w2 = CompressorWriter::new(Vec::new(), 4096, 6, 19);
w2.write_all(&body).unwrap();
let finished = w2.into_inner();
std::fs::write("flushed.br", &flushed).unwrap();
std::fs::write("finished.br", &finished).unwrap();
println!("flushed={} bytes finished={} bytes", flushed.len(), finished.len());
}
cargo run -q
# flushed=44 bytes finished=43 bytes
brotli -d -c flushed.br > /dev/null
# corrupt input [flushed.br] (exit 1)
brotli -d -c finished.br | wc -c
# 760 (exit 0)
node -e 'require("fs").createReadStream("flushed.br").pipe(require("zlib").createBrotliDecompress()).on("error", e => console.log(e.code, e.message)).resume()'
# Z_BUF_ERROR unexpected end of file
Appending the one-byte empty last meta-block (0x03, i.e. ISLAST=1, ISLASTEMPTY=1) to flushed.br makes it decode to bytes identical to finished.br, which confirms the only thing missing is the finish step.
Against a live Pingora proxy with ResponseCompressionBuilder::enable(6):
curl -sS -H 'Accept-Encoding: br' https://<any host behind pingora>/ \
| node -e 'process.stdin.pipe(require("zlib").createBrotliDecompress()).on("error", e => console.log(e.message)).resume()'
# unexpected end of file
The same request with Accept-Encoding: gzip decodes cleanly.
Expected behavior
When end is true the encoder should finish the stream so the output is a complete brotli stream that strict decoders accept, matching what the gzip compressor does with try_finish().
Observed results
strict decode errors
Source: cloudflare/pingora