返回 DeepSeek-Reasonix
gateway_lock_test.go
根目录 / internal / bot / gateway_lock_test.go
1 package bot
2
3 import (
4 "context"
5 "errors"
6 "io"
7 "log/slog"
8 "runtime"
9 "sync"
10 "testing"
11 "time"
12
13 "reasonix/internal/control"
14 )
15
16 type closeProbeBotController struct {
17 *control.Controller
18 onClose func()
19 }
20
21 type stopWaitBotController struct {
22 botController
23 started chan struct{}
24 release chan struct{}
25 }
26
27 func (c *stopWaitBotController) RunTurn(context.Context, string) error {
28 close(c.started)
29 <-c.release
30 return nil
31 }
32
33 func (c *stopWaitBotController) SessionPath() string { return "" }
34 func (c *stopWaitBotController) WorkspaceRoot() string { return "" }
35 func (c *stopWaitBotController) Close() {}
36
37 type countingStopAdapter struct {
38 *fakeAdapter
39 mu sync.Mutex
40 stopCalls int
41 }
42
43 type cancelBlockingStartAdapter struct {
44 *fakeAdapter
45 entered chan struct{}
46 }
47
48 func (a *cancelBlockingStartAdapter) Start(ctx context.Context) error {
49 close(a.entered)
50 <-ctx.Done()
51 return ctx.Err()
52 }
53
54 func (a *countingStopAdapter) Stop() error {
55 a.mu.Lock()
56 a.stopCalls++
57 a.mu.Unlock()
58 return a.fakeAdapter.Stop()
59 }
60
61 func (a *countingStopAdapter) calls() int {
62 a.mu.Lock()
63 defer a.mu.Unlock()
64 return a.stopCalls
65 }
66
67 func (c *closeProbeBotController) Close() {
68 if c.onClose != nil {
69 c.onClose()
70 }
71 }
72
73 func TestBotGatewayStopClosesSessionsWithoutGatewayLock(t *testing.T) {
74 gw := &BotGateway{
75 controllers: map[string]*sessionState{},
76 }
77 closed := make(chan struct{}, 1)
78 gw.controllers["session"] = &sessionState{
79 ctrl: &closeProbeBotController{
80 Controller: control.New(control.Options{}),
81 onClose: func() {
82 gw.mu.Lock()
83 gw.mu.Unlock() //nolint:staticcheck // probe: lock must be immediately acquirable
84 closed <- struct{}{}
85 },
86 },
87 }
88
89 done := make(chan struct{})
90 go func() {
91 gw.Stop()
92 close(done)
93 }()
94
95 select {
96 case <-done:
97 case <-time.After(time.Second):
98 t.Fatal("Stop blocked while closing a controller")
99 }
100 select {
101 case <-closed:
102 case <-time.After(time.Second):
103 t.Fatal("controller Close was not called")
104 }
105 if len(gw.controllers) != 0 {
106 t.Fatalf("controllers retained after Stop: %d", len(gw.controllers))
107 }
108 }
109
110 func TestBotGatewayStopWaitsForDispatchHandler(t *testing.T) {
111 adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
112 entered := make(chan struct{})
113 release := make(chan struct{})
114 gw := NewGatewayWithAdapterBindings(GatewayConfig{
115 Enabled: map[Platform]bool{PlatformFeishu: true},
116 Allowlist: AllowlistConfig{AllowAll: true},
117 OnInbound: func(InboundMessage) {
118 close(entered)
119 <-release
120 },
121 }, []AdapterBinding{{ID: "feishu-lark", Platform: PlatformFeishu, Adapter: adapter}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
122 if err := gw.Start(context.Background()); err != nil {
123 t.Fatalf("Start: %v", err)
124 }
125 adapter.msgCh <- InboundMessage{ChatType: ChatDM, ChatID: "chat", UserID: "user", Text: "/status"}
126 select {
127 case <-entered:
128 case <-time.After(time.Second):
129 t.Fatal("dispatch handler did not start")
130 }
131
132 done := make(chan struct{})
133 go func() {
134 gw.Stop()
135 close(done)
136 }()
137 select {
138 case <-done:
139 t.Fatal("Stop returned while a dispatch handler was still running")
140 case <-time.After(50 * time.Millisecond):
141 }
142 close(release)
143 select {
144 case <-done:
145 case <-time.After(time.Second):
146 t.Fatal("Stop did not return after the dispatch handler exited")
147 }
148 }
149
150 func TestBotGatewayStopWaitsForTurn(t *testing.T) {
151 adapter := newFakeAdapter(PlatformWeixin, "fake-weixin")
152 gw := NewGatewayWithAdapterBindings(GatewayConfig{
153 Enabled: map[Platform]bool{PlatformWeixin: true},
154 Allowlist: AllowlistConfig{AllowAll: true},
155 }, []AdapterBinding{{ID: "weixin", Platform: PlatformWeixin, Adapter: adapter}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
156 ctrl := &stopWaitBotController{started: make(chan struct{}), release: make(chan struct{})}
157 msg := InboundMessage{Platform: PlatformWeixin, ConnectionID: "weixin", ChatType: ChatDM, ChatID: "chat", UserID: "user", Text: "hello"}
158 key := BuildSessionKey(msg.Session())
159 gw.controllers[key] = &sessionState{ctrl: ctrl, sink: &sessionEventSink{}}
160 if err := gw.Start(context.Background()); err != nil {
161 t.Fatalf("Start: %v", err)
162 }
163 adapter.msgCh <- msg
164 select {
165 case <-ctrl.started:
166 case <-time.After(time.Second):
167 t.Fatal("turn did not start")
168 }
169
170 done := make(chan struct{})
171 go func() {
172 gw.Stop()
173 close(done)
174 }()
175 select {
176 case <-done:
177 t.Fatal("Stop returned while a turn was still running")
178 case <-time.After(50 * time.Millisecond):
179 }
180 close(ctrl.release)
181 select {
182 case <-done:
183 case <-time.After(time.Second):
184 t.Fatal("Stop did not return after the turn exited")
185 }
186 }
187
188 type typingHoldAdapter struct {
189 *fakeAdapter
190 entered chan struct{} // one send per turn parked in SendTyping
191 release chan struct{}
192 }
193
194 func (a *typingHoldAdapter) SendTyping(context.Context, string) error {
195 a.entered <- struct{}{}
196 <-a.release
197 return nil
198 }
199
200 type cancelPublishBotController struct {
201 botController
202 closeEntered chan struct{} // one send when Close begins
203 closeHold chan struct{} // Close parks here, pinning Stop inside the closeSessions loop
204 turnCtx chan error // ctx.Err() observed on RunTurn entry
205 }
206
207 func (c *cancelPublishBotController) RunTurn(ctx context.Context, _ string) error {
208 c.turnCtx <- ctx.Err()
209 return nil
210 }
211
212 func (c *cancelPublishBotController) SessionPath() string { return "" }
213 func (c *cancelPublishBotController) WorkspaceRoot() string { return "" }
214 func (c *cancelPublishBotController) Close() {
215 c.closeEntered <- struct{}{}
216 <-c.closeHold
217 }
218
219 // Guards the cancel-publication window: runTurn publishes state.cancel under
220 // gw.mu only after the session is already visible in gw.controllers, so Stop
221 // can consume the field from another goroutine while the turn is still on its
222 // way to publication. TestBotGatewayStopWaitsForTurn stops only after RunTurn
223 // has begun (cancel already published) and never covers this window.
224 //
225 // Two sessions are needed because whichever state closeSessions visits first
226 // has its cancel read before Close signals the test — any write released off
227 // that signal is ordered after the read and invisible to the race detector.
228 // The first state's blocking Close pins Stop mid-loop instead, so the second
229 // state's cancel read happens after the turns were let through to publish.
230 // Run with -race: an unlocked read of state.cancel here is a data race.
231 func TestBotGatewayStopBeforeTurnCancelPublication(t *testing.T) {
232 adapter := &typingHoldAdapter{
233 fakeAdapter: newFakeAdapter(PlatformWeixin, "fake-weixin"),
234 entered: make(chan struct{}, 2),
235 release: make(chan struct{}),
236 }
237 gw := NewGatewayWithAdapterBindings(GatewayConfig{
238 Enabled: map[Platform]bool{PlatformWeixin: true},
239 Allowlist: AllowlistConfig{AllowAll: true},
240 }, []AdapterBinding{{ID: "weixin", Platform: PlatformWeixin, Adapter: adapter}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
241 closeEntered := make(chan struct{}, 2)
242 closeHold := make(chan struct{})
243 turnCtx := make(chan error, 2)
244 chats := []string{"chat-a", "chat-b"}
245 for _, chat := range chats {
246 msg := InboundMessage{Platform: PlatformWeixin, ConnectionID: "weixin", ChatType: ChatDM, ChatID: chat, UserID: "user"}
247 gw.controllers[BuildSessionKey(msg.Session())] = &sessionState{
248 ctrl: &cancelPublishBotController{closeEntered: closeEntered, closeHold: closeHold, turnCtx: turnCtx},
249 sink: &sessionEventSink{},
250 }
251 }
252 if err := gw.Start(context.Background()); err != nil {
253 t.Fatalf("Start: %v", err)
254 }
255 for _, chat := range chats {
256 adapter.msgCh <- InboundMessage{Platform: PlatformWeixin, ConnectionID: "weixin", ChatType: ChatDM, ChatID: chat, UserID: "user", Text: "hello"}
257 }
258 for range chats {
259 select {
260 case <-adapter.entered:
261 case <-time.After(time.Second):
262 t.Fatal("turn did not reach the pre-publication window")
263 }
264 }
265
266 done := make(chan struct{})
267 go func() {
268 gw.Stop()
269 close(done)
270 }()
271 // Stop is parked inside the closeSessions loop: the first state's cancel is
272 // consumed and its Close is held; the second state's cancel is still unread.
273 select {
274 case <-closeEntered:
275 case <-time.After(time.Second):
276 t.Fatal("Stop did not reach the session close loop")
277 }
278 // Let both turns publish their cancel funcs while Stop stays parked. The
279 // sleep is deliberate and cannot become a channel handshake: observing the
280 // publication would order the write before Stop's read and hide the race
281 // from the detector.
282 close(adapter.release)
283 time.Sleep(200 * time.Millisecond)
284 close(closeHold)
285
286 for range chats {
287 select {
288 case err := <-turnCtx:
289 if err == nil {
290 t.Fatal("turn ran with a live context after Stop closed its session")
291 }
292 case <-time.After(time.Second):
293 t.Fatal("turn did not run to completion")
294 }
295 }
296 select {
297 case <-done:
298 case <-time.After(time.Second):
299 t.Fatal("Stop did not return after the late turns exited")
300 }
301 }
302
303 func TestBotGatewayStopIsIdempotent(t *testing.T) {
304 adapter := &countingStopAdapter{fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu")}
305 gw := NewGatewayWithAdapterBindings(GatewayConfig{
306 Enabled: map[Platform]bool{PlatformFeishu: true},
307 }, []AdapterBinding{{ID: "feishu-lark", Platform: PlatformFeishu, Adapter: adapter}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
308 if err := gw.Start(context.Background()); err != nil {
309 t.Fatalf("Start: %v", err)
310 }
311
312 gw.Stop()
313 gw.Stop()
314 if got := adapter.calls(); got != 1 {
315 t.Fatalf("adapter Stop calls = %d, want 1", got)
316 }
317 }
318
319 func TestBotGatewayStopCancelsConcurrentStart(t *testing.T) {
320 adapter := &cancelBlockingStartAdapter{
321 fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu"),
322 entered: make(chan struct{}),
323 }
324 gw := NewGatewayWithAdapterBindings(GatewayConfig{
325 Enabled: map[Platform]bool{PlatformFeishu: true},
326 }, []AdapterBinding{{ID: "feishu-lark", Platform: PlatformFeishu, Adapter: adapter}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
327 startDone := make(chan error, 1)
328 go func() { startDone <- gw.Start(context.Background()) }()
329 select {
330 case <-adapter.entered:
331 case <-time.After(time.Second):
332 t.Fatal("adapter Start did not begin")
333 }
334
335 stopDone := make(chan struct{})
336 go func() {
337 gw.Stop()
338 close(stopDone)
339 }()
340 select {
341 case err := <-startDone:
342 if !errors.Is(err, context.Canceled) {
343 t.Fatalf("Start error = %v, want context canceled", err)
344 }
345 case <-time.After(time.Second):
346 t.Fatal("Stop did not cancel the in-progress Start")
347 }
348 select {
349 case <-stopDone:
350 case <-time.After(time.Second):
351 t.Fatal("Stop did not finish after Start returned")
352 }
353 }
354
355 // Guards the gw.cfg.Channels / gw.cfg.ConnectionChannels / gw.cfg.ToolApprovalMode
356 // snapshot locking: approval-mode writers mutate those under gw.mu while
357 // sessionOptionsForMessage and the project/session index builders read them.
358 // Run with -race; a lock-free read is a concurrent map read/write crash.
359 func TestBotGatewayToolApprovalModeConcurrentWithConfigReaders(t *testing.T) {
360 t.Setenv("REASONIX_HOME", t.TempDir())
361 gw := &BotGateway{
362 cfg: GatewayConfig{
363 WorkspaceRoot: t.TempDir(),
364 Channels: map[Platform]ChannelConfig{
365 PlatformFeishu: {ToolApprovalMode: control.ToolApprovalAsk},
366 },
367 ConnectionChannels: map[string]ChannelConfig{
368 "feishu-lark": {ToolApprovalMode: control.ToolApprovalAsk},
369 },
370 },
371 controllers: map[string]*sessionState{},
372 sessionOverrides: map[string]sessionRuntimeOverride{},
373 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
374 }
375
376 // Keep both sides finite. The former stop-driven writer kept allocating until
377 // the reader returned and repeatedly tripped Go's Windows GC in normal CI.
378 // The shared start edge orders neither loop after the other, so -race still
379 // observes any missing map lock even if one goroutine happens to run first.
380 const iterations = 200
381 start := make(chan struct{})
382 writerDone := make(chan struct{})
383 go func() {
384 defer close(writerDone)
385 <-start
386 modes := []string{control.ToolApprovalYolo, control.ToolApprovalAsk, control.ToolApprovalAuto}
387 for i := 0; i < iterations; i++ {
388 mode := modes[i%len(modes)]
389 gw.UpdateConnectionToolApprovalMode("feishu-lark", mode)
390 gw.mu.Lock()
391 gw.updateToolApprovalModeDefaultLocked(InboundMessage{Platform: PlatformFeishu}, mode)
392 gw.updateToolApprovalModeDefaultLocked(InboundMessage{}, mode)
393 gw.mu.Unlock()
394 runtime.Gosched()
395 }
396 }()
397
398 close(start)
399 connMsg := InboundMessage{Platform: PlatformFeishu, ConnectionID: "feishu-lark", ChatType: ChatDM, ChatID: "chat", UserID: "user"}
400 for i := 0; i < iterations; i++ {
401 gw.sessionOptionsForMessage(connMsg)
402 gw.sessionOptionsForMessage(InboundMessage{Platform: PlatformFeishu})
403 projects := gw.buildProjectIndex()
404 gw.buildSessionIndex(projects)
405 runtime.Gosched()
406 }
407 <-writerDone
408 }
409
409 lines GO