brotli-stream-termination
qproxy MITM truncates streamed brotli response bodies — client apps show `Decompression error: BrotliDecompressionError`
TL;DR
qproxy's MITM re-encodes every streamed content-encoded response body,
and its streaming brotli encoder never emits the final ISLAST metablock —
it only ever flush()es, unlike gzip/zstd which finish on the last chunk.
Tapped clients receive an unterminated brotli stream and fail with
Decompression error: BrotliDecompressionError (seen in Claude.app chat
and Claude Code CLI startup). Fix: finalize the CompressorWriter on
final_chunk in EncodeStage::Br::process (crates/qproxy/src/mitm/codec.rs
~492) via into_inner(), and add the missing br stream-roundtrip
regression test. Details, repro, and a related design question below.
Symptom
Claude.app (running tapped under qcontrol run) intermittently shows
Decompression error: BrotliDecompressionError in the chat window. Any
tapped client receiving a streamed response with Content-Encoding: br
through the MITM path is affected; chat completions are streamed, which is
why the chat window is where it surfaces.
Also observed 2026-09-01: Claude Code CLI (v2.1.252) shows
› Decompression error: BrotliDecompressionError immediately at startup
when launched from a terminal inside a tapped app (Zed under
qcontrol run) — the CLI inherits the injected proxy env
(HTTPS_PROXY=http://127.0.0.1:<port>, NODE_EXTRA_CA_CERTS/SSL_CERT_FILE
pointing at the qcontrol CA), so its API traffic rides the same MITM path.
Same root cause; confirms the bug is not specific to Claude.app.
The tap is easy to forget you're under
Follow-up observation (2026-09-01, same machine): the error kept appearing
while qcontrol appeared not to be running — no dashboard open, no
qcontrol start in any visible terminal. It was running: the
qcontrol monitor daemon persists as root for days (in this case since
Thursday), and Zed had been launched via qcontrol run that morning, so
every process spawned from inside Zed (terminals, CLIs, Claude Code)
silently inherited HTTPS_PROXY + the CA env and rode the MITM path.
Nothing in the affected app indicates it is tapped.
To check whether you're in this state: ps aux | grep qcontrol — look for
the root qcontrol monitor daemon and any qcontrol run / app-session
wrappers. Workarounds until the fix lands: launch the affected app from
outside the tapped app (so it doesn't inherit the proxy env), relaunch the
wrapper app without qcontrol run, or sudo qcontrol stop.
This is worth keeping in mind when triaging future reports: "qcontrol isn't even running" is not reliable evidence against the MITM being in the path.
Root cause
Two facts combine (all paths in the qcontrol repo, at commit 63fe84e1):
- Streamed bodies are always re-encoded, even when unmutated.
runtime_body_transformincrates/qproxy/src/mitm/mod.rs(~line 840) decodes content-encoded bodies so plugins see representation bytes, then in the(HttpBodyMode::Stream, decoded=true, _)match arm pushes the bytes back through the streamingBodyEncoderunconditionally — the third tuple element (mutated) is_. So every streamed brotli response is decoded and re-compressed by qproxy regardless of whether any plugin touched it. - The streaming brotli encoder never terminates the stream.
EncodeStage::Br::processincrates/qproxy/src/mitm/codec.rs(~line 492) ignores itsfinal_chunkparameter — it only ever callsflush(). Its siblings finalize correctly: gzip callstry_finish()and zstd callsdo_finish()whenfinal_chunkis true. A brotli stream must end with a final metablock (ISLAST bit), which thebrotli::CompressorWriteremits only on close/drop. The writer lives in the per-exchangeMitmContextand is never closed while its output can still be forwarded, so the terminator bytes are never sent.
Net effect: the client receives a brotli body that simply stops. Its
decoder hits end-of-body without a stream terminator and fails —
BrotliDecompressionError.
Why only brotli, and only streaming
- The buffered path is fine:
encode_stage()(codec.rs ~line 317) builds a scopedCompressorWriterthat is dropped at the end of the block, and drop finalizes the stream into the output Vec. - The decode side of brotli is fine:
Stage::Br::processcallsdecoder.close()on the final chunk (codec.rs ~line 430). - gzip and zstd streaming re-encode are fine (they finish on
final_chunk), and — tellingly — the test module at the bottom of codec.rs has*_stream_roundtrips_back_to_wire_representationtests for gzip and zstd but not br. The missing test is exactly the broken case.
Suggested fix
In EncodeStage::Br::process, when final_chunk is true, finalize the
compressor and return the remaining bytes. brotli::CompressorWriter
(workspace has 8.0.4) exposes into_inner(self) -> W, which flushes the
final metablock into the inner Vec<u8> — take the boxed writer out of the
enum variant (e.g. std::mem::replace with a terminal variant), call
into_inner(), and return the bytes past the previously-emitted length.
Mirror the shape gzip/zstd already use.
Add the missing regression test alongside the gzip/zstd ones:
br_stream_roundtrips_back_to_wire_representation — encode two chunks
(false then true for final_chunk), concatenate, and assert a fresh
brotli decoder can decode the result to completion. It fails against
current code (the decode errors or truncates at EOF) and passes with the
fix.
Worth a look while in there: whether the (Stream, true, _) arm should
skip re-encoding entirely when the body was not mutated (forward the
original wire bytes, as the buffered (Buffer, true, false) arm already
does). That would make unmutated streams byte-identical passthrough and
shrink the blast radius of any future codec bug, but it changes streaming
semantics (decoder state must stay in sync) — engineer's call, separate
from the one-line-ish termination fix.
Repro / verification
sudo qcontrol start, then run any chat app underqcontrol run(observed with Claude.app tapped via the app facade) and hold a conversation — errors appear in the chat window on brotli-encoded streamed responses.- Or unit-level: write the missing
brstream roundtrip test above and runcargo nextest run -p qproxy— it demonstrates the truncation without any live traffic.