mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-20 06:28:13 +00:00
fix(websocket): join ping goroutine and unify keepalive deadlines
The handler returned while the ping goroutine could still write to a connection that gofiber had recycled into its pool. This caused a nil dereference panic or a ping on an unrelated socket. Join the ping goroutine through a done channel before the handler returns. Compute effective keepalive values once in the constructor, clamp pongTimeout to at least pingInterval with one warning, and pass the effective values to both proxy directions. A negative interval or timeout disables keepalive. New test drives the real HandleWebSocket wiring.
This commit is contained in:
+184
-1
@@ -2,12 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fiber "github.com/gofiber/fiber/v3"
|
||||
gorillaws "github.com/gorilla/websocket"
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
|
||||
@@ -310,6 +312,13 @@ func TestWebSocketProxy_ConfigValidation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestWebSocketProxy_ReapsSilentBackend_Keepalive is a lower-level unit test
|
||||
// of proxyBackendToClient's read loop in isolation: given an initial read
|
||||
// deadline (set here exactly as handleConnection sets it), a silent backend
|
||||
// must be reaped instead of blocking forever. It does NOT exercise
|
||||
// handleConnection's own keepalive wiring (effectiveKeepalive, the ping
|
||||
// goroutine, or the deadline being set from configuration) - see
|
||||
// TestWebSocketProxy_HandleConnection_KeepaliveReapsIdleClient for that.
|
||||
func TestWebSocketProxy_ReapsSilentBackend_Keepalive(t *testing.T) {
|
||||
config := WebSocketConfig{
|
||||
Enabled: true,
|
||||
@@ -350,7 +359,7 @@ func TestWebSocketProxy_ReapsSilentBackend_Keepalive(t *testing.T) {
|
||||
// client conn is nil and safe here: the silent backend sends no
|
||||
// message, so proxyBackendToClient returns on the read deadline
|
||||
// before any client.WriteMessage call.
|
||||
wsp.proxyBackendToClient(context.Background(), backend, nil, "test-conn")
|
||||
wsp.proxyBackendToClient(context.Background(), backend, nil, "test-conn", config.PongTimeout)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
@@ -395,3 +404,177 @@ func TestWebSocketProxy_StatsStructure(t *testing.T) {
|
||||
assert.True(t, hasPong)
|
||||
assert.True(t, hasSize)
|
||||
}
|
||||
|
||||
// startTestWebSocketProxyApp starts a real fiber app on an ephemeral
|
||||
// loopback port serving wsp at /ws, so tests can dial it with a real
|
||||
// WebSocket client and exercise handleConnection through its actual entry
|
||||
// point (HandleWebSocket) instead of calling unexported methods directly.
|
||||
// It registers app shutdown via t.Cleanup and returns the "host:port" to
|
||||
// dial.
|
||||
func startTestWebSocketProxyApp(t *testing.T, wsp *WebSocketProxy) string {
|
||||
t.Helper()
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/ws", func(c fiber.Ctx) error {
|
||||
return wsp.HandleWebSocket(c)
|
||||
})
|
||||
|
||||
listenReady := make(chan struct{})
|
||||
var addr string
|
||||
go func() {
|
||||
_ = app.Listen("127.0.0.1:0", fiber.ListenConfig{
|
||||
DisableStartupMessage: true,
|
||||
ListenerAddrFunc: func(a net.Addr) {
|
||||
addr = a.String()
|
||||
close(listenReady)
|
||||
},
|
||||
})
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = app.ShutdownWithContext(ctx)
|
||||
})
|
||||
|
||||
select {
|
||||
case <-listenReady:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("test WebSocket proxy app did not start listening")
|
||||
}
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
// TestWebSocketProxy_HandleConnection_KeepaliveReapsIdleClient exercises the
|
||||
// keepalive wiring INSIDE handleConnection end to end, through the real
|
||||
// HandleWebSocket entry point. This closes the gap left by
|
||||
// TestWebSocketProxy_ReapsSilentBackend_Keepalive above: that test sets the
|
||||
// read deadline itself and calls proxyBackendToClient directly, so it passes
|
||||
// even if handleConnection stopped calling effectiveKeepalive / setting any
|
||||
// deadline at all. This test sets no deadline itself - if handleConnection's
|
||||
// own keepalive wiring were removed, the idle client below would never be
|
||||
// reaped and this test would time out.
|
||||
func TestWebSocketProxy_HandleConnection_KeepaliveReapsIdleClient(t *testing.T) {
|
||||
// Backend that completes the upgrade, consumes the forwarded
|
||||
// connection_init, then goes silent - mirrors a slow/half-open backend.
|
||||
serverRelease := make(chan struct{})
|
||||
upgrader := gorillaws.Upgrader{}
|
||||
backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _, _ = conn.ReadMessage()
|
||||
<-serverRelease
|
||||
_ = conn.Close()
|
||||
}))
|
||||
defer backendSrv.Close()
|
||||
defer close(serverRelease)
|
||||
|
||||
// PongTimeout >= 2x PingInterval so effectiveKeepalive's clamp does not
|
||||
// stretch it past the tight value this test relies on to stay fast.
|
||||
config := WebSocketConfig{
|
||||
Enabled: true,
|
||||
PingInterval: 100 * time.Millisecond,
|
||||
PongTimeout: 250 * time.Millisecond,
|
||||
}
|
||||
wsp := NewWebSocketProxy(backendSrv.URL, config, libpack_logger.New(), nil)
|
||||
|
||||
proxyAddr := startTestWebSocketProxyApp(t, wsp)
|
||||
|
||||
clientConn, _, err := gorillaws.DefaultDialer.Dial("ws://"+proxyAddr+"/ws", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial proxy: %v", err)
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
// Send the connection_init handleConnection reads before wiring up the
|
||||
// backend and the keepalive, then go silent: no more messages, so this
|
||||
// client never hits the traffic-refresh path in proxyClientToBackend
|
||||
// either - only handleConnection's own read deadline can reap it.
|
||||
if err := clientConn.WriteMessage(gorillaws.TextMessage, []byte(`{"type":"connection_init"}`)); err != nil {
|
||||
t.Fatalf("write connection_init: %v", err)
|
||||
}
|
||||
|
||||
readErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := clientConn.ReadMessage()
|
||||
readErrCh <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-readErrCh:
|
||||
assert.Error(t, err, "expected the proxy to close the idle connection")
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("handleConnection did not reap the idle client via its own keepalive wiring")
|
||||
}
|
||||
|
||||
// wsp's own bookkeeping (decremented by handleConnection's defer right
|
||||
// before it returns) must reflect the teardown too.
|
||||
assert.Eventually(t, func() bool {
|
||||
return wsp.activeConnections.Load() == 0
|
||||
}, 2*time.Second, 10*time.Millisecond, "handleConnection did not return after reaping the idle client")
|
||||
}
|
||||
|
||||
// TestWebSocketProxy_HandleConnection_PingGoroutineJoinsBeforeReturn is a
|
||||
// regression test for the ping goroutine leak: handleConnection must cancel
|
||||
// AND join (wait for) its ping goroutine before returning, because gofiber's
|
||||
// contrib/v3 websocket recycles the *websocket.Conn wrapper into a
|
||||
// sync.Pool - nilling its embedded conn - the instant the handler returns
|
||||
// (see releaseConn in contrib/v3/websocket). A ping goroutine still running
|
||||
// after that either nil-derefs inside WriteControl (an unrecovered panic in
|
||||
// a goroutine with no recover, crashing the whole test binary) or writes a
|
||||
// stray ping onto a different, newly-accepted connection's socket.
|
||||
//
|
||||
// This cannot be proven with a single deterministic assertion: the failure
|
||||
// is a timing race between the goroutine observing cancellation and its
|
||||
// ticker firing. The test instead hammers many rapid connect/disconnect
|
||||
// cycles with an aggressive ping interval to maximize the window in which a
|
||||
// missing join would be exercised, and relies on this project's `-race` test
|
||||
// gate to flag the resulting data race, or on the unrecovered panic crashing
|
||||
// the test binary outright.
|
||||
func TestWebSocketProxy_HandleConnection_PingGoroutineJoinsBeforeReturn(t *testing.T) {
|
||||
upgrader := gorillaws.Upgrader{}
|
||||
backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer backendSrv.Close()
|
||||
|
||||
config := WebSocketConfig{
|
||||
Enabled: true,
|
||||
PingInterval: 5 * time.Millisecond,
|
||||
PongTimeout: 20 * time.Millisecond,
|
||||
}
|
||||
wsp := NewWebSocketProxy(backendSrv.URL, config, libpack_logger.New(), nil)
|
||||
|
||||
proxyAddr := startTestWebSocketProxyApp(t, wsp)
|
||||
|
||||
const iterations = 25
|
||||
for i := 0; i < iterations; i++ {
|
||||
clientConn, _, err := gorillaws.DefaultDialer.Dial("ws://"+proxyAddr+"/ws", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("iteration %d: dial proxy: %v", i, err)
|
||||
}
|
||||
if err := clientConn.WriteMessage(gorillaws.TextMessage, []byte(`{"type":"connection_init"}`)); err != nil {
|
||||
t.Fatalf("iteration %d: write connection_init: %v", i, err)
|
||||
}
|
||||
// Disconnect immediately, no close handshake, while the ping
|
||||
// goroutine's ticker is still armed - handleConnection's teardown
|
||||
// and join then race a live ticker on every iteration.
|
||||
_ = clientConn.Close()
|
||||
|
||||
assert.Eventually(t, func() bool {
|
||||
return wsp.activeConnections.Load() == 0
|
||||
}, 2*time.Second, time.Millisecond, "iteration %d: handleConnection did not tear down", i)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user