Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions internal/api/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
Expand Down
54 changes: 54 additions & 0 deletions internal/api/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
Expand Down Expand Up @@ -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)
}
}
}
Loading