返回 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"
7 "net/http/httptest"
8 "path/filepath"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "testing"
13 "time"
14
15 "reasonix/internal/config"
16 "reasonix/internal/control"
17 "reasonix/internal/jobs"
18 )
19
20 type blockingRequestBody struct {
21 started chan struct{}
22 release chan struct{}
23 once sync.Once
24 }
25
26 func (b *blockingRequestBody) Read([]byte) (int, error) {
27 b.once.Do(func() { close(b.started) })
28 <-b.release
29 return 0, io.EOF
30 }
31
32 func (*blockingRequestBody) Close() error { return nil }
33
34 // lockProbeController wraps a real controller but intercepts the two blocking
35 // steps of a model switch — Snapshot (may touch disk) and Close (jobs grace wait
36 // up to 15s + SessionEnd hook) — so a test can assert switchModel runs them while
37 // s.mu is free. Embedding *control.Controller keeps it a full SessionAPI.
38 type lockProbeController struct {
39 *control.Controller
40 onSnapshot func()
41 onClose func()
42 }
43
44 type blockingNewSessionController struct {
45 *control.Controller
46 entered chan struct{}
47 release chan struct{}
48 }
49
50 func (c *blockingNewSessionController) NewSession() error {
51 close(c.entered)
52 <-c.release
53 return c.Controller.NewSession()
54 }
55
56 func (c *lockProbeController) Snapshot() error {
57 if c.onSnapshot != nil {
58 c.onSnapshot()
59 }
60 return c.Controller.Snapshot()
61 }
62
63 func (c *lockProbeController) Close() {
64 if c.onClose != nil {
65 c.onClose()
66 }
67 c.Controller.Close()
68 }
69
70 // expectServerMutexAvailable returns a callback that fails the test if s.mu can't
71 // be acquired within 500ms — i.e. switchModel is holding the lock across the
72 // callback. It signals checks once it has probed, so the test can assert the
73 // callback actually ran.
74 func expectServerMutexAvailable(t *testing.T, s *Server, checks chan<- struct{}) func() {
75 t.Helper()
76 return func() {
77 acquired := make(chan struct{})
78 go func() {
79 s.mu.Lock()
80 s.mu.Unlock() //nolint:staticcheck // probe: lock must be immediately acquirable
81 close(acquired)
82 }()
83 select {
84 case <-acquired:
85 case <-time.After(500 * time.Millisecond):
86 t.Error("switchModel held s.mu across a Snapshot/Close callback")
87 }
88 if checks == nil {
89 return
90 }
91 select {
92 case checks <- struct{}{}:
93 default:
94 }
95 }
96 }
97
98 // TestSwitchModelDoesNotHoldServerLockDuringSnapshotAndClose is the regression
99 // guard for the serve.go:114 lock-audit fix: Snapshot on the old controller,
100 // boot.Build of the new one, and Close of the old one must all run OFF s.mu so
101 // HTTP handlers blocked on s.ctl()'s RLock aren't stalled (worst case 15s+ on
102 // Close). The probe callbacks try to grab s.mu on another goroutine and fail
103 // fast if it's held.
104 func TestSwitchModelDoesNotHoldServerLockDuringSnapshotAndClose(t *testing.T) {
105 bc := NewBroadcaster()
106 snapChecks := make(chan struct{}, 1)
107 closeChecks := make(chan struct{}, 1)
108
109 old := &lockProbeController{Controller: control.New(control.Options{Sink: bc})}
110 s := &Server{ctrl: old, bc: bc}
111 old.onSnapshot = expectServerMutexAvailable(t, s, snapChecks)
112 old.onClose = expectServerMutexAvailable(t, s, closeChecks)
113
114 var built *control.Controller
115 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
116 built = control.New(control.Options{Sink: bc})
117 return built, nil
118 }
119
120 if err := s.switchModel(context.Background(), "next-model"); err != nil {
121 t.Fatalf("switchModel: %v", err)
122 }
123
124 select {
125 case <-snapChecks:
126 case <-time.After(time.Second):
127 t.Fatal("Snapshot callback never ran during switchModel")
128 }
129 select {
130 case <-closeChecks:
131 case <-time.After(time.Second):
132 t.Fatal("Close callback never ran during switchModel")
133 }
134 if s.ctl() != built {
135 t.Fatal("switchModel did not publish the freshly built controller")
136 }
137 }
138
139 // TestSwitchModelDiscardsBuiltControllerOnConcurrentSwap verifies the failure
140 // path: if the controller is swapped out (e.g. by resume) between Build and the
141 // publish lock, switchModel must discard the new controller instead of leaking
142 // it or clobbering the concurrent swap.
143 func TestSwitchModelDiscardsBuiltControllerOnConcurrentSwap(t *testing.T) {
144 bc := NewBroadcaster()
145 old := control.New(control.Options{Sink: bc})
146 other := control.New(control.Options{Sink: bc})
147 s := &Server{ctrl: old, bc: bc}
148
149 var built *control.Controller
150 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
151 // Simulate a concurrent path (resume/new-session) replacing the
152 // controller after the off-lock snapshot but before the publish lock.
153 s.mu.Lock()
154 s.ctrl = other
155 s.mu.Unlock()
156 built = control.New(control.Options{Sink: bc})
157 return built, nil
158 }
159
160 err := s.switchModel(context.Background(), "next-model")
161 if err == nil {
162 t.Fatal("expected switchModel to fail when the controller changed mid-switch")
163 }
164 if s.ctl() != other {
165 t.Fatal("switchModel clobbered a concurrent controller swap")
166 }
167 }
168
169 // TestSwitchModelRejectsWhileRunning keeps the pre-existing guard: a switch is
170 // refused while a turn is running, before any snapshot/build work.
171 func TestSwitchModelRejectsWhileRunning(t *testing.T) {
172 bc := NewBroadcaster()
173 ctrl := control.New(control.Options{Runner: blockingRunner{}, Sink: bc})
174 s := &Server{ctrl: ctrl, bc: bc}
175 built := false
176 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
177 built = true
178 return control.New(control.Options{Sink: bc}), nil
179 }
180
181 // Drive the controller into a running turn.
182 ctrl.SubmitHTTP("hi")
183 waitRunning(t, ctrl)
184
185 if err := s.switchModel(context.Background(), "next-model"); err == nil {
186 t.Fatal("expected switchModel to refuse while a turn is running")
187 }
188 if built {
189 t.Fatal("switchModel built a controller despite a running turn")
190 }
191 ctrl.Cancel()
192 waitNotRunning(t, ctrl)
193 }
194
195 func TestForegroundMutationRejectsStaleSessionPath(t *testing.T) {
196 dir := t.TempDir()
197 currentPath := filepath.Join(dir, "current.jsonl")
198 stalePath := filepath.Join(dir, "stale.jsonl")
199 bc := NewBroadcaster()
200 ctrl := control.New(control.Options{Runner: blockingRunner{}, Sink: bc, SessionPath: currentPath})
201 s := newLifecycleTestServer(t, ctrl, bc, config.ServeConfig{})
202
203 ctrl.SubmitHTTP("hi")
204 waitRunning(t, ctrl)
205 req := httptest.NewRequest(http.MethodPost, "/cancel", strings.NewReader(`{}`))
206 req.Host = "127.0.0.1"
207 req.Header.Set("Content-Type", "application/json")
208 req.Header.Set(expectedSessionPathHeader, stalePath)
209 rec := httptest.NewRecorder()
210 s.Handler().ServeHTTP(rec, req)
211 if rec.Code != http.StatusConflict {
212 t.Fatalf("stale cancel status = %d, want %d", rec.Code, http.StatusConflict)
213 }
214 if !ctrl.Running() {
215 t.Fatal("stale cancel reached the newly current controller")
216 }
217
218 req = httptest.NewRequest(http.MethodPost, "/cancel", strings.NewReader(`{}`))
219 req.Host = "127.0.0.1"
220 req.Header.Set("Content-Type", "application/json")
221 req.Header.Set(expectedSessionPathHeader, currentPath)
222 rec = httptest.NewRecorder()
223 s.Handler().ServeHTTP(rec, req)
224 if rec.Code != http.StatusNoContent {
225 t.Fatalf("current cancel status = %d, want %d", rec.Code, http.StatusNoContent)
226 }
227 waitNotRunning(t, ctrl)
228 }
229
230 func TestForegroundMutationReadsBodyBeforeBindingLock(t *testing.T) {
231 s := New(control.New(control.Options{}), NewBroadcaster(), config.ServeConfig{})
232 blockedBody := &blockingRequestBody{started: make(chan struct{}), release: make(chan struct{})}
233 firstDone := make(chan struct{})
234 go func() {
235 defer close(firstDone)
236 req := httptest.NewRequest(http.MethodPost, "/slow", blockedBody)
237 s.foregroundMutation(func(w http.ResponseWriter, r *http.Request) {
238 _, _ = io.ReadAll(r.Body)
239 w.WriteHeader(http.StatusNoContent)
240 })(httptest.NewRecorder(), req)
241 }()
242 select {
243 case <-blockedBody.started:
244 case <-time.After(time.Second):
245 t.Fatal("slow request body was never read")
246 }
247
248 secondDone := make(chan struct{})
249 go func() {
250 defer close(secondDone)
251 req := httptest.NewRequest(http.MethodPost, "/fast", http.NoBody)
252 s.foregroundMutation(func(w http.ResponseWriter, _ *http.Request) {
253 w.WriteHeader(http.StatusNoContent)
254 })(httptest.NewRecorder(), req)
255 }()
256 select {
257 case <-secondDone:
258 case <-time.After(500 * time.Millisecond):
259 close(blockedBody.release)
260 <-firstDone
261 t.Fatal("slow body held the session binding lock")
262 }
263 close(blockedBody.release)
264 select {
265 case <-firstDone:
266 case <-time.After(time.Second):
267 t.Fatal("slow request did not finish after its body was released")
268 }
269 }
270
271 func TestForegroundMutationRejectsOversizedBodyBeforeHandler(t *testing.T) {
272 s := New(control.New(control.Options{}), NewBroadcaster(), config.ServeConfig{})
273 called := false
274 req := httptest.NewRequest(http.MethodPost, "/oversized", strings.NewReader(strings.Repeat("x", foregroundMutationMaxBody+1)))
275 rec := httptest.NewRecorder()
276 s.foregroundMutation(func(http.ResponseWriter, *http.Request) { called = true })(rec, req)
277 if rec.Code != http.StatusRequestEntityTooLarge {
278 t.Fatalf("oversized body status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
279 }
280 if called {
281 t.Fatal("oversized body reached the foreground handler")
282 }
283 }
284
285 func TestSwitchModelRejectsWhileBackgroundJobRunning(t *testing.T) {
286 bc := NewBroadcaster()
287 manager := jobs.NewManager(bc)
288 ctrl := control.New(control.Options{Sink: bc, Jobs: manager})
289 defer ctrl.Close()
290 manager.Start("task", "running", func(ctx context.Context, _ io.Writer) (string, error) {
291 <-ctx.Done()
292 return "", ctx.Err()
293 })
294
295 s := &Server{ctrl: ctrl, bc: bc}
296 built := false
297 s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
298 built = true
299 return control.New(control.Options{Sink: bc}), nil
300 }
301
302 if err := s.switchModel(context.Background(), "next-model"); err == nil {
303 t.Fatal("expected switchModel to refuse while a background job is running")
304 }
305 if built {
306 t.Fatal("switchModel built a controller despite a running background job")
307 }
308 }
309
310 func TestExtensionReloadRejectsWhileTurnRunning(t *testing.T) {
311 bc := NewBroadcaster()
312 ctrl := control.New(control.Options{Runner: blockingRunner{}, Sink: bc})
313 s := New(ctrl, bc, config.ServeConfig{})
314 built := false
315 s.rebuildController = func(_ context.Context, _ *control.Controller, _ string) (*control.Controller, error) {
316 built = true
317 return control.New(control.Options{Sink: bc}), nil
318 }
319
320 ctrl.SubmitHTTP("hi")
321 waitRunning(t, ctrl)
322 if err := s.reloadExtensions(context.Background()); err == nil {
323 t.Fatal("expected extension reload to refuse while a turn is running")
324 }
325 if built {
326 t.Fatal("extension reload built a controller despite a running turn")
327 }
328 ctrl.Cancel()
329 waitNotRunning(t, ctrl)
330 }
331
332 func TestConcurrentExtensionReloadsAreSerialized(t *testing.T) {
333 bc := NewBroadcaster()
334 s := New(control.New(control.Options{Sink: bc}), bc, config.ServeConfig{})
335 firstEntered := make(chan struct{})
336 secondEntered := make(chan struct{})
337 releaseFirst := make(chan struct{})
338 var calls atomic.Int32
339 s.rebuildController = func(_ context.Context, _ *control.Controller, _ string) (*control.Controller, error) {
340 if calls.Add(1) == 1 {
341 close(firstEntered)
342 <-releaseFirst
343 } else {
344 close(secondEntered)
345 }
346 return control.New(control.Options{Sink: bc}), nil
347 }
348
349 done := make(chan error, 2)
350 go func() { done <- s.reloadExtensions(context.Background()) }()
351 <-firstEntered
352 go func() { done <- s.reloadExtensions(context.Background()) }()
353 select {
354 case <-secondEntered:
355 t.Fatal("second extension reload entered the rebuild while the first still owned bindMu")
356 case <-time.After(100 * time.Millisecond):
357 }
358 close(releaseFirst)
359 for range 2 {
360 if err := <-done; err != nil {
361 t.Fatalf("reload: %v", err)
362 }
363 }
364 if calls.Load() != 2 {
365 t.Fatalf("rebuild calls = %d, want 2", calls.Load())
366 }
367 }
368
369 func TestSubmitWaitsForExtensionReloadAndTargetsReplacement(t *testing.T) {
370 bc := NewBroadcaster()
371 old := control.New(control.Options{Sink: bc, Runner: blockingRunner{}})
372 s := New(old, bc, config.ServeConfig{})
373 buildEntered := make(chan struct{})
374 releaseBuild := make(chan struct{})
375 replacement := control.New(control.Options{Sink: bc, Runner: blockingRunner{}})
376 s.rebuildController = func(_ context.Context, _ *control.Controller, _ string) (*control.Controller, error) {
377 close(buildEntered)
378 <-releaseBuild
379 return replacement, nil
380 }
381
382 reloadDone := make(chan error, 1)
383 go func() { reloadDone <- s.reloadExtensions(context.Background()) }()
384 <-buildEntered
385
386 submitDone := make(chan int, 1)
387 go func() {
388 req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(`{"input":"hello"}`))
389 rec := httptest.NewRecorder()
390 s.submit(rec, req)
391 submitDone <- rec.Code
392 }()
393 select {
394 case <-submitDone:
395 t.Fatal("submit crossed the extension reload generation boundary")
396 case <-time.After(100 * time.Millisecond):
397 }
398
399 close(releaseBuild)
400 if err := <-reloadDone; err != nil {
401 t.Fatalf("reload: %v", err)
402 }
403 if code := <-submitDone; code != 202 {
404 t.Fatalf("submit status = %d, want 202", code)
405 }
406 waitRunning(t, replacement)
407 if old.Running() {
408 t.Fatal("submit started on the outgoing controller")
409 }
410 replacement.Cancel()
411 waitNotRunning(t, replacement)
412 }
413
414 // blockingRunner keeps a turn "running" until its context is cancelled, so tests
415 // can observe Running() == true deterministically.
416 type blockingRunner struct{}
417
418 func (blockingRunner) Run(ctx context.Context, _ string) error {
419 <-ctx.Done()
420 return ctx.Err()
421 }
422
423 func waitRunning(t *testing.T, ctrl *control.Controller) {
424 t.Helper()
425 deadline := time.After(2 * time.Second)
426 for {
427 if ctrl.Running() {
428 return
429 }
430 select {
431 case <-deadline:
432 t.Fatal("controller never entered the running state")
433 case <-time.After(5 * time.Millisecond):
434 }
435 }
436 }
437
438 func waitNotRunning(t *testing.T, ctrl *control.Controller) {
439 t.Helper()
440 done, running := ctrl.TurnIdleDone()
441 if !running {
442 return
443 }
444 select {
445 case <-done:
446 case <-time.After(30 * time.Second):
447 t.Fatalf("controller never reached idle after cancel: %+v", ctrl.RuntimeStatus())
448 }
449 }
450
450 lines GO