Skip to content

Commit 2b619db

Browse files
jakubnogithub-actions[bot]
authored andcommitted
fix(proxy): track connections through shutdown
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> GitOrigin-RevId: bdf063393e4dbadae8d8af4d99170df450e0bbb7
1 parent 530d068 commit 2b619db

8 files changed

Lines changed: 513 additions & 8 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,9 @@ Key mechanisms (all under `pkg/sandbox/`):
246246
- **Sandbox proxy** (:5007, `pkg/proxy/`): reverse-proxies incoming traffic from client-proxy to
247247
the sandbox's slot IP and requested port over HTTP or configured HTTPS, enforcing per-sandbox
248248
traffic access tokens. HTTPS backends may use self-signed certificates.
249+
Shared proxy shutdown waits for H2C and upgraded connections to close and includes them in
250+
forced connection cleanup. Failed H2C handshakes release their hijacked connections when the
251+
handshake handler returns.
249252
- Writes sandbox lifecycle **events** and cgroup **host stats** to ClickHouse; exports metrics via
250253
OTel. Sandbox and template-build log writes go through a flag-resolved HTTP route: the legacy
251254
collector remains the fallback primary destination, and configured shadow destinations can mirror

‎packages/orchestrator/go.mod‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ require (
7373
go.opentelemetry.io/otel/sdk/metric v1.46.0
7474
go.opentelemetry.io/otel/trace v1.46.0
7575
go.uber.org/zap v1.28.0
76+
golang.org/x/net v0.58.0
7677
golang.org/x/sync v0.23.0
7778
golang.org/x/sys v0.48.0
7879
golang.org/x/term v0.45.0
@@ -318,7 +319,6 @@ require (
318319
golang.org/x/crypto v0.55.0 // indirect
319320
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
320321
golang.org/x/mod v0.41.0 // indirect
321-
golang.org/x/net v0.58.0 // indirect
322322
golang.org/x/oauth2 v0.37.0 // indirect
323323
golang.org/x/text v0.41.0 // indirect
324324
golang.org/x/time v0.15.0 // indirect
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//go:build linux
2+
3+
package proxy
4+
5+
import (
6+
"context"
7+
"crypto/tls"
8+
"fmt"
9+
"io"
10+
"net"
11+
"net/http"
12+
"net/http/httptest"
13+
"net/url"
14+
"strings"
15+
"testing"
16+
"time"
17+
18+
"github.com/stretchr/testify/assert"
19+
"github.com/stretchr/testify/require"
20+
"golang.org/x/net/http2"
21+
22+
"github.com/e2b-dev/infra/packages/shared/pkg/connlimit"
23+
"github.com/e2b-dev/infra/packages/shared/pkg/logger"
24+
reverseproxy "github.com/e2b-dev/infra/packages/shared/pkg/proxy"
25+
"github.com/e2b-dev/infra/packages/shared/pkg/proxy/pool"
26+
)
27+
28+
func TestSandboxProxyCloseInterruptsStalledH2CUpload(t *testing.T) {
29+
t.Parallel()
30+
31+
for _, forced := range []bool{false, true} {
32+
t.Run(fmt.Sprintf("forced=%t", forced), func(t *testing.T) {
33+
t.Parallel()
34+
35+
backendStarted := make(chan error, 1)
36+
backend := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
37+
_, err := io.ReadFull(r.Body, make([]byte, 1))
38+
backendStarted <- err
39+
_, _ = io.Copy(io.Discard, r.Body)
40+
}))
41+
t.Cleanup(backend.Close)
42+
backendURL, err := url.Parse(backend.URL)
43+
require.NoError(t, err)
44+
45+
handlerDone := make(chan struct{})
46+
proxy := &SandboxProxy{proxy: reverseproxy.New(0, reverseproxy.SandboxProxyRetries, time.Minute,
47+
func(*http.Request) (*pool.Destination, error) {
48+
return &pool.Destination{
49+
Url: backendURL,
50+
ConnectionKey: "upload-lifecycle",
51+
RequestLogger: logger.NewNopLogger(),
52+
}, nil
53+
}, &reverseproxy.ConnectionLimitConfig{
54+
Limiter: connlimit.NewConnectionLimiter(),
55+
GetMaxLimit: func(context.Context) int { return 10 },
56+
OnConnectionReleased: func(context.Context, int64) { close(handlerDone) },
57+
}, true)}
58+
var listenConfig net.ListenConfig
59+
listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0")
60+
require.NoError(t, err)
61+
serveDone := make(chan error, 1)
62+
go func() { serveDone <- proxy.proxy.Serve(listener) }()
63+
t.Cleanup(func() {
64+
assert.NoError(t, proxy.proxy.Close())
65+
assert.ErrorIs(t, <-serveDone, http.ErrServerClosed)
66+
})
67+
68+
client := &http.Client{Transport: &http2.Transport{
69+
AllowHTTP: true,
70+
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
71+
return (&net.Dialer{}).DialContext(ctx, network, addr)
72+
},
73+
}}
74+
t.Cleanup(client.CloseIdleConnections)
75+
reader, writer := io.Pipe()
76+
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "http://"+listener.Addr().String()+"/upload", reader)
77+
require.NoError(t, err)
78+
req.ContentLength = 8193
79+
clientDone := make(chan struct{})
80+
go func() {
81+
defer close(clientDone)
82+
resp, err := client.Do(req)
83+
if err == nil {
84+
_ = resp.Body.Close()
85+
}
86+
}()
87+
t.Cleanup(func() {
88+
_ = writer.Close()
89+
_ = reader.Close()
90+
select {
91+
case <-clientDone:
92+
case <-time.After(5 * time.Second):
93+
t.Error("upload client did not exit")
94+
}
95+
})
96+
_, err = io.WriteString(writer, strings.Repeat("x", 8192))
97+
require.NoError(t, err)
98+
select {
99+
case err := <-backendStarted:
100+
require.NoError(t, err)
101+
case <-time.After(5 * time.Second):
102+
t.Fatal("backend did not receive the upload")
103+
}
104+
backend.CloseClientConnections()
105+
106+
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
107+
defer cancel()
108+
if forced {
109+
cancel()
110+
}
111+
require.NoError(t, proxy.Close(ctx))
112+
select {
113+
case <-handlerDone:
114+
case <-time.After(5 * time.Second):
115+
t.Fatal("shutdown left the upload handler blocked")
116+
}
117+
assert.Zero(t, proxy.proxy.CurrentServerConnections())
118+
})
119+
}
120+
}

