Skip to content

Commit 031f3e5

Browse files
committed
fix: Resolve dispatcher tunnel response hang and connection handoff issues.
The dispatcher TunnelClient waited for connection close before resolving HTTP responses, causing indefinite hangs with HTTP/1.1 keep-alive. Now detects complete responses via Content-Length and chunked encoding. On the interceptor side, new dispatcher function instances would connect via WebSocket but the tunnel kept reading from the old connection. The interceptor now closes the previous active connection when a new one arrives, forcing a stream refresh. The WakeableWSStream also checks for pending connections before calling wake to avoid redundant reconnects. Increases dispatcher e2e sandbox timeout from 1m to 5m and suite context from 1m to 3m to accommodate deployment wait times. 🐘 Generated with Crush Assisted-by: Claude Opus 4.6 via Crush <crush@charm.land>
1 parent 166069f commit 031f3e5

4 files changed

Lines changed: 77 additions & 17 deletions

File tree

‎e2esandbox/dispatcher_test.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func TestDispatcherSuite(t *testing.T) {
4646
}
4747

4848
func (s *DispatcherSuite) SetupSuite() {
49-
s.ctx, s.cancel = context.WithTimeout(context.Background(), 1*time.Minute)
49+
s.ctx, s.cancel = context.WithTimeout(context.Background(), 3*time.Minute)
5050
t := s.T()
5151

5252
token := resolveVercelToken(t)
@@ -83,7 +83,7 @@ func (s *DispatcherSuite) SetupSuite() {
8383
ProjectID: projectID,
8484
Ports: []int{8081},
8585
Runtime: "node24",
86-
Timeout: 1 * time.Minute,
86+
Timeout: 5 * time.Minute,
8787
})
8888
require.NoError(t, err, "failed to create sandbox")
8989

@@ -186,7 +186,9 @@ func (s *DispatcherSuite) TestDispatcherPreviewFlow() {
186186
}
187187

188188
return payload.Status == "ok"
189-
}, 30*time.Second, 2*time.Second, "dispatcher preview did not forward /api/health to the sandbox userservice")
189+
}, 60*time.Second, 3*time.Second, "dispatcher preview did not forward /api/health to the sandbox userservice")
190+
191+
s.dumpSandboxDiagnostics()
190192

191193
out, err := s.execInSandbox("curl", "-sf", "--max-time", "20", "--proxy", "socks5h://127.0.0.1:1080", "http://example.com")
192194
require.NoError(t, err, "curl through dispatcher-backed SOCKS proxy failed")

‎flow/dispatcher/src/tunnel.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,23 @@ export class TunnelClient {
228228
if (message.data.length > 0) {
229229
pending.chunks.push(message.data);
230230
}
231+
232+
if (this.isCompleteHttpResponse(pending.chunks)) {
233+
const fullData = this.concatenateChunks(pending.chunks);
234+
const finalMessage = create(TunnelNetworkMessageSchema, {
235+
connectionId,
236+
data: fullData,
237+
});
238+
pending.resolve(finalMessage);
239+
this.pendingRequests.delete(connectionId);
240+
241+
const closeMsg = create(TunnelNetworkMessageSchema, {
242+
connectionId,
243+
error: "dispatcher: response complete",
244+
data: new Uint8Array(),
245+
});
246+
this.sendMessageDirect(closeMsg);
247+
}
231248
return;
232249
}
233250

@@ -401,6 +418,30 @@ export class TunnelClient {
401418
});
402419
}
403420

421+
private isCompleteHttpResponse(chunks: Uint8Array[]): boolean {
422+
const data = this.concatenateChunks(chunks);
423+
const text = new TextDecoder().decode(data);
424+
425+
const headerEnd = text.indexOf("\r\n\r\n");
426+
if (headerEnd === -1) return false;
427+
428+
const headerSection = text.slice(0, headerEnd).toLowerCase();
429+
const bodyStartIdx = headerEnd + 4;
430+
431+
const clMatch = headerSection.match(/content-length:\s*(\d+)/);
432+
if (clMatch) {
433+
const expectedLen = parseInt(clMatch[1], 10);
434+
const bodyBytes = data.slice(new TextEncoder().encode(text.slice(0, bodyStartIdx)).length);
435+
return bodyBytes.length >= expectedLen;
436+
}
437+
438+
if (headerSection.includes("transfer-encoding: chunked")) {
439+
return text.endsWith("0\r\n\r\n") || text.includes("\r\n0\r\n\r\n");
440+
}
441+
442+
return false;
443+
}
444+
404445
private concatenateChunks(chunks: Uint8Array[]): Uint8Array {
405446
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
406447
const result = new Uint8Array(totalLength);

‎pkg/commands/intercept_health.go‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@ import (
66
"log/slog"
77
"net"
88
"net/http"
9+
"sync"
910

1011
"github.com/gorilla/websocket"
1112
)
1213

13-
// interceptServer accepts inbound dispatcher tunnel connections and hands them
14-
// to the interceptor stream via a channel.
1514
type interceptServer struct {
1615
httpServer *http.Server
1716
tunnelConnCh chan *websocket.Conn
1817
upgrader websocket.Upgrader
18+
19+
mu sync.Mutex
20+
activeConn *websocket.Conn
1921
}
2022

2123
func newInterceptServer(addr string) (*interceptServer, error) {
@@ -64,16 +66,22 @@ func (s *interceptServer) handleTunnelWS(w http.ResponseWriter, r *http.Request)
6466
return
6567
}
6668

69+
s.mu.Lock()
70+
oldActive := s.activeConn
71+
s.activeConn = conn
72+
s.mu.Unlock()
73+
74+
if oldActive != nil {
75+
slog.Info("New tunnel connection arrived, closing previous active connection")
76+
_ = oldActive.Close()
77+
}
78+
6779
select {
68-
case s.tunnelConnCh <- conn:
69-
default:
70-
select {
71-
case staleConn := <-s.tunnelConnCh:
72-
if staleConn != nil {
73-
_ = staleConn.Close()
74-
}
75-
default:
80+
case old := <-s.tunnelConnCh:
81+
if old != nil {
82+
_ = old.Close()
7683
}
77-
s.tunnelConnCh <- conn
84+
default:
7885
}
86+
s.tunnelConnCh <- conn
7987
}

‎pkg/tunnel/stream_ws.go‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,21 @@ func NewReconnectableWSStream(conn *websocket.Conn, refresher func(context.Conte
4343
func NewWakeableWSStream(connCh <-chan *websocket.Conn, wake func(context.Context) error) *WSStream {
4444
return &WSStream{
4545
refresher: func(ctx context.Context) (*websocket.Conn, error) {
46-
if wake == nil {
47-
return nil, fmt.Errorf("ws stream: wake not configured")
48-
}
4946
if connCh == nil {
5047
return nil, fmt.Errorf("ws stream: tunnel connection channel not configured")
5148
}
49+
50+
select {
51+
case conn := <-connCh:
52+
if conn != nil {
53+
return conn, nil
54+
}
55+
default:
56+
}
57+
58+
if wake == nil {
59+
return nil, fmt.Errorf("ws stream: wake not configured")
60+
}
5261
if err := wake(ctx); err != nil {
5362
return nil, err
5463
}

0 commit comments

Comments
 (0)