返回 DeepSeek-Reasonix
adapter_stop_test.go
根目录 / internal / bot / qq / adapter_stop_test.go
1 package qq
2
3 import (
4 "context"
5 "encoding/json"
6 "io"
7 "log/slog"
8 "net"
9 "net/http/httptest"
10 "strings"
11 "testing"
12 "time"
13
14 "golang.org/x/net/websocket"
15 )
16
17 // Guards the Stop drain contract: the gateway loop blocks in websocket reads
18 // that do not honor ctx, so Stop must close the tracked connection to unblock
19 // them and must wait for the loop goroutine to exit before returning.
20 func TestStopClosesTrackedConnAndWaitsForLoop(t *testing.T) {
21 srv := httptest.NewServer(websocket.Handler(func(ws *websocket.Conn) {
22 _, _ = io.Copy(io.Discard, ws) // hold the connection open, send nothing
23 }))
24 defer srv.Close()
25
26 conn, err := websocket.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), "", srv.URL)
27 if err != nil {
28 t.Fatalf("dial test server: %v", err)
29 }
30
31 a := &adapter{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
32 ctx, cancel := context.WithCancel(context.Background())
33 a.cancel = cancel
34 tracked := make(chan struct{})
35 decodeReturned := make(chan struct{})
36 a.loopWG.Go(func() {
37 if !a.trackConn(ctx, conn) {
38 conn.Close()
39 return
40 }
41 defer a.dropConn(conn)
42 close(tracked)
43 var payload gatewayPayload
44 _ = json.NewDecoder(conn).Decode(&payload) // blocks like connectGateway's reads
45 close(decodeReturned)
46 })
47 select {
48 case <-tracked:
49 case <-time.After(time.Second):
50 t.Fatal("gateway loop did not track its connection")
51 }
52
53 done := make(chan struct{})
54 go func() {
55 _ = a.Stop()
56 close(done)
57 }()
58 select {
59 case <-done:
60 case <-time.After(2 * time.Second):
61 t.Fatal("Stop did not close the gateway connection and wait for the loop")
62 }
63 select {
64 case <-decodeReturned:
65 case <-time.After(time.Second):
66 t.Fatal("Stop returned before the blocking gateway read exited")
67 }
68 }
69
70 // Guards the dial-phase Stop contract: until the dial returns, the conn is
71 // not tracked and closeConn has nothing to close, so cancelling the adapter
72 // context must abort a stalled TCP dial or WebSocket handshake. This locks in
73 // cfg.DialContext(ctx) over websocket.DialConfig, which dials with
74 // context.Background() and would leave Stop blocked on loopWG.Wait.
75 func TestStopUnblocksStalledHandshakeDial(t *testing.T) {
76 ln, err := net.Listen("tcp", "127.0.0.1:0")
77 if err != nil {
78 t.Fatalf("listen: %v", err)
79 }
80 defer ln.Close()
81
82 accepted := make(chan net.Conn, 1)
83 go func() {
84 conn, err := ln.Accept()
85 if err != nil {
86 return
87 }
88 accepted <- conn // hold the conn open, never answer the handshake
89 }()
90 defer func() {
91 select {
92 case conn := <-accepted:
93 conn.Close()
94 default:
95 }
96 }()
97
98 a := &adapter{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
99 ctx, cancel := context.WithCancel(context.Background())
100 a.cancel = cancel
101 dialErr := make(chan error, 1)
102 a.loopWG.Go(func() {
103 conn, err := a.dialGateway(ctx, "ws://"+ln.Addr().String(), "test-token")
104 if err == nil {
105 conn.Close()
106 }
107 dialErr <- err
108 })
109
110 var srvConn net.Conn
111 select {
112 case srvConn = <-accepted:
113 defer srvConn.Close()
114 case <-time.After(time.Second):
115 t.Fatal("dial never reached the stalled server")
116 }
117
118 done := make(chan struct{})
119 go func() {
120 _ = a.Stop()
121 close(done)
122 }()
123 select {
124 case <-done:
125 case <-time.After(2 * time.Second):
126 t.Fatal("Stop blocked on a stalled gateway handshake")
127 }
128 select {
129 case err := <-dialErr:
130 if err == nil {
131 t.Fatal("stalled handshake dial unexpectedly succeeded")
132 }
133 case <-time.After(time.Second):
134 t.Fatal("dial did not return after Stop cancelled the context")
135 }
136 }
137
138 // A connection that finishes dialing after Stop must not be published: Stop
139 // has already emptied the tracked slot, so a late publication would leave a
140 // conn (and its blocked reader) that nothing can ever close.
141 func TestTrackConnRefusesPublicationAfterCancel(t *testing.T) {
142 a := &adapter{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
143 ctx, cancel := context.WithCancel(context.Background())
144 cancel()
145 if a.trackConn(ctx, &websocket.Conn{}) {
146 t.Fatal("trackConn published a connection after cancellation")
147 }
148 a.connMu.Lock()
149 defer a.connMu.Unlock()
150 if a.conn != nil {
151 t.Fatal("cancelled publication still stored the connection")
152 }
153 }
154
155 func TestStopWithoutStartIsSafe(t *testing.T) {
156 a := &adapter{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
157 done := make(chan struct{})
158 go func() {
159 _ = a.Stop()
160 close(done)
161 }()
162 select {
163 case <-done:
164 case <-time.After(time.Second):
165 t.Fatal("Stop blocked on a never-started adapter")
166 }
167 }
168
168 lines GO