返回 DeepSeek-Reasonix
serve_lock_test.go
根目录 / internal / serve / serve_lock_test.go
1 package serve
2
3 import (
4 "context"
5 "io"
6 "net/http/httptest"
7 "strings"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "reasonix/internal/config"
13 "reasonix/internal/control"
14 "reasonix/internal/jobs"
15 )
16
17 // lockProbeController wraps a real controller but intercepts the two blocking
18 // steps of a model switch — Snapshot (may touch disk) and Close (jobs grace wait
19 // up to 15s + SessionEnd hook) — so a test can assert switchModel runs them while
20 // s.mu is free. Embedding *control.Controller keeps it a full SessionAPI.
21 type lockProbeController struct {
22 *control.Controller
23 onSnapshot func()
24 onClose func()
25 }
26
27 func (c *lockProbeController) Snapshot() error {
28 if c.onSnapshot != nil {
29 c.onSnapshot()
30 }
31 return c.Controller.Snapshot()
32 }
33
34 func (c *lockProbeController) Close() {
35 if c.onClose != nil {
36 c.onClose()
37 }
38 c.Controller.Close()
39 }
40
41 // expectServerMutexAvailable returns a callback that fails the test if s.mu can't
42 // be acquired within 500ms — i.e. switchModel is holding the lock across the
43 // callback. It signals checks once it has probed, so the test can assert the
44 // callback actually ran.
45 func expectServerMutexAvailable(t *testing.T, s *Server, checks chan<- struct{}) func() {
46 t.Helper()
47 return func() {
48 acquired := make(chan struct{})
49 go func() {
50 s.mu.Lock()
51 s.mu.Unlock() //nolint:staticcheck // probe: lock must be immediately acquirable
52 close(acquired)
53 }()
54 select {
55 case <-acquired:
56 case <-time.After(500 * time.Millisecond):
57 t.Error("switchModel held s.mu across a Snapshot/Close callback")
58 }
59 if checks == nil {
60 return
61 }
62 select {
63 case checks <- struct{}{}:
64 default:
65 }
66 }
67 }
68
69 // TestSwitchModelDoesNotHoldServerLockDuringSnapshotAndClose is the regression
70 // guard for the serve.go:114 lock-audit fix: Snapshot on the old controller,
71 // boot.Build of the new one, and Close of the old one must all run OFF s.mu so
72 // HTTP handlers blocked on s.ctl()'s RLock aren't stalled (worst case 15s+ on
73 // Close). The probe callbacks try to grab s.mu on another goroutine and fail
74 // fast if it's held.
75 func TestSwitchModelDoesNotHoldServerLockDuringSnapshotAndClose(t *testing.T) {
76 bc := NewBroadcaster()
77 snapChecks := make(chan struct{}, 1)
78 closeChecks := make(chan struct{}, 1)
79
80 old := &lockProbeController{Controller: control.New(control.Options{Sink: bc})}
81 s := &Server{ctrl: old, bc: bc}
82 old.onSnapshot = expectServerMutexAvailable(t, s, snapChecks)
83 old.onClose = expectServerMutexAvailable(t, s, closeChecks)
84
85 var built *control.Controller
86 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
87 built = control.New(control.Options{Sink: bc})
88 return built, nil
89 }
90
91 if err := s.switchModel(context.Background(), "next-model"); err != nil {
92 t.Fatalf("switchModel: %v", err)
93 }
94
95 select {
96 case <-snapChecks:
97 case <-time.After(time.Second):
98 t.Fatal("Snapshot callback never ran during switchModel")
99 }
100 select {
101 case <-closeChecks:
102 case <-time.After(time.Second):
103 t.Fatal("Close callback never ran during switchModel")
104 }
105 if s.ctl() != built {
106 t.Fatal("switchModel did not publish the freshly built controller")
107 }
108 }
109
110 // TestSwitchModelDiscardsBuiltControllerOnConcurrentSwap verifies the failure
111 // path: if the controller is swapped out (e.g. by resume) between Build and the
112 // publish lock, switchModel must discard the new controller instead of leaking
113 // it or clobbering the concurrent swap.
114 func TestSwitchModelDiscardsBuiltControllerOnConcurrentSwap(t *testing.T) {
115 bc := NewBroadcaster()
116 old := control.New(control.Options{Sink: bc})
117 other := control.New(control.Options{Sink: bc})
118 s := &Server{ctrl: old, bc: bc}
119
120 var built *control.Controller
121 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
122 // Simulate a concurrent path (resume/new-session) replacing the
123 // controller after the off-lock snapshot but before the publish lock.
124 s.mu.Lock()
125 s.ctrl = other
126 s.mu.Unlock()
127 built = control.New(control.Options{Sink: bc})
128 return built, nil
129 }
130
131 err := s.switchModel(context.Background(), "next-model")
132 if err == nil {
133 t.Fatal("expected switchModel to fail when the controller changed mid-switch")
134 }
135 if s.ctl() != other {
136 t.Fatal("switchModel clobbered a concurrent controller swap")
137 }
138 }
139
140 // TestSwitchModelRejectsWhileRunning keeps the pre-existing guard: a switch is
141 // refused while a turn is running, before any snapshot/build work.
142 func TestSwitchModelRejectsWhileRunning(t *testing.T) {
143 bc := NewBroadcaster()
144 ctrl := control.New(control.Options{Runner: blockingRunner{}, Sink: bc})
145 s := &Server{ctrl: ctrl, bc: bc}
146 built := false
147 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
148 built = true
149 return control.New(control.Options{Sink: bc}), nil
150 }
151
152 // Drive the controller into a running turn.
153 ctrl.SubmitHTTP("hi")
154 waitRunning(t, ctrl)
155
156 if err := s.switchModel(context.Background(), "next-model"); err == nil {
157 t.Fatal("expected switchModel to refuse while a turn is running")
158 }
159 if built {
160 t.Fatal("switchModel built a controller despite a running turn")
161 }
162 ctrl.Cancel()
163 waitNotRunning(t, ctrl)
164 }
165
166 func TestSwitchModelRejectsWhileBackgroundJobRunning(t *testing.T) {
167 bc := NewBroadcaster()
168 manager := jobs.NewManager(bc)
169 ctrl := control.New(control.Options{Sink: bc, Jobs: manager})
170 defer ctrl.Close()
171 manager.Start("task", "running", func(ctx context.Context, _ io.Writer) (string, error) {
172 <-ctx.Done()
173 return "", ctx.Err()
174 })
175
176 s := &Server{ctrl: ctrl, bc: bc}
177 built := false
178 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
179 built = true
180 return control.New(control.Options{Sink: bc}), nil
181 }
182
183 if err := s.switchModel(context.Background(), "next-model"); err == nil {
184 t.Fatal("expected switchModel to refuse while a background job is running")
185 }
186 if built {
187 t.Fatal("switchModel built a controller despite a running background job")
188 }
189 }
190
191 func TestExtensionReloadRejectsWhileTurnRunning(t *testing.T) {
192 bc := NewBroadcaster()
193 ctrl := control.New(control.Options{Runner: blockingRunner{}, Sink: bc})
194 s := New(ctrl, bc, config.ServeConfig{})
195 built := false
196 s.rebuildController = func(_ context.Context, _ *control.Controller, _ string) (*control.Controller, error) {
197 built = true
198 return control.New(control.Options{Sink: bc}), nil
199 }
200
201 ctrl.SubmitHTTP("hi")
202 waitRunning(t, ctrl)
203 if err := s.reloadExtensions(context.Background()); err == nil {
204 t.Fatal("expected extension reload to refuse while a turn is running")
205 }
206 if built {
207 t.Fatal("extension reload built a controller despite a running turn")
208 }
209 ctrl.Cancel()
210 waitNotRunning(t, ctrl)
211 }
212
213 func TestConcurrentExtensionReloadsAreSerialized(t *testing.T) {
214 bc := NewBroadcaster()
215 s := New(control.New(control.Options{Sink: bc}), bc, config.ServeConfig{})
216 firstEntered := make(chan struct{})
217 secondEntered := make(chan struct{})
218 releaseFirst := make(chan struct{})
219 var calls atomic.Int32
220 s.rebuildController = func(_ context.Context, _ *control.Controller, _ string) (*control.Controller, error) {
221 if calls.Add(1) == 1 {
222 close(firstEntered)
223 <-releaseFirst
224 } else {
225 close(secondEntered)
226 }
227 return control.New(control.Options{Sink: bc}), nil
228 }
229
230 done := make(chan error, 2)
231 go func() { done <- s.reloadExtensions(context.Background()) }()
232 <-firstEntered
233 go func() { done <- s.reloadExtensions(context.Background()) }()
234 select {
235 case <-secondEntered:
236 t.Fatal("second extension reload entered the rebuild while the first still owned bindMu")
237 case <-time.After(100 * time.Millisecond):
238 }
239 close(releaseFirst)
240 for range 2 {
241 if err := <-done; err != nil {
242 t.Fatalf("reload: %v", err)
243 }
244 }
245 if calls.Load() != 2 {
246 t.Fatalf("rebuild calls = %d, want 2", calls.Load())
247 }
248 }
249
250 func TestSubmitWaitsForExtensionReloadAndTargetsReplacement(t *testing.T) {
251 bc := NewBroadcaster()
252 old := control.New(control.Options{Sink: bc, Runner: blockingRunner{}})
253 s := New(old, bc, config.ServeConfig{})
254 buildEntered := make(chan struct{})
255 releaseBuild := make(chan struct{})
256 replacement := control.New(control.Options{Sink: bc, Runner: blockingRunner{}})
257 s.rebuildController = func(_ context.Context, _ *control.Controller, _ string) (*control.Controller, error) {
258 close(buildEntered)
259 <-releaseBuild
260 return replacement, nil
261 }
262
263 reloadDone := make(chan error, 1)
264 go func() { reloadDone <- s.reloadExtensions(context.Background()) }()
265 <-buildEntered
266
267 submitDone := make(chan int, 1)
268 go func() {
269 req := httptest.NewRequest("POST", "/submit", strings.NewReader(`{"input":"hello"}`))
270 rec := httptest.NewRecorder()
271 s.submit(rec, req)
272 submitDone <- rec.Code
273 }()
274 select {
275 case <-submitDone:
276 t.Fatal("submit crossed the extension reload generation boundary")
277 case <-time.After(100 * time.Millisecond):
278 }
279
280 close(releaseBuild)
281 if err := <-reloadDone; err != nil {
282 t.Fatalf("reload: %v", err)
283 }
284 if code := <-submitDone; code != 202 {
285 t.Fatalf("submit status = %d, want 202", code)
286 }
287 waitRunning(t, replacement)
288 if old.Running() {
289 t.Fatal("submit started on the outgoing controller")
290 }
291 replacement.Cancel()
292 waitNotRunning(t, replacement)
293 }
294
295 // blockingRunner keeps a turn "running" until its context is cancelled, so tests
296 // can observe Running() == true deterministically.
297 type blockingRunner struct{}
298
299 func (blockingRunner) Run(ctx context.Context, _ string) error {
300 <-ctx.Done()
301 return ctx.Err()
302 }
303
304 func waitRunning(t *testing.T, ctrl *control.Controller) {
305 t.Helper()
306 deadline := time.After(2 * time.Second)
307 for {
308 if ctrl.Running() {
309 return
310 }
311 select {
312 case <-deadline:
313 t.Fatal("controller never entered the running state")
314 case <-time.After(5 * time.Millisecond):
315 }
316 }
317 }
318
319 func waitNotRunning(t *testing.T, ctrl *control.Controller) {
320 t.Helper()
321 deadline := time.After(2 * time.Second)
322 for {
323 if !ctrl.Running() {
324 return
325 }
326 select {
327 case <-deadline:
328 t.Fatal("controller never left the running state after cancel")
329 case <-time.After(5 * time.Millisecond):
330 }
331 }
332 }
333
333 lines GO