Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions close.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,18 @@ func CloseStatus(err error) StatusCode {
//
// Close will unblock all goroutines interacting with the connection once
// complete.
func (c *Conn) Close(code StatusCode, reason string) (err error) {
func (c *Conn) Close(code StatusCode, reason string) error {
firstCall := !c.closeCalled.Swap(true)
err := c.closeWithHandshake(code, reason)
if firstCall && errors.Is(err, net.ErrClosed) {
return nil
}
return err
}

// closeWithHandshake implements Close for library code, which must not set
// closeCalled.
func (c *Conn) closeWithHandshake(code StatusCode, reason string) (err error) {
defer errd.Wrap(&err, "failed to close WebSocket")

if c.casClosing() {
Expand Down Expand Up @@ -129,7 +140,16 @@ func (c *Conn) Close(code StatusCode, reason string) (err error) {

// CloseNow closes the WebSocket connection without attempting a close handshake.
// Use when you do not want the overhead of the close handshake.
func (c *Conn) CloseNow() (err error) {
func (c *Conn) CloseNow() error {
firstCall := !c.closeCalled.Swap(true)
err := c.closeWithoutHandshake()
if firstCall && errors.Is(err, net.ErrClosed) {
return nil
}
return err
}

func (c *Conn) closeWithoutHandshake() (err error) {
defer errd.Wrap(&err, "failed to immediately close WebSocket")

if c.casClosing() {
Expand Down
10 changes: 7 additions & 3 deletions conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,13 @@ type Conn struct {
closeReadCtx context.Context
closeReadDone chan struct{}

closing atomic.Bool
closeMu sync.Mutex // Protects following.
closed chan struct{}
// closeCalled is set by the first Close or CloseNow call. Library code
// closes the connection without setting it, so the user's first Close
// does not report net.ErrClosed when the library closed first.
closeCalled atomic.Bool
closing atomic.Bool
closeMu sync.Mutex // Protects following.
closed chan struct{}

pingCounter atomic.Int64
activePingsMu sync.Mutex
Expand Down
80 changes: 80 additions & 0 deletions conn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,71 @@ func TestConn(t *testing.T) {
assert.ErrorIs(t, websocket.ErrClosed, err2)
})

t.Run("firstCloseAfterLibraryClose", func(t *testing.T) {
t.Parallel()

closers := []struct {
name string
close func(*websocket.Conn) error
}{
{"Close", func(c *websocket.Conn) error { return c.Close(websocket.StatusNormalClosure, "") }},
{"CloseNow", (*websocket.Conn).CloseNow},
}
for _, closer := range closers {
t.Run(closer.name, func(t *testing.T) {
t.Parallel()

t.Run("peerClose", func(t *testing.T) {
tt, c1, c2 := newConnTest(t, nil, nil)

readErr := xsync.Go(func() error {
_, _, err := c1.Read(tt.ctx)
return err
})
assert.Success(t, c2.Close(websocket.StatusNormalClosure, ""))
assert.Success(t, assertCloseStatus(websocket.StatusNormalClosure, <-readErr))

assert.Success(t, closer.close(c1))
})

t.Run("closeReadDataMessage", func(t *testing.T) {
tt, c1, c2 := newConnTest(t, nil, nil)

closeReadCtx := c1.CloseRead(tt.ctx)
readErr := xsync.Go(func() error {
_, _, err := c2.Read(tt.ctx)
return err
})
assert.Success(t, c2.Write(tt.ctx, websocket.MessageText, []byte("x")))
assert.Success(t, assertCloseStatus(websocket.StatusPolicyViolation, <-readErr))
<-closeReadCtx.Done()

assert.Success(t, closer.close(c1))
})

t.Run("netConnUnexpectedType", func(t *testing.T) {
tt, c1, c2 := newConnTest(t, nil, nil)

nc := websocket.NetConn(tt.ctx, c1, websocket.MessageBinary)
readErr := xsync.Go(func() error {
_, _, err := c2.Read(tt.ctx)
return err
})
// net.Pipe writes block until nc.Read reads them.
writeErr := xsync.Go(func() error {
return c2.Write(tt.ctx, websocket.MessageText, []byte("x"))
})
_, err := nc.Read(make([]byte, 1))
assert.Contains(t, err, "unexpected frame type")
assert.Success(t, <-writeErr)
assert.Success(t, assertCloseStatus(websocket.StatusUnsupportedData, <-readErr))

assert.Success(t, closer.close(c1))
})
})
}
})

t.Run("MidReadClose", func(t *testing.T) {
tt, c1, c2 := newConnTest(t, nil, nil)

Expand Down Expand Up @@ -449,6 +514,21 @@ func TestWasm(t *testing.T) {
}

s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Sec-WebSocket-Protocol") == "close" {
// Closes first so the client's Close runs after the browser
// closed the connection.
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{"close"},
InsecureSkipVerify: true,
})
if err == nil {
err = c.Close(websocket.StatusNormalClosure, "")
}
if err != nil {
t.Error(err)
}
return
}
err := echoServer(w, r, &websocket.AcceptOptions{
Subprotocols: []string{"echo"},
InsecureSkipVerify: true,
Expand Down
2 changes: 1 addition & 1 deletion netconn.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ func (nc *netConn) read(p []byte) (int, error) {
}
if typ != nc.msgType {
err := fmt.Errorf("unexpected frame type read (expected %v): %v", nc.msgType, typ)
nc.c.Close(StatusUnsupportedData, err.Error())
nc.c.closeWithHandshake(StatusUnsupportedData, err.Error())
return 0, err
}
nc.reader = r
Expand Down
2 changes: 1 addition & 1 deletion read.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func (c *Conn) CloseRead(ctx context.Context) context.Context {
defer c.close()
_, _, err := c.Reader(ctx)
if err == nil {
c.Close(StatusPolicyViolation, "unexpected data message")
c.closeWithHandshake(StatusPolicyViolation, "unexpected data message")
}
}()
return ctx
Expand Down
25 changes: 20 additions & 5 deletions ws_js.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ type Conn struct {
closeErrOnce sync.Once
closeErr error
closeWasClean bool
// closeCalled is set by the first Close or CloseNow call. Library code
// and the browser close the connection without setting it, so the user's
// first Close does not report net.ErrClosed after either closed it.
closeCalled atomic.Bool

releaseOnClose func()
releaseOnError func()
Expand Down Expand Up @@ -125,7 +129,7 @@ func (c *Conn) init() {
}

func (c *Conn) closeWithInternal() {
c.Close(StatusInternalError, "something went wrong")
c.closeWithHandshake(StatusInternalError, "something went wrong")
}

// Read attempts to read a message from the connection.
Expand All @@ -145,7 +149,7 @@ func (c *Conn) Read(ctx context.Context) (MessageType, []byte, error) {
readLimit := c.msgReadLimit.Load()
if readLimit >= 0 && int64(len(p)) > readLimit {
reason := fmt.Errorf("read limited at %d bytes", c.msgReadLimit.Load())
c.Close(StatusMessageTooBig, reason.Error())
c.closeWithHandshake(StatusMessageTooBig, reason.Error())
return 0, nil, fmt.Errorf("%w: %v", ErrMessageTooBig, reason)
}
return typ, p, nil
Expand All @@ -154,7 +158,7 @@ func (c *Conn) Read(ctx context.Context) (MessageType, []byte, error) {
func (c *Conn) read(ctx context.Context) (MessageType, []byte, error) {
select {
case <-ctx.Done():
c.Close(StatusPolicyViolation, "read timed out")
c.closeWithHandshake(StatusPolicyViolation, "read timed out")
return 0, nil, ctx.Err()
case <-c.readSignal:
case <-c.closed:
Expand Down Expand Up @@ -229,6 +233,17 @@ func (c *Conn) write(typ MessageType, p []byte) error {
// or the connection is closed.
// It thus performs the full WebSocket close handshake.
func (c *Conn) Close(code StatusCode, reason string) error {
firstCall := !c.closeCalled.Swap(true)
err := c.closeWithHandshake(code, reason)
if firstCall && errors.Is(err, net.ErrClosed) {
return nil
}
return err
}

// closeWithHandshake implements Close for library code, which must not set
// closeCalled.
func (c *Conn) closeWithHandshake(code StatusCode, reason string) error {
err := c.exportedClose(code, reason)
if err != nil {
return fmt.Errorf("failed to close WebSocket: %w", err)
Expand Down Expand Up @@ -403,10 +418,10 @@ func (c *Conn) CloseRead(ctx context.Context) context.Context {

go func() {
defer cancel()
defer c.CloseNow()
defer c.closeWithHandshake(StatusGoingAway, "")
_, _, err := c.read(ctx)
if err != nil {
c.Close(StatusPolicyViolation, "unexpected data message")
c.closeWithHandshake(StatusPolicyViolation, "unexpected data message")
}
}()
return ctx
Expand Down
20 changes: 20 additions & 0 deletions ws_js_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package websocket_test

import (
"context"
"net"
"net/http"
"os"
"testing"
Expand Down Expand Up @@ -37,6 +38,25 @@ func TestWasm(t *testing.T) {
assert.Success(t, err)
}

func TestWasmFirstCloseAfterPeerClose(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()

c, _, err := websocket.Dial(ctx, os.Getenv("WS_ECHO_SERVER_URL"), &websocket.DialOptions{
Subprotocols: []string{"close"},
})
assert.Success(t, err)

_, _, err = c.Read(ctx)
// Read returns once the browser has closed the connection.
assert.ErrorIs(t, net.ErrClosed, err)

err = c.Close(websocket.StatusNormalClosure, "")
assert.Success(t, err)
}

func TestWasmDialTimeout(t *testing.T) {
t.Parallel()

Expand Down
Loading