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) + } + } +}