Skip to content

Commit 9dc5ae5

Browse files
authored
rpc: send clean close frame on websocket disconnect (#20788)
Currently, when a WebSocket connection is closed by the RPC server, the underlying TCP connection gets torn down immediately without sending a final WebSocket Close frame. Because of this abrupt teardown, clients connected to the node experience an abnormal closure (error code 1006) instead of a clean, normal disconnect (error code 1000). This PR fixes that behavior by explicitly sending a clean 1000 normal closure frame right before terminating the connection. It uses the `Close()` method from the `coder/websocket` library to safely transmit this control frame.
1 parent d844d74 commit 9dc5ae5

2 files changed

Lines changed: 63 additions & 1 deletion

File tree

rpc/websocket.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,28 @@ type wsConnAdapter struct {
257257
}
258258

259259
func (a *wsConnAdapter) Close() error {
260-
return a.conn.CloseNow()
260+
// Attempt a graceful WebSocket close handshake first, so the peer sees a clean
261+
// 1000 (normal closure) instead of 1006 (abnormal). Keep the handshake fully async
262+
// so server shutdown doesn't block per connection, and force-close after a bounded
263+
// grace period if the peer doesn't complete the close promptly.
264+
closeDone := make(chan struct{})
265+
go func() {
266+
defer close(closeDone)
267+
_ = a.conn.Close(websocket.StatusNormalClosure, "")
268+
}()
269+
270+
go func() {
271+
timer := time.NewTimer(time.Second)
272+
defer timer.Stop()
273+
274+
select {
275+
case <-closeDone:
276+
case <-timer.C:
277+
_ = a.conn.CloseNow()
278+
}
279+
}()
280+
281+
return nil
261282
}
262283

263284
func (a *wsConnAdapter) SetWriteDeadline(t time.Time) error {

rpc/websocket_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,3 +367,44 @@ func TestWebsocketNonBlockingAcquire(t *testing.T) {
367367
t.Errorf("expected error code %d (server overloaded), got %d", ErrCodeServerOverloaded, rpcErr.ErrorCode())
368368
}
369369
}
370+
371+
// TestWebsocketServerGracefulClose verifies that when the server initiates a
372+
// websocket closure, it explicitly sends a clean StatusNormalClosure (1000)
373+
// rather than an abnormal closure.
374+
func TestWebsocketServerGracefulClose(t *testing.T) {
375+
t.Parallel()
376+
logger := log.New()
377+
378+
srv := newTestServer(logger)
379+
httpsrv := httptest.NewServer(srv.WebsocketHandler([]string{"*"}, nil, false, logger))
380+
wsURL := "ws:" + strings.TrimPrefix(httpsrv.URL, "http:")
381+
defer srv.Stop()
382+
defer httpsrv.Close()
383+
384+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
385+
defer cancel()
386+
387+
conn, resp, err := websocket.Dial(ctx, wsURL, nil)
388+
if err != nil {
389+
if resp != nil && resp.Body != nil {
390+
resp.Body.Close()
391+
}
392+
t.Fatalf("failed to dial: %v", err)
393+
}
394+
defer conn.CloseNow()
395+
396+
if err := conn.Write(ctx, websocket.MessageText, []byte("invalid json")); err != nil {
397+
t.Fatalf("failed to write: %v", err)
398+
}
399+
400+
for {
401+
_, _, err := conn.Read(ctx)
402+
if err != nil {
403+
status := websocket.CloseStatus(err)
404+
if status != websocket.StatusNormalClosure {
405+
t.Errorf("expected close status %v, got %v (err: %v)", websocket.StatusNormalClosure, status, err)
406+
}
407+
break
408+
}
409+
}
410+
}

0 commit comments

Comments
 (0)