‎packages/shared/pkg/httpserver/h2c.go‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package httpserver
22

33
import (
4+
"bufio"
5+
"net"
46
"net/http"
57
"time"
68

@@ -26,19 +28,54 @@ func ConfigureH2C(server *http.Server) {
2628
}
2729

2830
h2cHandler := h2c.NewHandler(handler, h2Server)
29-
limitedH2CHandler := http.MaxBytesHandler(h2cHandler, h2cUpgradeBodyLimit)
31+
trackedH2CHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32+
writer := &h2cResponseWriter{ResponseWriter: w}
33+
defer func() {
34+
// h2c can return a preface read error without closing the hijacked socket.
35+
if writer.conn != nil {
36+
_ = writer.conn.Close()
37+
}
38+
}()
39+
h2cHandler.ServeHTTP(writer, r)
40+
})
41+
// MaxBytesHandler needs the original writer's private requestTooLarge hook.
42+
limitedH2CHandler := http.MaxBytesHandler(trackedH2CHandler, h2cUpgradeBodyLimit)
3043

3144
server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3245
if isH2CUpgrade(r.Header) {
3346
limitedH2CHandler.ServeHTTP(w, r)
3447

3548
return
3649
}
50+
if r.Method == "PRI" && len(r.Header) == 0 && r.URL.Path == "*" && r.Proto == "HTTP/2.0" {
51+
trackedH2CHandler.ServeHTTP(w, r)
52+
53+
return
54+
}
3755

3856
h2cHandler.ServeHTTP(w, r)
3957
})
4058
}
4159

