Skip to content

Commit ae59313

Browse files
committed
Cover connection close under the frame handoff
The existing tests close connections from the client side only. That leaves the one case where the handoff genuinely changes which goroutine does what unexercised: a close started while a frame is being processed runs on the handoff goroutine, while the read loop sits in the channel receive waiting for that same frame. Two cases, each run against both variants so a divergence shows up rather than just an absence of failure: - a server-side Disconnect while the connection is otherwise quiet, checking the read loop is unblocked, the hub drains, and the peer sees the connection end; - a Disconnect issued from inside an RPC handler, which is the goroutine question above. Teardown must complete and must not deadlock against the close handshake, which transport close waits on and the read loop is the one to drain. Both pass on either setting, which is what the design predicts: handle() is synchronous, so no handoff goroutine is ever in flight when the read loop exits, and the teardown sequence is reached exactly as it was before.
1 parent 8803854 commit ae59313

1 file changed

Lines changed: 93 additions & 1 deletion

File tree

handler_websocket_test.go

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1449,7 +1449,14 @@ func newOffReadLoopServer(t *testing.T, offReadLoop bool, writeWithTimer bool) (
14491449
})
14501450
n.OnConnect(func(client *Client) {
14511451
client.OnRPC(func(e RPCEvent, cb RPCCallback) {
1452-
// Echo the request back so the reply can be tied to its command.
1452+
// "disconnect" lets a test close the connection from inside command
1453+
// processing, which under the handoff runs on a different goroutine
1454+
// than the read loop. Anything else echoes, so the reply can be tied
1455+
// to its command.
1456+
if e.Method == "disconnect" {
1457+
client.Disconnect(DisconnectForceNoReconnect)
1458+
return
1459+
}
14531460
cb(RPCReply{Data: e.Data}, nil)
14541461
})
14551462
client.OnSubscribe(func(e SubscribeEvent, cb SubscribeCallback) {
@@ -1717,6 +1724,91 @@ func TestProcessCommandsOffReadLoopInterleavedWithPushes(t *testing.T) {
17171724
require.Equal(t, numPushes, pushes)
17181725
}
17191726

1727+
// TestProcessCommandsOffReadLoopServerDisconnect covers teardown started by the
1728+
// server rather than by the peer. The read loop is parked waiting for the
1729+
// handoff or for the next frame, so what has to hold is that closing the
1730+
// transport still unblocks it and drains the connection.
1731+
func TestProcessCommandsOffReadLoopServerDisconnect(t *testing.T) {
1732+
t.Parallel()
1733+
for _, offReadLoop := range []bool{false, true} {
1734+
offReadLoop := offReadLoop
1735+
name := "inline"
1736+
if offReadLoop {
1737+
name = "off_read_loop"
1738+
}
1739+
t.Run(name, func(t *testing.T) {
1740+
t.Parallel()
1741+
n, url := newOffReadLoopServer(t, offReadLoop, false)
1742+
conn := dialAndConnect(t, url)
1743+
defer func() { _ = conn.Close() }()
1744+
1745+
cmd, err := json.Marshal(&protocol.Command{
1746+
Id: 2, Rpc: &protocol.RPCRequest{Method: "echo", Data: []byte(`{}`)},
1747+
})
1748+
require.NoError(t, err)
1749+
require.NoError(t, conn.WriteMessage(websocket.TextMessage, cmd))
1750+
require.Equal(t, []uint32{2}, readReplyIDs(t, conn, 1))
1751+
require.Equal(t, 1, n.Hub().NumClients())
1752+
1753+
for _, c := range n.Hub().Connections() {
1754+
c.Disconnect(DisconnectForceNoReconnect)
1755+
}
1756+
1757+
require.Eventually(t, func() bool { return n.Hub().NumClients() == 0 },
1758+
10*time.Second, 20*time.Millisecond,
1759+
"server-side disconnect did not complete")
1760+
1761+
// And the peer must actually see the connection end.
1762+
require.NoError(t, conn.SetReadDeadline(time.Now().Add(10*time.Second)))
1763+
for {
1764+
if _, _, rErr := conn.ReadMessage(); rErr != nil {
1765+
break
1766+
}
1767+
}
1768+
})
1769+
}
1770+
}
1771+
1772+
// TestProcessCommandsOffReadLoopDisconnectDuringCommand closes the connection
1773+
// from inside command processing. That is the one case the handoff genuinely
1774+
// changes: the close runs on the handoff goroutine while the read loop sits in
1775+
// the channel receive waiting for that same frame to finish. Teardown must still
1776+
// complete, and must not deadlock against the close handshake the read loop is
1777+
// the one to drain.
1778+
func TestProcessCommandsOffReadLoopDisconnectDuringCommand(t *testing.T) {
1779+
t.Parallel()
1780+
for _, offReadLoop := range []bool{false, true} {
1781+
offReadLoop := offReadLoop
1782+
name := "inline"
1783+
if offReadLoop {
1784+
name = "off_read_loop"
1785+
}
1786+
t.Run(name, func(t *testing.T) {
1787+
t.Parallel()
1788+
n, url := newOffReadLoopServer(t, offReadLoop, false)
1789+
conn := dialAndConnect(t, url)
1790+
defer func() { _ = conn.Close() }()
1791+
1792+
cmd, err := json.Marshal(&protocol.Command{
1793+
Id: 2, Rpc: &protocol.RPCRequest{Method: "disconnect", Data: []byte(`{}`)},
1794+
})
1795+
require.NoError(t, err)
1796+
require.NoError(t, conn.WriteMessage(websocket.TextMessage, cmd))
1797+
1798+
require.Eventually(t, func() bool { return n.Hub().NumClients() == 0 },
1799+
15*time.Second, 20*time.Millisecond,
1800+
"disconnect issued from command processing did not complete")
1801+
1802+
require.NoError(t, conn.SetReadDeadline(time.Now().Add(10*time.Second)))
1803+
for {
1804+
if _, _, rErr := conn.ReadMessage(); rErr != nil {
1805+
break
1806+
}
1807+
}
1808+
})
1809+
}
1810+
}
1811+
17201812
// What a connection costs while it is doing nothing is what caps connection
17211813
// density. These benchmarks hold b.N *real* WebSocket connections open, put
17221814
// each through a realistic command sequence, let them go idle, and then report

0 commit comments

Comments
 (0)