From 857782ee42eefa69e74667a39dc4f284f0606d7f Mon Sep 17 00:00:00 2001 From: Jackmaninov Date: Sun, 4 Jan 2026 11:08:52 +0300 Subject: [PATCH] Fix websocket panics on session disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes multiple race conditions and error handling issues in websocket session management that caused panics during unauthenticated visitor access and login redirects. Changes: - Add nil checks in read() and write() after acquiring lock to prevent nil pointer dereference when disconnect() races with read/write - Return net.ErrClosed from errHandler() for close errors to prevent read loop spinning (~1000 iterations before panic) - Check context in Write() before sending to detect disconnecting session - Add atomic.Bool closed flag to session, checked before channel send to prevent "send on closed channel" panics - Handle additional expected close codes (1005, 1006) and "close sent" error to suppress noisy error logs during normal disconnection Fixes https://github.com/cortezaproject/corteza/issues/2223 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server/pkg/websocket/session.go | 48 +++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/server/pkg/websocket/session.go b/server/pkg/websocket/session.go index bbdef8b551..89724c5938 100644 --- a/server/pkg/websocket/session.go +++ b/server/pkg/websocket/session.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "sync" + "sync/atomic" "time" "github.com/cortezaproject/corteza/server/pkg/auth" @@ -39,9 +40,10 @@ type ( session struct { l sync.RWMutex - id uint64 - once sync.Once - conn conection + id uint64 + once sync.Once + conn conection + closed atomic.Bool ctx context.Context ctxCancel context.CancelFunc @@ -92,6 +94,9 @@ func (s *session) disconnect() { s.l.Lock() defer s.l.Unlock() + // Mark session as closed before closing channels + s.closed.Store(true) + // Cancel context s.ctxCancel() @@ -190,6 +195,11 @@ func (s *session) read() (raw []byte, err error) { s.l.RLock() defer s.l.RUnlock() + // Check if connection was closed by disconnect() + if s.conn == nil { + return nil, net.ErrClosed + } + if _, raw, err = s.conn.ReadMessage(); err != nil { return nil, errHandler("websocket read failed", err) } @@ -291,6 +301,11 @@ func (s *session) write(t int, msg []byte) (err error) { } }() + // Check if connection was closed by disconnect() + if s.conn == nil { + return net.ErrClosed + } + if err = s.conn.SetWriteDeadline(time.Now().Add(s.config.Timeout)); err != nil { return fmt.Errorf("deadline error: %w", err) } @@ -324,6 +339,11 @@ func (s *session) authenticate(p *payloadAuth) error { // sendBytes sends byte to channel or timeout func (s *session) Write(p []byte) (int, error) { + // Check if session is closed before attempting to send + if s.closed.Load() { + return 0, net.ErrClosed + } + defer func() { if recovered := recover(); recovered != nil { s.logger.Debug("recovering from websocket write panic", zap.Any("recovered-error", recovered)) @@ -331,6 +351,9 @@ func (s *session) Write(p []byte) (int, error) { }() select { + case <-s.ctx.Done(): + // Session is disconnecting, channel may be closed + return 0, net.ErrClosed case s.send <- p: return len(p), nil case <-time.After(2 * time.Millisecond): @@ -343,14 +366,23 @@ func errHandler(prefix string, err error) error { return nil } - if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { - // normal closing - return nil + // Handle websocket close errors - these are expected during disconnection + if websocket.IsCloseError(err, + websocket.CloseNormalClosure, + websocket.CloseGoingAway, + websocket.CloseAbnormalClosure, + websocket.CloseNoStatusReceived, + ) { + return net.ErrClosed } if errors.Is(err, net.ErrClosed) { - // suppress errors when reading/writing from/to a closed connection - return nil + return net.ErrClosed + } + + // "close sent" occurs when writing to a connection that's closing + if err.Error() == "websocket: close sent" { + return net.ErrClosed } return fmt.Errorf(prefix+": %w", err)