60+
type h2cResponseWriter struct {
61+
http.ResponseWriter
62+
63+
conn net.Conn
64+
}
65+
66+
func (w *h2cResponseWriter) Unwrap() http.ResponseWriter {
67+
return w.ResponseWriter
68+
}
69+
70+
func (w *h2cResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
71+
conn, buffered, err := http.NewResponseController(w.ResponseWriter).Hijack()
72+
if err == nil {
73+
w.conn = conn
74+
}
75+
76+
return conn, buffered, err
77+
}
78+
4279
func newHTTP2Server() *http2.Server {
4380
return &http2.Server{
4481
MaxConcurrentStreams: 100,

‎packages/shared/pkg/httpserver/h2c_test.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package httpserver
22

33
import (
4+
"bufio"
45
"context"
56
"crypto/tls"
7+
"fmt"
68
"io"
79
"net"
810
"net/http"
@@ -15,6 +17,33 @@ import (
1517
"golang.org/x/net/http2"
1618
)
1719

20+
func TestConfigureH2CRejectsIncompleteOversizedUpgrade(t *testing.T) {
21+
t.Parallel()
22+
23+
server := httptest.NewUnstartedServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
24+
t.Error("oversized upgrade reached the application handler")
25+
}))
26+
ConfigureH2C(server.Config)
27+
server.Start()
28+
t.Cleanup(server.Close)
29+
30+
var dialer net.Dialer
31+
conn, err := dialer.DialContext(t.Context(), "tcp", server.Listener.Addr().String())
32+
require.NoError(t, err)
33+
t.Cleanup(func() { _ = conn.Close() })
34+
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
35+
_, err = fmt.Fprintf(conn, "POST /upload HTTP/1.1\r\nHost: sandbox.test\r\nConnection: Upgrade, HTTP2-Settings\r\nUpgrade: h2c\r\nHTTP2-Settings: AAMAAABkAAQAAP__\r\nContent-Length: %d\r\n\r\n", h2cUpgradeBodyLimit+2)
36+
require.NoError(t, err)
37+
_, err = io.WriteString(conn, strings.Repeat("x", h2cUpgradeBodyLimit+1))
38+
require.NoError(t, err)
39+
40+
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
41+
require.NoError(t, err)
42+
defer resp.Body.Close()
43+
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
44+
require.True(t, resp.Close)
45+
}
46+
1847
func TestConfigureH2CAcceptsHTTP2AndHTTP1(t *testing.T) {
1948
t.Parallel()
2049

‎packages/shared/pkg/proxy/proxy.go‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package proxy
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"net"
78
"net/http"
@@ -12,6 +13,7 @@ import (
1213
"github.com/e2b-dev/infra/packages/shared/pkg/httpserver"
1314
"github.com/e2b-dev/infra/packages/shared/pkg/proxy/pool"
1415
"github.com/e2b-dev/infra/packages/shared/pkg/proxy/tracking"
16+
"github.com/e2b-dev/infra/packages/shared/pkg/smap"
1517
)
1618

1719
// ConnectionLimitConfig bundles connection limiting and associated metric callbacks.
@@ -33,6 +35,7 @@ type Proxy struct {
3335
http.Server
3436

3537
pool *pool.ProxyPool
38+
serverConnections *smap.Map[*tracking.Connection]
3639
currentServerConnsCounter atomic.Int64
3740
}
3841

@@ -69,7 +72,8 @@ func New(
6972
ReadHeaderTimeout: 0,
7073
Handler: handler(p, getDestination, connLimitConfig),
7174
},
72-
pool: p,
75+
pool: p,
76+
serverConnections: smap.New[*tracking.Connection](),
7377
}
7478
httpserver.ConfigureH2C(&proxy.Server)
7579

@@ -109,5 +113,35 @@ func (p *Proxy) ListenAndServe(ctx context.Context) error {
109113
}
110114

111115
func (p *Proxy) Serve(l net.Listener) error {
112-
return p.Server.Serve(tracking.NewListener(l, &p.currentServerConnsCounter))
116+
return p.Server.Serve(tracking.NewListener(l, &p.currentServerConnsCounter, p.serverConnections))
117+
}
118+
119+
func (p *Proxy) Shutdown(ctx context.Context) error {
120+
if err := p.Server.Shutdown(ctx); err != nil {
121+
return err
122+
}
123+
124+
// net/http does not wait for hijacked H2C and upgraded connections.
125+
ticker := time.NewTicker(10 * time.Millisecond)
126+
defer ticker.Stop()
127+
for p.CurrentServerConnections() != 0 {
128+
select {
129+
case <-ctx.Done():
130+
return ctx.Err()
131+
case <-ticker.C:
132+
}
133+
}
134+
135+
return nil
136+
}
137+
138+
func (p *Proxy) Close() error {
139+
err := p.Server.Close()
140+
for _, conn := range p.serverConnections.Items() {
141+
if closeErr := conn.Close(); closeErr != nil && !errors.Is(closeErr, net.ErrClosed) {
142+
err = errors.Join(err, closeErr)
143+
}
144+
}
145+
146+
return err
113147
}

0 commit comments

Comments
 (0)