[{"data":1,"prerenderedAt":715},["ShallowReactive",2],{"$ftXBL4K4Sa6PTyvFdiHnVK-oBPkS12ehwiFw04lgofRo":3},{"newThing":4,"project":5,"cycle":8,"sections":20,"threads":710,"otherDocs":711,"images":712,"archiveItems":713,"links":714},null,{"id":6,"label":7,"liveBase":4},"design","Design",{"id":9,"num":10,"dir":11,"name":12,"title":12,"focus":13,"status":14,"statusNote":15,"created":16,"completed":4,"mode":4,"recordless":17,"thumbnail":4,"imageCount":18,"artifactCount":18,"lastActivity":19,"hasPickup":17,"hasInitialPrompt":17},"c80",80,"c80.brotli-stream-termination","brotli-stream-termination","qproxy MITM truncates streamed brotli response bodies — client apps show `Decompression error: BrotliDecompressionError`","active","bug handoff — for qcontrol engineers","2026-08-27",false,0,"2026-09-11",[21,108,303,458,547,665],{"key":22,"title":23,"empty":17,"ast":24},"extra","TL;DR",{"type":25,"children":26},"root",[27],{"type":28,"tag":29,"props":30,"children":31},"element","p",{},[32,35,41,43,50,52,58,60,66,68,74,76,82,84,90,92,98,100,106],{"type":33,"value":34},"text","qproxy's MITM re-encodes every ",{"type":28,"tag":36,"props":37,"children":38},"strong",{},[39],{"type":33,"value":40},"streamed",{"type":33,"value":42}," content-encoded response body,\nand its streaming brotli encoder never emits the final ISLAST metablock —\nit only ever ",{"type":28,"tag":44,"props":45,"children":47},"code",{"className":46},[],[48],{"type":33,"value":49},"flush()",{"type":33,"value":51},"es, unlike gzip\u002Fzstd which finish on the last chunk.\nTapped clients receive an unterminated brotli stream and fail with\n",{"type":28,"tag":44,"props":53,"children":55},{"className":54},[],[56],{"type":33,"value":57},"Decompression error: BrotliDecompressionError",{"type":33,"value":59}," (seen in Claude.app chat\nand Claude Code CLI startup). Fix: finalize the ",{"type":28,"tag":44,"props":61,"children":63},{"className":62},[],[64],{"type":33,"value":65},"CompressorWriter",{"type":33,"value":67}," on\n",{"type":28,"tag":44,"props":69,"children":71},{"className":70},[],[72],{"type":33,"value":73},"final_chunk",{"type":33,"value":75}," in ",{"type":28,"tag":44,"props":77,"children":79},{"className":78},[],[80],{"type":33,"value":81},"EncodeStage::Br::process",{"type":33,"value":83}," (",{"type":28,"tag":44,"props":85,"children":87},{"className":86},[],[88],{"type":33,"value":89},"crates\u002Fqproxy\u002Fsrc\u002Fmitm\u002Fcodec.rs",{"type":33,"value":91},"\n~492) via ",{"type":28,"tag":44,"props":93,"children":95},{"className":94},[],[96],{"type":33,"value":97},"into_inner()",{"type":33,"value":99},", and add the missing ",{"type":28,"tag":44,"props":101,"children":103},{"className":102},[],[104],{"type":33,"value":105},"br",{"type":33,"value":107}," stream-roundtrip\nregression test. Details, repro, and a related design question below.",{"key":22,"title":109,"empty":17,"ast":110},"Symptom",{"type":25,"children":111},[112,146,197,204,248,298],{"type":28,"tag":29,"props":113,"children":114},{},[115,117,123,125,130,132,136,138,144],{"type":33,"value":116},"Claude.app (running tapped under ",{"type":28,"tag":44,"props":118,"children":120},{"className":119},[],[121],{"type":33,"value":122},"qcontrol run",{"type":33,"value":124},") intermittently shows\n",{"type":28,"tag":44,"props":126,"children":128},{"className":127},[],[129],{"type":33,"value":57},{"type":33,"value":131}," in the chat window. Any\ntapped client receiving a ",{"type":28,"tag":36,"props":133,"children":134},{},[135],{"type":33,"value":40},{"type":33,"value":137}," response with ",{"type":28,"tag":44,"props":139,"children":141},{"className":140},[],[142],{"type":33,"value":143},"Content-Encoding: br",{"type":33,"value":145},"\nthrough the MITM path is affected; chat completions are streamed, which is\nwhy the chat window is where it surfaces.",{"type":28,"tag":29,"props":147,"children":148},{},[149,151,156,158,164,166,171,173,179,181,187,189,195],{"type":33,"value":150},"Also observed 2026-09-01: ",{"type":28,"tag":36,"props":152,"children":153},{},[154],{"type":33,"value":155},"Claude Code CLI",{"type":33,"value":157}," (v2.1.252) shows\n",{"type":28,"tag":44,"props":159,"children":161},{"className":160},[],[162],{"type":33,"value":163},"› Decompression error: BrotliDecompressionError",{"type":33,"value":165}," immediately at startup\nwhen launched from a terminal inside a tapped app (Zed under\n",{"type":28,"tag":44,"props":167,"children":169},{"className":168},[],[170],{"type":33,"value":122},{"type":33,"value":172},") — the CLI inherits the injected proxy env\n(",{"type":28,"tag":44,"props":174,"children":176},{"className":175},[],[177],{"type":33,"value":178},"HTTPS_PROXY=http:\u002F\u002F127.0.0.1:\u003Cport>",{"type":33,"value":180},", ",{"type":28,"tag":44,"props":182,"children":184},{"className":183},[],[185],{"type":33,"value":186},"NODE_EXTRA_CA_CERTS",{"type":33,"value":188},"\u002F",{"type":28,"tag":44,"props":190,"children":192},{"className":191},[],[193],{"type":33,"value":194},"SSL_CERT_FILE",{"type":33,"value":196},"\npointing at the qcontrol CA), so its API traffic rides the same MITM path.\nSame root cause; confirms the bug is not specific to Claude.app.",{"type":28,"tag":198,"props":199,"children":201},"h3",{"id":200},"the-tap-is-easy-to-forget-youre-under",[202],{"type":33,"value":203},"The tap is easy to forget you're under",{"type":28,"tag":29,"props":205,"children":206},{},[207,209,215,217,223,225,231,233,238,240,246],{"type":33,"value":208},"Follow-up observation (2026-09-01, same machine): the error kept appearing\nwhile qcontrol ",{"type":28,"tag":210,"props":211,"children":212},"em",{},[213],{"type":33,"value":214},"appeared",{"type":33,"value":216}," not to be running — no dashboard open, no\n",{"type":28,"tag":44,"props":218,"children":220},{"className":219},[],[221],{"type":33,"value":222},"qcontrol start",{"type":33,"value":224}," in any visible terminal. It was running: the\n",{"type":28,"tag":44,"props":226,"children":228},{"className":227},[],[229],{"type":33,"value":230},"qcontrol monitor",{"type":33,"value":232}," daemon persists as root for days (in this case since\nThursday), and Zed had been launched via ",{"type":28,"tag":44,"props":234,"children":236},{"className":235},[],[237],{"type":33,"value":122},{"type":33,"value":239}," that morning, so\nevery process spawned from inside Zed (terminals, CLIs, Claude Code)\nsilently inherited ",{"type":28,"tag":44,"props":241,"children":243},{"className":242},[],[244],{"type":33,"value":245},"HTTPS_PROXY",{"type":33,"value":247}," + the CA env and rode the MITM path.\nNothing in the affected app indicates it is tapped.",{"type":28,"tag":29,"props":249,"children":250},{},[251,253,259,261,266,268,273,275,281,283,288,290,296],{"type":33,"value":252},"To check whether you're in this state: ",{"type":28,"tag":44,"props":254,"children":256},{"className":255},[],[257],{"type":33,"value":258},"ps aux | grep qcontrol",{"type":33,"value":260}," — look for\nthe root ",{"type":28,"tag":44,"props":262,"children":264},{"className":263},[],[265],{"type":33,"value":230},{"type":33,"value":267}," daemon and any ",{"type":28,"tag":44,"props":269,"children":271},{"className":270},[],[272],{"type":33,"value":122},{"type":33,"value":274}," \u002F ",{"type":28,"tag":44,"props":276,"children":278},{"className":277},[],[279],{"type":33,"value":280},"app-session",{"type":33,"value":282},"\nwrappers. Workarounds until the fix lands: launch the affected app from\noutside the tapped app (so it doesn't inherit the proxy env), relaunch the\nwrapper app without ",{"type":28,"tag":44,"props":284,"children":286},{"className":285},[],[287],{"type":33,"value":122},{"type":33,"value":289},", or ",{"type":28,"tag":44,"props":291,"children":293},{"className":292},[],[294],{"type":33,"value":295},"sudo qcontrol stop",{"type":33,"value":297},".",{"type":28,"tag":29,"props":299,"children":300},{},[301],{"type":33,"value":302},"This is worth keeping in mind when triaging future reports: \"qcontrol\nisn't even running\" is not reliable evidence against the MITM being in\nthe path.",{"key":22,"title":304,"empty":17,"ast":305},"Root cause",{"type":25,"children":306},[307,320,446],{"type":28,"tag":29,"props":308,"children":309},{},[310,312,318],{"type":33,"value":311},"Two facts combine (all paths in the qcontrol repo, at commit ",{"type":28,"tag":44,"props":313,"children":315},{"className":314},[],[316],{"type":33,"value":317},"63fe84e1",{"type":33,"value":319},"):",{"type":28,"tag":321,"props":322,"children":323},"ol",{},[324,372],{"type":28,"tag":325,"props":326,"children":327},"li",{},[328,333,339,340,346,348,354,356,362,364,370],{"type":28,"tag":36,"props":329,"children":330},{},[331],{"type":33,"value":332},"Streamed bodies are always re-encoded, even when unmutated.",{"type":28,"tag":44,"props":334,"children":336},{"className":335},[],[337],{"type":33,"value":338},"runtime_body_transform",{"type":33,"value":75},{"type":28,"tag":44,"props":341,"children":343},{"className":342},[],[344],{"type":33,"value":345},"crates\u002Fqproxy\u002Fsrc\u002Fmitm\u002Fmod.rs",{"type":33,"value":347}," (~line 840)\ndecodes content-encoded bodies so plugins see representation bytes, then\nin the ",{"type":28,"tag":44,"props":349,"children":351},{"className":350},[],[352],{"type":33,"value":353},"(HttpBodyMode::Stream, decoded=true, _)",{"type":33,"value":355}," match arm pushes the\nbytes back through the streaming ",{"type":28,"tag":44,"props":357,"children":359},{"className":358},[],[360],{"type":33,"value":361},"BodyEncoder",{"type":33,"value":363}," unconditionally — the\nthird tuple element (mutated) is ",{"type":28,"tag":44,"props":365,"children":367},{"className":366},[],[368],{"type":33,"value":369},"_",{"type":33,"value":371},". So every streamed brotli response\nis decoded and re-compressed by qproxy regardless of whether any plugin\ntouched it.",{"type":28,"tag":325,"props":373,"children":374},{},[375,380,385,386,391,393,398,400,405,407,413,415,421,423,428,430,436,438,444],{"type":28,"tag":36,"props":376,"children":377},{},[378],{"type":33,"value":379},"The streaming brotli encoder never terminates the stream.",{"type":28,"tag":44,"props":381,"children":383},{"className":382},[],[384],{"type":33,"value":81},{"type":33,"value":75},{"type":28,"tag":44,"props":387,"children":389},{"className":388},[],[390],{"type":33,"value":89},{"type":33,"value":392},"\n(~line 492) ignores its ",{"type":28,"tag":44,"props":394,"children":396},{"className":395},[],[397],{"type":33,"value":73},{"type":33,"value":399}," parameter — it only ever calls\n",{"type":28,"tag":44,"props":401,"children":403},{"className":402},[],[404],{"type":33,"value":49},{"type":33,"value":406},". Its siblings finalize correctly: gzip calls ",{"type":28,"tag":44,"props":408,"children":410},{"className":409},[],[411],{"type":33,"value":412},"try_finish()",{"type":33,"value":414},"\nand zstd calls ",{"type":28,"tag":44,"props":416,"children":418},{"className":417},[],[419],{"type":33,"value":420},"do_finish()",{"type":33,"value":422}," when ",{"type":28,"tag":44,"props":424,"children":426},{"className":425},[],[427],{"type":33,"value":73},{"type":33,"value":429}," is true. A brotli stream\nmust end with a final metablock (ISLAST bit), which the\n",{"type":28,"tag":44,"props":431,"children":433},{"className":432},[],[434],{"type":33,"value":435},"brotli::CompressorWriter",{"type":33,"value":437}," emits only on close\u002Fdrop. The writer lives in\nthe per-exchange ",{"type":28,"tag":44,"props":439,"children":441},{"className":440},[],[442],{"type":33,"value":443},"MitmContext",{"type":33,"value":445}," and is never closed while its output can\nstill be forwarded, so the terminator bytes are never sent.",{"type":28,"tag":29,"props":447,"children":448},{},[449,451,457],{"type":33,"value":450},"Net effect: the client receives a brotli body that simply stops. Its\ndecoder hits end-of-body without a stream terminator and fails —\n",{"type":28,"tag":44,"props":452,"children":454},{"className":453},[],[455],{"type":33,"value":456},"BrotliDecompressionError",{"type":33,"value":297},{"key":22,"title":459,"empty":17,"ast":460},"Why only brotli, and only streaming",{"type":25,"children":461},[462],{"type":28,"tag":463,"props":464,"children":465},"ul",{},[466,493,520],{"type":28,"tag":325,"props":467,"children":468},{},[469,471,476,478,484,486,491],{"type":33,"value":470},"The ",{"type":28,"tag":36,"props":472,"children":473},{},[474],{"type":33,"value":475},"buffered",{"type":33,"value":477}," path is fine: ",{"type":28,"tag":44,"props":479,"children":481},{"className":480},[],[482],{"type":33,"value":483},"encode_stage()",{"type":33,"value":485}," (codec.rs ~line 317)\nbuilds a scoped ",{"type":28,"tag":44,"props":487,"children":489},{"className":488},[],[490],{"type":33,"value":65},{"type":33,"value":492}," that is dropped at the end of the\nblock, and drop finalizes the stream into the output Vec.",{"type":28,"tag":325,"props":494,"children":495},{},[496,497,502,504,510,512,518],{"type":33,"value":470},{"type":28,"tag":36,"props":498,"children":499},{},[500],{"type":33,"value":501},"decode",{"type":33,"value":503}," side of brotli is fine: ",{"type":28,"tag":44,"props":505,"children":507},{"className":506},[],[508],{"type":33,"value":509},"Stage::Br::process",{"type":33,"value":511}," calls\n",{"type":28,"tag":44,"props":513,"children":515},{"className":514},[],[516],{"type":33,"value":517},"decoder.close()",{"type":33,"value":519}," on the final chunk (codec.rs ~line 430).",{"type":28,"tag":325,"props":521,"children":522},{},[523,525,530,532,538,540,545],{"type":33,"value":524},"gzip and zstd streaming re-encode are fine (they finish on\n",{"type":28,"tag":44,"props":526,"children":528},{"className":527},[],[529],{"type":33,"value":73},{"type":33,"value":531},"), and — tellingly — the test module at the bottom of\ncodec.rs has ",{"type":28,"tag":44,"props":533,"children":535},{"className":534},[],[536],{"type":33,"value":537},"*_stream_roundtrips_back_to_wire_representation",{"type":33,"value":539}," tests for\n",{"type":28,"tag":36,"props":541,"children":542},{},[543],{"type":33,"value":544},"gzip and zstd but not br",{"type":33,"value":546},". The missing test is exactly the broken\ncase.",{"key":22,"title":548,"empty":17,"ast":549},"Suggested fix",{"type":25,"children":550},[551,608,644],{"type":28,"tag":29,"props":552,"children":553},{},[554,556,561,563,568,570,575,577,583,585,591,593,599,601,606],{"type":33,"value":555},"In ",{"type":28,"tag":44,"props":557,"children":559},{"className":558},[],[560],{"type":33,"value":81},{"type":33,"value":562},", when ",{"type":28,"tag":44,"props":564,"children":566},{"className":565},[],[567],{"type":33,"value":73},{"type":33,"value":569}," is true, finalize the\ncompressor and return the remaining bytes. ",{"type":28,"tag":44,"props":571,"children":573},{"className":572},[],[574],{"type":33,"value":435},{"type":33,"value":576},"\n(workspace has 8.0.4) exposes ",{"type":28,"tag":44,"props":578,"children":580},{"className":579},[],[581],{"type":33,"value":582},"into_inner(self) -> W",{"type":33,"value":584},", which flushes the\nfinal metablock into the inner ",{"type":28,"tag":44,"props":586,"children":588},{"className":587},[],[589],{"type":33,"value":590},"Vec\u003Cu8>",{"type":33,"value":592}," — take the boxed writer out of the\nenum variant (e.g. ",{"type":28,"tag":44,"props":594,"children":596},{"className":595},[],[597],{"type":33,"value":598},"std::mem::replace",{"type":33,"value":600}," with a terminal variant), call\n",{"type":28,"tag":44,"props":602,"children":604},{"className":603},[],[605],{"type":33,"value":97},{"type":33,"value":607},", and return the bytes past the previously-emitted length.\nMirror the shape gzip\u002Fzstd already use.",{"type":28,"tag":29,"props":609,"children":610},{},[611,613,619,621,627,629,635,637,642],{"type":33,"value":612},"Add the missing regression test alongside the gzip\u002Fzstd ones:\n",{"type":28,"tag":44,"props":614,"children":616},{"className":615},[],[617],{"type":33,"value":618},"br_stream_roundtrips_back_to_wire_representation",{"type":33,"value":620}," — encode two chunks\n(",{"type":28,"tag":44,"props":622,"children":624},{"className":623},[],[625],{"type":33,"value":626},"false",{"type":33,"value":628}," then ",{"type":28,"tag":44,"props":630,"children":632},{"className":631},[],[633],{"type":33,"value":634},"true",{"type":33,"value":636}," for ",{"type":28,"tag":44,"props":638,"children":640},{"className":639},[],[641],{"type":33,"value":73},{"type":33,"value":643},"), concatenate, and assert a fresh\nbrotli decoder can decode the result to completion. It fails against\ncurrent code (the decode errors or truncates at EOF) and passes with the\nfix.",{"type":28,"tag":29,"props":645,"children":646},{},[647,649,655,657,663],{"type":33,"value":648},"Worth a look while in there: whether the ",{"type":28,"tag":44,"props":650,"children":652},{"className":651},[],[653],{"type":33,"value":654},"(Stream, true, _)",{"type":33,"value":656}," arm should\nskip re-encoding entirely when the body was not mutated (forward the\noriginal wire bytes, as the buffered ",{"type":28,"tag":44,"props":658,"children":660},{"className":659},[],[661],{"type":33,"value":662},"(Buffer, true, false)",{"type":33,"value":664}," arm already\ndoes). That would make unmutated streams byte-identical passthrough and\nshrink the blast radius of any future codec bug, but it changes streaming\nsemantics (decoder state must stay in sync) — engineer's call, separate\nfrom the one-line-ish termination fix.",{"key":22,"title":666,"empty":17,"ast":667},"Repro \u002F verification",{"type":25,"children":668},[669],{"type":28,"tag":321,"props":670,"children":671},{},[672,690],{"type":28,"tag":325,"props":673,"children":674},{},[675,681,683,688],{"type":28,"tag":44,"props":676,"children":678},{"className":677},[],[679],{"type":33,"value":680},"sudo qcontrol start",{"type":33,"value":682},", then run any chat app under ",{"type":28,"tag":44,"props":684,"children":686},{"className":685},[],[687],{"type":33,"value":122},{"type":33,"value":689},"\n(observed with Claude.app tapped via the app facade) and hold a\nconversation — errors appear in the chat window on brotli-encoded\nstreamed responses.",{"type":28,"tag":325,"props":691,"children":692},{},[693,695,700,702,708],{"type":33,"value":694},"Or unit-level: write the missing ",{"type":28,"tag":44,"props":696,"children":698},{"className":697},[],[699],{"type":33,"value":105},{"type":33,"value":701}," stream roundtrip test above and\nrun ",{"type":28,"tag":44,"props":703,"children":705},{"className":704},[],[706],{"type":33,"value":707},"cargo nextest run -p qproxy",{"type":33,"value":709}," — it demonstrates the truncation\nwithout any live traffic.",[],[],[],[],[],1789151073936]