返回 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.Add(1)
37 go func() {
38 defer a.loopWG.Done()
39 if !a.trackConn(ctx, conn) {
40 conn.Close()
41 return
42 }
43 defer a.dropConn(conn)
44 close(tracked)
45 var payload gatewayPayload
46 _ = json.NewDecoder(conn).Decode(&payload) // blocks like connectGateway's reads
47 close(decodeReturned)
48 }()
49 select {
50 case <-tracked:
51 case <-time.After(time.Second):
52 t.Fatal("gateway loop did not track its connection")
53 }
54
55 done := make(chan struct{})
56 go func() {
57 _ = a.Stop()
58 close(done)
59 }()
60 select {
61 case <-done:
62 case <-time.After(2 * time.Second):
63 t.Fatal("Stop did not close the gateway connection and wait for the loop")
64 }
65 select {
66 case <-decodeReturned:
67 case <-time.After(time.Second):
68 t.Fatal("Stop returned before the blocking gateway read exited")
69 }
70 }
71
72 // Guards the dial-phase Stop contract: until the dial returns, the conn is
73 // not tracked and closeConn has nothing to close, so cancelling the adapter
74 // context must abort a stalled TCP dial or WebSocket handshake. This locks in
75 // cfg.DialContext(ctx) over websocket.DialConfig, which dials with
76 // context.Background() and would leave Stop blocked on loopWG.Wait.
77 func TestStopUnblocksStalledHandshakeDial(t *testing.T) {
78 ln, err := net.Listen("tcp", "127.0.0.1:0")
79 if err != nil {
80 t.Fatalf("listen: %v", err)
81 }
82 defer ln.Close()
83
84 accepted := make(chan net.Conn, 1)
85 go func() {
86 conn, err := ln.Accept()
87 if err != nil {
88 return
89 }
90 accepted <- conn // hold the conn open, never answer the handshake
91 }()
92 defer func() {
93 select {
94 case conn := <-accepted:
95 conn.Close()
96 default:
97 }
98 }()
99
100 a := &adapter{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
101 ctx, cancel := context.WithCancel(context.Background())
102 a.cancel = cancel
103 dialErr := make(chan error, 1)
104 a.loopWG.Add(1)
105 go func() {
106 defer a.loopWG.Done()
107 conn, err := a.dialGateway(ctx, "ws://"+ln.Addr().String(), "test-token")
108 if err == nil {
109 conn.Close()
110 }
111 dialErr <- err
112 }()
113
114 var srvConn net.Conn
115 select {
116 case srvConn = <-accepted:
117 defer srvConn.Close()
118 case <-time.After(time.Second):
119 t.Fatal("dial never reached the stalled server")
120 }
121
122 done := make(chan struct{})
123 go func() {
124 _ = a.Stop()
125 close(done)
126 }()
127 select {
128 case <-done:
129 case <-time.After(2 * time.Second):
130 t.Fatal("Stop blocked on a stalled gateway handshake")
131 }
132 select {
133 case err := <-dialErr:
134 if err == nil {
135 t.Fatal("stalled handshake dial unexpectedly succeeded")
136 }
137 case <-time.After(time.Second):
138 t.Fatal("dial did not return after Stop cancelled the context")
139 }
140 }
141
142 // A connection that finishes dialing after Stop must not be published: Stop
143 // has already emptied the tracked slot, so a late publication would leave a
144 // conn (and its blocked reader) that nothing can ever close.
145 func TestTrackConnRefusesPublicationAfterCancel(t *testing.T) {
146 a := &adapter{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
147 ctx, cancel := context.WithCancel(context.Background())
148 cancel()
149 if a.trackConn(ctx, &websocket.Conn{}) {
150 t.Fatal("trackConn published a connection after cancellation")
151 }
152 a.connMu.Lock()
153 defer a.connMu.Unlock()
154 if a.conn != nil {
155 t.Fatal("cancelled publication still stored the connection")
156 }
157 }
158
159 func TestStopWithoutStartIsSafe(t *testing.T) {
160 a := &adapter{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
161 done := make(chan struct{})
162 go func() {
163 _ = a.Stop()
164 close(done)
165 }()
166 select {
167 case <-done:
168 case <-time.After(time.Second):
169 t.Fatal("Stop blocked on a never-started adapter")
170 }
171 }
172
172 lines GO