返回 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 stubBotController
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 stubBotController
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 type panickingRootBotController struct {
208 stubBotController
209 }
210
211 func (panickingRootBotController) WorkspaceRoot() string { panic("workspace root unavailable") }
212
213 func TestAPanicWhileClaimingASessionReleasesTheGatewayLock(t *testing.T) {
214 gw := NewGateway(GatewayConfig{Allowlist: AllowlistConfig{AllowAll: true}}, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
215 msg := InboundMessage{Platform: PlatformWeixin, ConnectionID: "weixin", ChatType: ChatDM, ChatID: "chat", UserID: "user"}
216 key := BuildSessionKey(msg.Session())
217 gw.controllers[key] = &sessionState{ctrl: panickingRootBotController{}, sink: &sessionEventSink{}}
218
219 panicked := make(chan any, 1)
220 go func() {
221 defer func() { panicked <- recover() }()
222 gw.getOrCreateSession(context.Background(), key, msg)
223 }()
224 if recovered := <-panicked; recovered == nil {
225 t.Fatal("the controller's panic did not reach the caller")
226 }
227
228 deadline := time.Now().Add(time.Second)
229 for !gw.mu.TryLock() {
230 if time.Now().After(deadline) {
231 t.Fatal("the gateway lock is still held after the panic unwound")
232 }
233 time.Sleep(time.Millisecond)
234 }
235 gw.mu.Unlock()
236 }
237
238 func (c *cancelPublishBotController) RunTurn(ctx context.Context, _ string) error {
239 c.turnCtx <- ctx.Err()
240 return nil
241 }
242
243 func (c *cancelPublishBotController) SessionPath() string { return "" }
244 func (c *cancelPublishBotController) WorkspaceRoot() string { return "" }
245 func (c *cancelPublishBotController) Close() {
246 c.closeEntered <- struct{}{}
247 <-c.closeHold
248 }
249
250 // Guards the cancel-publication window: runTurn publishes state.cancel under
251 // gw.mu only after the session is already visible in gw.controllers, so Stop
252 // can consume the field from another goroutine while the turn is still on its
253 // way to publication. TestBotGatewayStopWaitsForTurn stops only after RunTurn
254 // has begun (cancel already published) and never covers this window.
255 //
256 // Two sessions are needed because whichever state closeSessions visits first
257 // has its cancel read before Close signals the test — any write released off
258 // that signal is ordered after the read and invisible to the race detector.
259 // The first state's blocking Close pins Stop mid-loop instead, so the second
260 // state's cancel read happens after the turns were let through to publish.
261 // Run with -race: an unlocked read of state.cancel here is a data race.
262 func TestBotGatewayStopBeforeTurnCancelPublication(t *testing.T) {
263 adapter := &typingHoldAdapter{
264 fakeAdapter: newFakeAdapter(PlatformWeixin, "fake-weixin"),
265 entered: make(chan struct{}, 2),
266 release: make(chan struct{}),
267 }
268 gw := NewGatewayWithAdapterBindings(GatewayConfig{
269 Enabled: map[Platform]bool{PlatformWeixin: true},
270 Allowlist: AllowlistConfig{AllowAll: true},
271 }, []AdapterBinding{{ID: "weixin", Platform: PlatformWeixin, Adapter: adapter}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
272 closeEntered := make(chan struct{}, 2)
273 closeHold := make(chan struct{})
274 turnCtx := make(chan error, 2)
275 chats := []string{"chat-a", "chat-b"}
276 for _, chat := range chats {
277 msg := InboundMessage{Platform: PlatformWeixin, ConnectionID: "weixin", ChatType: ChatDM, ChatID: chat, UserID: "user"}
278 gw.controllers[BuildSessionKey(msg.Session())] = &sessionState{
279 ctrl: &cancelPublishBotController{closeEntered: closeEntered, closeHold: closeHold, turnCtx: turnCtx},
280 sink: &sessionEventSink{},
281 }
282 }
283 if err := gw.Start(context.Background()); err != nil {
284 t.Fatalf("Start: %v", err)
285 }
286 for _, chat := range chats {
287 adapter.msgCh <- InboundMessage{Platform: PlatformWeixin, ConnectionID: "weixin", ChatType: ChatDM, ChatID: chat, UserID: "user", Text: "hello"}
288 }
289 for range chats {
290 select {
291 case <-adapter.entered:
292 case <-time.After(time.Second):
293 t.Fatal("turn did not reach the pre-publication window")
294 }
295 }
296
297 done := make(chan struct{})
298 go func() {
299 gw.Stop()
300 close(done)
301 }()
302 // Stop is parked inside the closeSessions loop: the first state's cancel is
303 // consumed and its Close is held; the second state's cancel is still unread.
304 select {
305 case <-closeEntered:
306 case <-time.After(time.Second):
307 t.Fatal("Stop did not reach the session close loop")
308 }
309 // Let both turns publish their cancel funcs while Stop stays parked. The
310 // sleep is deliberate and cannot become a channel handshake: observing the
311 // publication would order the write before Stop's read and hide the race
312 // from the detector.
313 close(adapter.release)
314 time.Sleep(200 * time.Millisecond)
315 close(closeHold)
316
317 for range chats {
318 select {
319 case err := <-turnCtx:
320 if err == nil {
321 t.Fatal("turn ran with a live context after Stop closed its session")
322 }
323 case <-time.After(time.Second):
324 t.Fatal("turn did not run to completion")
325 }
326 }
327 select {
328 case <-done:
329 case <-time.After(time.Second):
330 t.Fatal("Stop did not return after the late turns exited")
331 }
332 }
333
334 func TestBotGatewayStopIsIdempotent(t *testing.T) {
335 adapter := &countingStopAdapter{fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu")}
336 gw := NewGatewayWithAdapterBindings(GatewayConfig{
337 Enabled: map[Platform]bool{PlatformFeishu: true},
338 }, []AdapterBinding{{ID: "feishu-lark", Platform: PlatformFeishu, Adapter: adapter}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
339 if err := gw.Start(context.Background()); err != nil {
340 t.Fatalf("Start: %v", err)
341 }
342
343 gw.Stop()
344 gw.Stop()
345 if got := adapter.calls(); got != 1 {
346 t.Fatalf("adapter Stop calls = %d, want 1", got)
347 }
348 }
349
350 func TestBotGatewayStopCancelsConcurrentStart(t *testing.T) {
351 adapter := &cancelBlockingStartAdapter{
352 fakeAdapter: newFakeAdapter(PlatformFeishu, "fake-feishu"),
353 entered: make(chan struct{}),
354 }
355 gw := NewGatewayWithAdapterBindings(GatewayConfig{
356 Enabled: map[Platform]bool{PlatformFeishu: true},
357 }, []AdapterBinding{{ID: "feishu-lark", Platform: PlatformFeishu, Adapter: adapter}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
358 startDone := make(chan error, 1)
359 go func() { startDone <- gw.Start(context.Background()) }()
360 select {
361 case <-adapter.entered:
362 case <-time.After(time.Second):
363 t.Fatal("adapter Start did not begin")
364 }
365
366 stopDone := make(chan struct{})
367 go func() {
368 gw.Stop()
369 close(stopDone)
370 }()
371 select {
372 case err := <-startDone:
373 if !errors.Is(err, context.Canceled) {
374 t.Fatalf("Start error = %v, want context canceled", err)
375 }
376 case <-time.After(time.Second):
377 t.Fatal("Stop did not cancel the in-progress Start")
378 }
379 select {
380 case <-stopDone:
381 case <-time.After(time.Second):
382 t.Fatal("Stop did not finish after Start returned")
383 }
384 }
385
386 // Guards the gw.cfg.Channels / gw.cfg.ConnectionChannels / gw.cfg.ToolApprovalMode
387 // snapshot locking: approval-mode writers mutate those under gw.mu while
388 // sessionOptionsForMessage and the project/session index builders read them.
389 // Run with -race; a lock-free read is a concurrent map read/write crash.
390 func TestBotGatewayToolApprovalModeConcurrentWithConfigReaders(t *testing.T) {
391 t.Setenv("REASONIX_HOME", t.TempDir())
392 gw := &BotGateway{
393 cfg: GatewayConfig{
394 WorkspaceRoot: t.TempDir(),
395 Channels: map[Platform]ChannelConfig{
396 PlatformFeishu: {ToolApprovalMode: control.ToolApprovalAsk},
397 },
398 ConnectionChannels: map[string]ChannelConfig{
399 "feishu-lark": {ToolApprovalMode: control.ToolApprovalAsk},
400 },
401 },
402 controllers: map[string]*sessionState{},
403 sessionOverrides: map[string]sessionRuntimeOverride{},
404 logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
405 }
406
407 // Keep both sides finite. The former stop-driven writer kept allocating until
408 // the reader returned and repeatedly tripped Go's Windows GC in normal CI.
409 // The shared start edge orders neither loop after the other, so -race still
410 // observes any missing map lock even if one goroutine happens to run first.
411 const iterations = 200
412 start := make(chan struct{})
413 writerDone := make(chan struct{})
414 go func() {
415 defer close(writerDone)
416 <-start
417 modes := []string{control.ToolApprovalYolo, control.ToolApprovalAsk, control.ToolApprovalAuto}
418 for i := range iterations {
419 mode := modes[i%len(modes)]
420 gw.UpdateConnectionToolApprovalMode("feishu-lark", mode)
421 gw.mu.Lock()
422 gw.updateToolApprovalModeDefaultLocked(InboundMessage{Platform: PlatformFeishu}, mode)
423 gw.updateToolApprovalModeDefaultLocked(InboundMessage{}, mode)
424 gw.mu.Unlock()
425 runtime.Gosched()
426 }
427 }()
428
429 close(start)
430 connMsg := InboundMessage{Platform: PlatformFeishu, ConnectionID: "feishu-lark", ChatType: ChatDM, ChatID: "chat", UserID: "user"}
431 for range iterations {
432 gw.sessionOptionsForMessage(connMsg)
433 gw.sessionOptionsForMessage(InboundMessage{Platform: PlatformFeishu})
434 projects := gw.buildProjectIndex()
435 gw.buildSessionIndex(projects)
436 runtime.Gosched()
437 }
438 <-writerDone
439 }
440
440 lines GO