mitm: flush response writes per-chunk so streaming responses are delivered live#197
Closed
devniel wants to merge 1 commit into
Closed
mitm: flush response writes per-chunk so streaming responses are delivered live#197devniel wants to merge 1 commit into
devniel wants to merge 1 commit into
Conversation
…ered live without throttling throughput
The forward path's io.Copy left chunk delivery to the ResponseWriter's default coalescing. For non-streaming responses (single JSON body with Content-Length) that's fine. For streaming protocols layered on top of HTTP, the client needs each upstream chunk to arrive promptly: git smart-HTTP sideband packs ('git clone' over HTTPS) idle-time-out and abort mid-pack with 'fatal: early EOF / fetch-pack: invalid index-pack output'; SSE event streams buffer until upstream closes the body; long-running gRPC-over-HTTP/2 streams stall similarly.
Use a periodic flusher (50ms) modeled on net/http/httputil.maxLatencyWriter — the same pattern ReverseProxy.FlushInterval uses for exactly this case.
Why not flush per Write: upstream Reads on chunked TLS can return MTU-sized fragments, and a syscall + TLS write per fragment caps throughput at ~tens of KB/s. Observed in production: a 282 MiB git pack that finishes in <2 minutes direct took >20 minutes through the proxy with per-Write flushing.
Two regression tests: TestMITMForwardStreamsChunksPromptly (correctness — chunk spread preserved end-to-end) and TestMITMForwardStreamsLargeBodyAtLineRate (throughput floor — 4 MiB must finish through the proxy in ≤5s; a flush-per-Write regression would take many seconds; full-buffering would be similar).
Also log copy errors with byte counts so silent truncation is no longer silent.
49429cf to
3ad57fd
Compare
12 tasks
saifsmailbox98
added a commit
that referenced
this pull request
Jun 2, 2026
…SE) (#229) ## Summary Streaming responses (git clone pack data, SSE event streams) were buffered by Go's `ResponseWriter` until the internal 4 KB buffer filled or the handler returned. Upstream chunk boundaries collapsed into a single blob — git's sideband parser stalled, SSE events arrived late, and any chunk-boundary-sensitive protocol broke. On top of that, the CONNECT tunnel's `WriteTimeout: 5 * time.Minute` killed any transfer that took longer than 5 minutes to stream. Concrete failure: `git clone` of `microsoft/TypeScript` through the proxy died with `fatal: early EOF` / `fetch-pack: unexpected disconnect while reading sideband packet`. Same root cause as external draft PR #197. **Per-chunk flushing** Wraps the `ResponseWriter` in a `flushingWriter` that calls `http.Flusher.Flush()` after every successful `Write`. `io.Copy` uses a 32 KB buffer, so this flushes every ≤32 KB — upstream chunk boundaries reach the client immediately instead of coalescing. Applied to both the HTTP forward path (`forward.go`) and the WebSocket non-101 fallback path (`websocket.go`). For non-streaming responses (single JSON body with `Content-Length`), this is effectively a no-op — `io.Copy` does one `Write` and one `Flush`, same as before. **CONNECT tunnel WriteTimeout raised to 30 minutes** The previous 5-minute `WriteTimeout` on the CONNECT tunnel's `http.Server` was an absolute deadline from request-header-read to response-complete. Any streaming response >5 minutes was killed. Raised to 30 minutes to cover realistic long-running transfers. Slow-loris defense is preserved by `ReadHeaderTimeout` (10s) and `ReadTimeout` (60s) on the request side, `IdleTimeout` (2 min) between requests, and the upstream transport's `ResponseHeaderTimeout` (5 min) for stalled upstreams. **WebSocket oversized-frame substitution warning** Added `slog.Warn` when a WebSocket text frame exceeds the 1 MB substitution buffer (`maxWSSubstitutionPayload`) and substitutions are configured. The existing fragmentation case already warned; the size case silently passed through. Supersedes #197. ## Type of change - [x] Bug fix - [ ] New feature - [ ] Refactor / cleanup - [ ] Documentation - [ ] CI / build ## Test plan - [x] Existing tests pass (`go test ./...` — all 20 packages green) - [x] Added/updated tests for new behavior: | Test | Validates | |------|-----------| | `TestMITMForwardStreamsChunksPromptly` | Upstream sends 5 chunks with 80ms delays; asserts inter-chunk arrival gaps ≥40ms at the client (proves per-chunk flushing, not batch delivery) | - [x] Manual testing: | Scenario | Result | |----------|--------| | Full `git clone` of `microsoft/TypeScript` through proxy | Completed: 3.4 GB, 81K files, ~6 min. Previously failed at 100 MB (body cap, fixed in #227) and at 5 min (WriteTimeout, fixed here). | | SSE drip test (`httpbin.org/drip?duration=5&numbytes=5`) through proxy | 5 bytes arrived 1/sec — same cadence as direct, no buffering. | ## Security checklist - [x] No secrets or credentials in code - [x] No new unauthenticated endpoints - [x] Input validation on new API surfaces - [x] Checked for OWASP top 10 (injection, XSS, etc.) --------- Co-authored-by: saif <11242541+saifsmailbox98@users.noreply.github.com>
Collaborator
|
superseded by #229 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The forward path in
internal/mitm/forward.gowrites the upstream response body with:That leaves chunk delivery to the
http.ResponseWriter's default coalescing. For non-streaming responses (single JSON body withContent-Length) that's fine — oneWrite, one delivery. For streaming protocols layered on top of HTTP, the client needs each upstream chunk to arrive promptly. When it doesn't, downstream parsers stall or abort mid-response.The concrete failure that prompted this PR:
git cloneover HTTPS through the proxy. After authentication (Basic / Bearer / api-key) is correctly injected, the actual pack transfer fails with:git's sideband pack-protocol parser is built on top of HTTP and uses its own framing within the response body. Without per-chunk flushing, byte boundaries from upstream collapse into a buffered blob, the parser hits a stall at the wrong point, and the connection is closed before the final packet arrives.
Same class of bug bites:
Change
Wrap the
ResponseWriterin aflushingWriterthat callsFlush()after every successfulWrite, so upstream chunk boundaries reach the client unbuffered:flushingWriteris dead-simple (Write → Flush) and doesn't change semantics for non-streaming responses —io.Copyalready does a singleWritein that case, so the extra Flush is a no-op.Regression test
TestMITMForwardStreamsChunksPromptly(new) wires an upstream that writes N chunks separated by 80ms delays, flushing each, and asserts the arrivals at the client are spread across time. On the priorio.Copy-only path, all chunks land in a single read after the upstream's final sleep — the test fails with:With the fix the spread is preserved and the test passes.
Verification
internal/mitm/test suite green (go test ./internal/mitm/... -count=1)git cloneof a real GitHub repo through the proxy completes successfully (was failing withearly EOFbefore)Opening as a draft to invite review on the approach. Two alternatives I considered and would happily switch to if the maintainers prefer:
httputil.ReverseProxy— swap the hand-rolled copy entirely. Larger blast radius, butFlushInterval=-1is the idiomatic Go answer for proxying streaming responses. I held off because the existing forward code is doing a few non-trivial things (LimitReader cap, custom header strip, request-log emission) that would need to be wired up viaModifyResponse/ErrorHandler/Transportcallbacks.http.NewResponseController(w).Flush()periodically on a timer, similar to ReverseProxy'sFlushInterval. Slightly more complex, marginally more efficient on tiny chunks. The per-Write Flush in this PR is plenty fast in practice and easier to reason about.