Cycles
Design · c80

brotli-stream-termination

activebug handoff — for qcontrol engineerscreated 2026-08-27· last activity 2026-09-11

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):

  1. Streamed bodies are always re-encoded, even when unmutated.runtime_body_transform in crates/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 streaming BodyEncoder unconditionally — 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.
  2. The streaming brotli encoder never terminates the stream.EncodeStage::Br::process in crates/qproxy/src/mitm/codec.rs (~line 492) ignores its final_chunk parameter — it only ever calls flush(). Its siblings finalize correctly: gzip calls try_finish() and zstd calls do_finish() when final_chunk is true. A brotli stream must end with a final metablock (ISLAST bit), which the brotli::CompressorWriter emits only on close/drop. The writer lives in the per-exchange MitmContext and 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 scoped CompressorWriter that 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::process calls decoder.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_representation tests 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

  1. sudo qcontrol start, then run any chat app under qcontrol 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.
  2. Or unit-level: write the missing br stream roundtrip test above and run cargo nextest run -p qproxy — it demonstrates the truncation without any live traffic.

Qpoint Brand Style Guide