From f0a134e532c13ed60f865267cfe2783aa73a39c3 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 23 Oct 2026 18:19:39 +0200 Subject: [PATCH] fix(api): keep relaying the request body after the sandbox answers Go's HTTP/1 server drains and closes the inbound request body when the response starts, unless the handler enables full duplex. ReverseProxy hands that same body to the transport as the outbound request body, and the transport reads it twice: the Content-Length bytes, then once more expecting io.EOF. When the backend answers in between (envd's Connect process.Process/Start does when the host is loaded), the server closes the body first, the transport's request write fails, it closes the upstream connection, and ReverseProxy aborts the half-sent response: httputil: ReverseProxy read error during body copy: read tcp ...: use of closed network connection The client sees the stream cut off mid-response. See golang/go#40747. Enable full duplex on every proxied request so the body stays readable for the transport after the response begins. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 +++++++++ internal/api/proxy.go | 6 +++++ internal/api/proxy_test.go | 54 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a5a2f..4f222ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to edvabe land here. Format roughly follows ## Unreleased +### Fixed + +- **Sandbox proxy no longer cuts streams that start while the request body + is still being relayed.** Go's HTTP/1 server closed the inbound request + body as soon as the proxied response began. When the backend answered + before the transport's final EOF read of that body (envd's Connect + `process.Process/Start` does this when the host is busy), the transport + failed the request write, closed the upstream connection, and the client + saw the stream drop (`ReverseProxy read error during body copy: … use of + closed network connection`). The proxy now enables full duplex on every + request, so the body keeps flowing upstream. + ## v0.4.0 — 2026-06-05 ### Added diff --git a/internal/api/proxy.go b/internal/api/proxy.go index bc41023..d298a99 100644 --- a/internal/api/proxy.go +++ b/internal/api/proxy.go @@ -94,6 +94,12 @@ func NewProxy(lookup SandboxLookup, resolver AgentResolver) http.Handler { return } + // Without full duplex, Go's HTTP/1 server closes the request body once the + // response starts — while the transport may still be forwarding it — which + // drops the upstream connection and cuts the stream (golang/go#40747). + // HTTP/2 is always full duplex and reports ErrNotSupported here. + _ = http.NewResponseController(w).EnableFullDuplex() + ctx := context.WithValue(r.Context(), proxyTargetKey{}, target) rp.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/internal/api/proxy_test.go b/internal/api/proxy_test.go index cbad1a5..9c35bd2 100644 --- a/internal/api/proxy_test.go +++ b/internal/api/proxy_test.go @@ -4,6 +4,7 @@ import ( "bufio" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "net/url" @@ -306,3 +307,56 @@ func TestProxyStreamsResponse(t *testing.T) { t.Errorf("chunks too close together: t1=%v t2=%v gap=%v want>=%v", t1, t2, gap, chunkDelay-50*time.Millisecond) } } + +// TestProxyStreamsResponseWhileRequestBodyInFlight covers a response that +// starts before the transport has finished forwarding the request body. Go's +// HTTP/1 server closes the inbound body when the response starts unless the +// handler enables full duplex; that body is the outbound one too, so the +// transport's write fails, it drops the upstream connection and the client +// sees the response cut off mid-stream (golang/go#40747). Repeated because +// each attempt only loses that race some of the time. +func TestProxyStreamsResponseWhileRequestBodyInFlight(t *testing.T) { + const body = "0123456789" + + host, port, closeBackend := newBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := http.NewResponseController(w).EnableFullDuplex(); err != nil { + t.Errorf("backend full duplex: %v", err) + } + w.WriteHeader(200) + _, _ = w.Write([]byte("start\n")) + w.(http.Flusher).Flush() + got, err := io.ReadAll(r.Body) + if err != nil || string(got) != body { + t.Errorf("backend body = %q, %v", got, err) + } + _, _ = w.Write([]byte("end\n")) + })) + defer closeBackend() + + mgr := &fakeLookup{byID: map[string]*sandbox.Sandbox{"isb_d": {ID: "isb_d"}}} + rt := &fakeResolver{host: host, port: port} + front := httptest.NewServer(NewProxy(mgr, rt)) + defer front.Close() + + for i := range 20 { + pr, pw := io.Pipe() + go func() { + time.Sleep(10 * time.Millisecond) + _, _ = pw.Write([]byte(body)) + _ = pw.Close() + }() + req, _ := http.NewRequest("POST", front.URL+"/process.Process/Start", pr) + req.ContentLength = int64(len(body)) + req.Header.Set(HeaderSandboxID, "isb_d") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("attempt %d: request: %v", i, err) + } + got, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil || string(got) != "start\nend\n" { + t.Fatalf("attempt %d: response = %q, %v", i, got, err) + } + } +}