返回 DeepSeek-Reasonix
race_regression_test.go
根目录 / desktop / race_regression_test.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sync"
10 "testing"
11 "time"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/boot"
15 "reasonix/internal/config"
16 "reasonix/internal/event"
17 )
18
19 // TestSessionLeaseHelpersConcurrentAccess hammers the sessionLeaseMu helpers
20 // (ensure/take/adopt/release/key) from concurrent goroutines. Run with -race:
21 // any residual raw access to tab.sessionLease shows up as a data race, and the
22 // final acquire asserts no lease leaked through an interleaving.
23 func TestSessionLeaseHelpersConcurrentAccess(t *testing.T) {
24 isolateDesktopUserDirs(t)
25 dir := config.SessionDir()
26 if err := os.MkdirAll(dir, 0o755); err != nil {
27 t.Fatalf("mkdir sessions: %v", err)
28 }
29 path := filepath.Join(dir, "lease-helper-hammer.jsonl")
30 key := sessionRuntimeKey(path)
31 tabA := &WorkspaceTab{ID: "a"}
32 tabB := &WorkspaceTab{ID: "b"}
33
34 const iterations = 300
35 var wg sync.WaitGroup
36 wg.Add(3)
37 go func() {
38 defer wg.Done()
39 for range iterations {
40 _ = tabA.ensureSessionLease(path)
41 _ = tabA.sessionLeaseRuntimeKey()
42 }
43 }()
44 go func() {
45 defer wg.Done()
46 for range iterations {
47 // The applyRuntimeTab transfer shape: move A's lease to B and back.
48 tabB.adoptSessionLease(tabA.takeSessionLease())
49 tabA.adoptSessionLease(tabB.takeSessionLease())
50 }
51 }()
52 go func() {
53 defer wg.Done()
54 for range iterations {
55 tabA.releaseSessionLease()
56 }
57 }()
58 wg.Wait()
59
60 tabA.releaseSessionLease()
61 tabB.releaseSessionLease()
62 lease, err := agent.TryAcquireSessionLease(key)
63 if err != nil {
64 t.Fatalf("lease leaked through concurrent helper interleavings: %v", err)
65 }
66 lease.Release()
67 }
68
69 // TestDetachRuntimeForReplacementTransfersLease asserts the detach clone takes
70 // lease ownership through the locked helpers and the visible tab keeps none.
71 func TestDetachRuntimeForReplacementTransfersLease(t *testing.T) {
72 isolateDesktopUserDirs(t)
73 dir := config.SessionDir()
74 if err := os.MkdirAll(dir, 0o755); err != nil {
75 t.Fatalf("mkdir sessions: %v", err)
76 }
77 path := filepath.Join(dir, "detach-transfer.jsonl")
78 key := sessionRuntimeKey(path)
79 tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: path}
80 app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
81 if err := tab.ensureSessionLease(path); err != nil {
82 t.Fatalf("ensureSessionLease: %v", err)
83 }
84 t.Cleanup(func() {
85 tab.releaseSessionLease()
86 for _, d := range app.detachedSessions {
87 d.releaseSessionLease()
88 }
89 })
90
91 if !app.detachRuntimeForReplacement(tab) {
92 t.Fatal("detachRuntimeForReplacement failed for a live tab")
93 }
94 detached := app.detachedSessions[key]
95 if detached == nil {
96 t.Fatal("detached runtime was not registered")
97 }
98 if got := detached.sessionLeaseRuntimeKey(); got != key {
99 t.Fatalf("detached clone lease key = %q, want %q", got, key)
100 }
101 if got := tab.sessionLeaseRuntimeKey(); got != "" {
102 t.Fatalf("visible tab still holds lease key %q after detach", got)
103 }
104 }
105
106 // TestDetachRuntimeForReplacementSkipsRemovedTab: a tab that DeleteSession /
107 // CloseTab already unlinked must not be re-published into detachedSessions
108 // (the "session resurrects" class, #4384).
109 func TestDetachRuntimeForReplacementSkipsRemovedTab(t *testing.T) {
110 isolateDesktopUserDirs(t)
111 dir := config.SessionDir()
112 if err := os.MkdirAll(dir, 0o755); err != nil {
113 t.Fatalf("mkdir sessions: %v", err)
114 }
115 path := filepath.Join(dir, "detach-removed.jsonl")
116
117 removed := &WorkspaceTab{ID: "removed", SessionPath: path, removed: true}
118 app := &App{tabs: map[string]*WorkspaceTab{"removed": removed}}
119 if app.detachRuntimeForReplacement(removed) {
120 t.Fatal("removed tab was detached into the background registry")
121 }
122 if len(app.detachedSessions) != 0 {
123 t.Fatal("removed tab left an entry in detachedSessions")
124 }
125
126 orphan := &WorkspaceTab{ID: "orphan", SessionPath: path}
127 if app.detachRuntimeForReplacement(orphan) {
128 t.Fatal("tab absent from a.tabs was detached into the background registry")
129 }
130 if len(app.detachedSessions) != 0 {
131 t.Fatal("orphan tab left an entry in detachedSessions")
132 }
133 }
134
135 // TestTabEventSinkEmitConcurrentRebind: Emit keeps running on the controller
136 // goroutine while detach/reattach rebinds the sink's tab routing. Run with
137 // -race; before setBinding existed the tabID write raced every Emit.
138 func TestTabEventSinkEmitConcurrentRebind(t *testing.T) {
139 sink := &tabEventSink{tabID: "before"}
140 const iterations = 500
141 var wg sync.WaitGroup
142 wg.Add(2)
143 go func() {
144 defer wg.Done()
145 for range iterations {
146 sink.Emit(event.Event{Kind: event.Notice, Text: "hammer"})
147 }
148 }()
149 go func() {
150 defer wg.Done()
151 for i := range iterations {
152 sink.setBinding(fmt.Sprintf("tab-%d", i%2), nil)
153 sink.clearContext()
154 }
155 }()
156 wg.Wait()
157 if tabID, _ := sink.binding(); tabID == "" {
158 t.Fatal("sink lost its tab binding")
159 }
160 }
161
162 func TestModeRebuildAndABANavigationFenceLeaseFailureAndOldEpochEvent(t *testing.T) {
163 app, tab, oldCtrl, pathA, pathB, loadedB := newAtomicRebindTestApp(t)
164 oldSink := tab.sink
165 app.mu.RLock()
166 epochA := app.sessionRuntimeViewLocked(tab).Epoch
167 app.mu.RUnlock()
168
169 restored := make(chan struct{})
170 candidateBuilt := make(chan struct{})
171 continueCommit := make(chan struct{})
172 var continueCommitOnce sync.Once
173 releaseCommit := func() {
174 continueCommitOnce.Do(func() { close(continueCommit) })
175 }
176 defer releaseCommit()
177 oldEvent := make(chan wireEventTab, 1)
178 var capturedOldEvent wireEventTab
179 oldSink.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
180 if name != eventChannel || len(payload) != 1 {
181 return
182 }
183 if wire, ok := payload[0].(wireEventTab); ok && wire.Text == "retired runtime event" {
184 oldEvent <- wire
185 }
186 }
187 var blockOnce sync.Once
188 var builtOnce sync.Once
189 app.rebindCandidateHook = func(stage string) error {
190 switch stage {
191 case "built":
192 builtOnce.Do(func() { close(candidateBuilt) })
193 case "restored":
194 blockOnce.Do(func() {
195 close(restored)
196 <-continueCommit
197 })
198 case "committed":
199 oldSink.Emit(event.Event{Kind: event.Notice, Text: "retired runtime event"})
200 select {
201 case capturedOldEvent = <-oldEvent:
202 case <-time.After(5 * time.Second):
203 return errors.New("retired runtime event was not emitted")
204 }
205 }
206 return nil
207 }
208
209 rebindDone := make(chan error, 1)
210 go func() {
211 rebindDone <- app.rebindTabToLoadedSessionPath(tab, pathB, loadedB)
212 }()
213 select {
214 case <-candidateBuilt:
215 case err := <-rebindDone:
216 t.Fatalf("A to B rebind ended before building the candidate: %v", err)
217 case <-time.After(10 * time.Second):
218 t.Fatal("A to B rebind did not build a candidate")
219 }
220 select {
221 case <-restored:
222 case err := <-rebindDone:
223 t.Fatalf("A to B rebind ended before restoring the candidate: %v", err)
224 case <-time.After(30 * time.Second):
225 t.Fatal("A to B rebind did not restore the built candidate")
226 }
227
228 // Concurrent deprecated mode call must not interleave with the rebind
229 // transaction or publish a half-updated profile.
230 modeDone := make(chan error, 1)
231 go func() {
232 modeDone <- app.SetTokenModeForTab(tab.ID, boot.TokenModeFull)
233 }()
234 releaseCommit()
235 if err := <-rebindDone; err != nil {
236 t.Fatalf("A to B rebind: %v", err)
237 }
238 if err := <-modeDone; err != nil {
239 t.Fatalf("SetTokenModeForTab after A to B: %v", err)
240 }
241
242 stale := capturedOldEvent
243 app.mu.RLock()
244 epochB := app.sessionRuntimeViewLocked(tab).Epoch
245 app.mu.RUnlock()
246 if stale.RuntimeEpoch != epochA || epochB == "" || epochB == epochA {
247 t.Fatalf("epoch fence stale=%q source=%q target=%q", stale.RuntimeEpoch, epochA, epochB)
248 }
249 if app.controllerForTab(tab) == oldCtrl ||
250 sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(pathB) ||
251 currentTabTokenMode(tab) != boot.TokenModeFull {
252 t.Fatalf("A to B plus SetTokenModeForTab did not converge: ctrl=%p path=%q token=%q",
253 app.controllerForTab(tab), tab.currentSessionPath(), currentTabTokenMode(tab))
254 }
255
256 // A is now free. Hold it as an external target, then attempt B to A. The
257 // failed return leg must keep the rebuilt B controller, lease, path, epoch,
258 // and full token profile unchanged.
259 app.rebindCandidateHook = nil
260 holderA, err := agent.TryAcquireSessionLease(pathA)
261 if err != nil {
262 t.Fatalf("hold A before return navigation: %v", err)
263 }
264 defer holderA.Release()
265 loadedA, err := agent.LoadSession(pathA)
266 if err != nil {
267 t.Fatalf("load A: %v", err)
268 }
269 ctrlB := app.controllerForTab(tab)
270 err = app.rebindTabToLoadedSessionPath(tab, pathA, loadedA)
271 if !errors.Is(err, agent.ErrSessionLeaseHeld) {
272 t.Fatalf("B to A error = %v, want ErrSessionLeaseHeld", err)
273 }
274 if app.controllerForTab(tab) != ctrlB ||
275 sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(pathB) ||
276 tab.sessionLeaseRuntimeKey() != sessionRuntimeKey(pathB) ||
277 currentTabTokenMode(tab) != boot.TokenModeFull {
278 t.Fatalf("failed B to A changed B runtime: ctrl=%p path=%q lease=%q token=%q",
279 app.controllerForTab(tab), tab.currentSessionPath(), tab.sessionLeaseRuntimeKey(), currentTabTokenMode(tab))
280 }
281 app.mu.RLock()
282 finalView := app.sessionRuntimeViewLocked(tab)
283 targetAlias := app.runtimeBySessionKey[sessionRuntimeKey(pathA)]
284 app.mu.RUnlock()
285 if finalView.Phase != sessionRuntimeReady || finalView.Epoch != epochB || targetAlias != nil {
286 t.Fatalf("failed B to A runtime = phase %q epoch %q aliasA=%#v, want ready/%q/no alias",
287 finalView.Phase, finalView.Epoch, targetAlias, epochB)
288 }
289 }
290
291 // TestTabBuildSupersededByRebindGeneration: a session rebind bumps
292 // buildGeneration to strand any in-flight async build, so its swap (and every
293 // mid-build field write) is rejected; synchronous rebuilds pass generation 0
294 // and rely on runtimeRebuildMu instead.
295 func TestTabBuildSupersededByRebindGeneration(t *testing.T) {
296 tab := &WorkspaceTab{ID: "tab"}
297 app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
298 app.mu.Lock()
299 tab.buildGeneration = 3
300 app.mu.Unlock()
301 if app.tabBuildSuperseded(tab, 3) {
302 t.Fatal("build with the current generation must not be superseded")
303 }
304 app.mu.Lock()
305 tab.buildGeneration++ // the rebind-side invalidation
306 app.mu.Unlock()
307 if !app.tabBuildSuperseded(tab, 3) {
308 t.Fatal("stale-generation build must be superseded after a rebind bump")
309 }
310 if app.tabBuildSuperseded(tab, 0) {
311 t.Fatal("synchronous rebuild (generation 0) must not be superseded by generation bumps")
312 }
313 app.mu.Lock()
314 tab.removed = true
315 app.mu.Unlock()
316 if !app.tabBuildSuperseded(tab, 0) {
317 t.Fatal("removed tab must supersede every build, including synchronous ones")
318 }
319 }
320
321 // TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey: superseded builds may only
322 // release the lease bound to their own path; a mismatched key (the rebind's
323 // replacement session) must be left untouched.
324 func TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey(t *testing.T) {
325 isolateDesktopUserDirs(t)
326 dir := config.SessionDir()
327 if err := os.MkdirAll(dir, 0o755); err != nil {
328 t.Fatalf("mkdir sessions: %v", err)
329 }
330 pathA := filepath.Join(dir, "lease-key-a.jsonl")
331 pathB := filepath.Join(dir, "lease-key-b.jsonl")
332 tab := &WorkspaceTab{ID: "tab"}
333 t.Cleanup(tab.releaseSessionLease)
334 if err := tab.ensureSessionLease(pathB); err != nil {
335 t.Fatalf("ensureSessionLease: %v", err)
336 }
337
338 tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathA))
339 if _, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB)); err == nil {
340 t.Fatal("mismatched key released the tab's live lease")
341 }
342
343 tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathB))
344 lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB))
345 if err != nil {
346 t.Fatalf("matching key did not release the lease: %v", err)
347 }
348 lease.Release()
349 }
350
351 // TestAbandonSupersededBuildPreservesNewBuildOwnership reproduces the rebind
352 // interleaving from the #5968 review: a stale async build cleans up after a
353 // rebind's replacement build already published its own SharedHostKey and
354 // session lease on the same live tab. The stale build must drop only its own
355 // host reference and lease, never the tab's.
356 func TestAbandonSupersededBuildPreservesNewBuildOwnership(t *testing.T) {
357 isolateDesktopUserDirs(t)
358 dir := config.SessionDir()
359 if err := os.MkdirAll(dir, 0o755); err != nil {
360 t.Fatalf("mkdir sessions: %v", err)
361 }
362 oldPath := filepath.Join(dir, "superseded-old.jsonl")
363 newPath := filepath.Join(dir, "superseded-new.jsonl")
364 newKey := sessionRuntimeKey(newPath)
365
366 app := &App{tabs: map[string]*WorkspaceTab{}}
367 // The stale build and the replacement build each hold one reference on
368 // the same workspace-root host (the common same-root rebind).
369 app.acquireSharedHost("root")
370 app.acquireSharedHost("root")
371
372 tab := &WorkspaceTab{ID: "tab", SharedHostKey: "root"}
373 app.tabs["tab"] = tab
374 t.Cleanup(tab.releaseSessionLease)
375 if err := tab.ensureSessionLease(newPath); err != nil {
376 t.Fatalf("ensureSessionLease(new): %v", err)
377 }
378
379 // Stale build abandons with ITS key material: the old lease path and the
380 // root key it acquired.
381 app.abandonSupersededBuild(tab, nil, "root", sessionRuntimeKey(oldPath))
382
383 app.mu.RLock()
384 hostKey := tab.SharedHostKey
385 app.mu.RUnlock()
386 if hostKey != "root" {
387 t.Fatalf("stale build cleared the tab's SharedHostKey: %q", hostKey)
388 }
389 app.sharedHostsMu.Lock()
390 entry := app.sharedHosts["root"]
391 refs := 0
392 if entry != nil {
393 refs = entry.refs
394 }
395 app.sharedHostsMu.Unlock()
396 if refs != 1 {
397 t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the live build's reference)", refs)
398 }
399 if got := tab.sessionLeaseRuntimeKey(); got != newKey {
400 t.Fatalf("stale build released the replacement build's lease: key = %q, want %q", got, newKey)
401 }
402
403 // Removal shape: the abandoned build's own key still on the tab is the
404 // last reference and must be released.
405 app.abandonSupersededBuild(tab, nil, "", newKey)
406 lease, err := agent.TryAcquireSessionLease(newKey)
407 if err != nil {
408 t.Fatalf("matching-key abandon did not release the lease: %v", err)
409 }
410 lease.Release()
411 }
412
413 // TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot reproduces the #5968
414 // review scenario: an async startTabControllerBuild is stalled right after
415 // binding its session lease (holding its controller, lease, and shared-host
416 // reference), a rebind to a different session starts meanwhile, and the
417 // stalled build then tries to finish. The rebind must bump the generation
418 // BEFORE snapshotting tab.Ctrl, so the stale build can only fall into its
419 // superseded branches: the rebound controller must survive un-overwritten,
420 // the stale build's lease and host reference must be released, and the
421 // replacement build's ownership must stay intact.
422 // TestMetaForTabConcurrentWithBuildSwap polls MetaForTab (the frontend's boot
423 // probe) while a fake build goroutine flips Ready/Label/StartupErr/model under
424 // a.mu — the write pattern of buildTabControllerWithContext. Run with -race.
425 func TestMetaForTabConcurrentWithBuildSwap(t *testing.T) {
426 isolateDesktopUserDirs(t)
427 tab := &WorkspaceTab{ID: "tab", Scope: "project", WorkspaceRoot: t.TempDir()}
428 app := &App{
429 tabs: map[string]*WorkspaceTab{"tab": tab},
430 tabOrder: []string{"tab"},
431 activeTabID: "tab",
432 }
433
434 const iterations = 100
435 done := make(chan struct{})
436 go func() {
437 defer close(done)
438 for i := range iterations {
439 app.mu.Lock()
440 tab.Ready = !tab.Ready
441 tab.Label = fmt.Sprintf("model-%d", i)
442 tab.StartupErr = ""
443 tab.model = fmt.Sprintf("provider/m%d", i)
444 tab.goal = fmt.Sprintf("goal-%d", i)
445 app.mu.Unlock()
446 }
447 }()
448 for range iterations {
449 meta := app.MetaForTab("tab")
450 if meta.EventChannel == "" {
451 t.Fatal("MetaForTab returned zero meta for a live tab")
452 }
453 }
454 <-done
455 }
456
456 lines GO