| 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 i := 0; i < iterations; i++ { |
| 40 | _ = tabA.ensureSessionLease(path) |
| 41 | _ = tabA.sessionLeaseRuntimeKey() |
| 42 | } |
| 43 | }() |
| 44 | go func() { |
| 45 | defer wg.Done() |
| 46 | for i := 0; i < iterations; i++ { |
| 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 i := 0; i < iterations; i++ { |
| 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 i := 0; i < iterations; i++ { |
| 146 | sink.Emit(event.Event{Kind: event.Notice, Text: "hammer"}) |
| 147 | } |
| 148 | }() |
| 149 | go func() { |
| 150 | defer wg.Done() |
| 151 | for i := 0; i < iterations; i++ { |
| 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 | continueCommit := make(chan struct{}) |
| 171 | oldEvent := make(chan wireEventTab, 1) |
| 172 | var capturedOldEvent wireEventTab |
| 173 | oldSink.runtimeEvents.emit = func(_ context.Context, name string, payload ...interface{}) { |
| 174 | if name != eventChannel || len(payload) != 1 { |
| 175 | return |
| 176 | } |
| 177 | if wire, ok := payload[0].(wireEventTab); ok && wire.Text == "retired runtime event" { |
| 178 | oldEvent <- wire |
| 179 | } |
| 180 | } |
| 181 | var blockOnce sync.Once |
| 182 | app.rebindCandidateHook = func(stage string) error { |
| 183 | switch stage { |
| 184 | case "restored": |
| 185 | blockOnce.Do(func() { |
| 186 | close(restored) |
| 187 | <-continueCommit |
| 188 | }) |
| 189 | case "committed": |
| 190 | oldSink.Emit(event.Event{Kind: event.Notice, Text: "retired runtime event"}) |
| 191 | select { |
| 192 | case capturedOldEvent = <-oldEvent: |
| 193 | case <-time.After(5 * time.Second): |
| 194 | return errors.New("retired runtime event was not emitted") |
| 195 | } |
| 196 | } |
| 197 | return nil |
| 198 | } |
| 199 | |
| 200 | rebindDone := make(chan error, 1) |
| 201 | go func() { |
| 202 | rebindDone <- app.rebindTabToLoadedSessionPath(tab, pathB, loadedB) |
| 203 | }() |
| 204 | select { |
| 205 | case <-restored: |
| 206 | case <-time.After(10 * time.Second): |
| 207 | t.Fatal("A to B rebind did not reach restored candidate") |
| 208 | } |
| 209 | |
| 210 | // This call observes A/economy, then waits behind the rebind transaction. |
| 211 | // Once B commits it must rebuild B to full, never interleave with the |
| 212 | // candidate or publish a half-updated profile. |
| 213 | modeDone := make(chan error, 1) |
| 214 | go func() { |
| 215 | modeDone <- app.SetTokenModeForTab(tab.ID, boot.TokenModeFull) |
| 216 | }() |
| 217 | close(continueCommit) |
| 218 | if err := <-rebindDone; err != nil { |
| 219 | t.Fatalf("A to B rebind: %v", err) |
| 220 | } |
| 221 | if err := <-modeDone; err != nil { |
| 222 | t.Fatalf("mode rebuild after A to B: %v", err) |
| 223 | } |
| 224 | |
| 225 | stale := capturedOldEvent |
| 226 | app.mu.RLock() |
| 227 | epochB := app.sessionRuntimeViewLocked(tab).Epoch |
| 228 | app.mu.RUnlock() |
| 229 | if stale.RuntimeEpoch != epochA || epochB == "" || epochB == epochA { |
| 230 | t.Fatalf("epoch fence stale=%q source=%q target=%q", stale.RuntimeEpoch, epochA, epochB) |
| 231 | } |
| 232 | if app.controllerForTab(tab) == oldCtrl || |
| 233 | sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(pathB) || |
| 234 | currentTabTokenMode(tab) != boot.TokenModeFull { |
| 235 | t.Fatalf("A to B plus mode rebuild did not converge: ctrl=%p path=%q token=%q", |
| 236 | app.controllerForTab(tab), tab.currentSessionPath(), currentTabTokenMode(tab)) |
| 237 | } |
| 238 | |
| 239 | // A is now free. Hold it as an external target, then attempt B to A. The |
| 240 | // failed return leg must keep the rebuilt B controller, lease, path, epoch, |
| 241 | // and full token profile unchanged. |
| 242 | app.rebindCandidateHook = nil |
| 243 | holderA, err := agent.TryAcquireSessionLease(pathA) |
| 244 | if err != nil { |
| 245 | t.Fatalf("hold A before return navigation: %v", err) |
| 246 | } |
| 247 | defer holderA.Release() |
| 248 | loadedA, err := agent.LoadSession(pathA) |
| 249 | if err != nil { |
| 250 | t.Fatalf("load A: %v", err) |
| 251 | } |
| 252 | ctrlB := app.controllerForTab(tab) |
| 253 | err = app.rebindTabToLoadedSessionPath(tab, pathA, loadedA) |
| 254 | if !errors.Is(err, agent.ErrSessionLeaseHeld) { |
| 255 | t.Fatalf("B to A error = %v, want ErrSessionLeaseHeld", err) |
| 256 | } |
| 257 | if app.controllerForTab(tab) != ctrlB || |
| 258 | sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(pathB) || |
| 259 | tab.sessionLeaseRuntimeKey() != sessionRuntimeKey(pathB) || |
| 260 | currentTabTokenMode(tab) != boot.TokenModeFull { |
| 261 | t.Fatalf("failed B to A changed B runtime: ctrl=%p path=%q lease=%q token=%q", |
| 262 | app.controllerForTab(tab), tab.currentSessionPath(), tab.sessionLeaseRuntimeKey(), currentTabTokenMode(tab)) |
| 263 | } |
| 264 | app.mu.RLock() |
| 265 | finalView := app.sessionRuntimeViewLocked(tab) |
| 266 | targetAlias := app.runtimeBySessionKey[sessionRuntimeKey(pathA)] |
| 267 | app.mu.RUnlock() |
| 268 | if finalView.Phase != sessionRuntimeReady || finalView.Epoch != epochB || targetAlias != nil { |
| 269 | t.Fatalf("failed B to A runtime = phase %q epoch %q aliasA=%#v, want ready/%q/no alias", |
| 270 | finalView.Phase, finalView.Epoch, targetAlias, epochB) |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | // TestTabBuildSupersededByRebindGeneration: a session rebind bumps |
| 275 | // buildGeneration to strand any in-flight async build, so its swap (and every |
| 276 | // mid-build field write) is rejected; synchronous rebuilds pass generation 0 |
| 277 | // and rely on runtimeRebuildMu instead. |
| 278 | func TestTabBuildSupersededByRebindGeneration(t *testing.T) { |
| 279 | tab := &WorkspaceTab{ID: "tab"} |
| 280 | app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}} |
| 281 | app.mu.Lock() |
| 282 | tab.buildGeneration = 3 |
| 283 | app.mu.Unlock() |
| 284 | if app.tabBuildSuperseded(tab, 3) { |
| 285 | t.Fatal("build with the current generation must not be superseded") |
| 286 | } |
| 287 | app.mu.Lock() |
| 288 | tab.buildGeneration++ // the rebind-side invalidation |
| 289 | app.mu.Unlock() |
| 290 | if !app.tabBuildSuperseded(tab, 3) { |
| 291 | t.Fatal("stale-generation build must be superseded after a rebind bump") |
| 292 | } |
| 293 | if app.tabBuildSuperseded(tab, 0) { |
| 294 | t.Fatal("synchronous rebuild (generation 0) must not be superseded by generation bumps") |
| 295 | } |
| 296 | app.mu.Lock() |
| 297 | tab.removed = true |
| 298 | app.mu.Unlock() |
| 299 | if !app.tabBuildSuperseded(tab, 0) { |
| 300 | t.Fatal("removed tab must supersede every build, including synchronous ones") |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | // TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey: superseded builds may only |
| 305 | // release the lease bound to their own path; a mismatched key (the rebind's |
| 306 | // replacement session) must be left untouched. |
| 307 | func TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey(t *testing.T) { |
| 308 | isolateDesktopUserDirs(t) |
| 309 | dir := config.SessionDir() |
| 310 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 311 | t.Fatalf("mkdir sessions: %v", err) |
| 312 | } |
| 313 | pathA := filepath.Join(dir, "lease-key-a.jsonl") |
| 314 | pathB := filepath.Join(dir, "lease-key-b.jsonl") |
| 315 | tab := &WorkspaceTab{ID: "tab"} |
| 316 | t.Cleanup(tab.releaseSessionLease) |
| 317 | if err := tab.ensureSessionLease(pathB); err != nil { |
| 318 | t.Fatalf("ensureSessionLease: %v", err) |
| 319 | } |
| 320 | |
| 321 | tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathA)) |
| 322 | if _, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB)); err == nil { |
| 323 | t.Fatal("mismatched key released the tab's live lease") |
| 324 | } |
| 325 | |
| 326 | tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathB)) |
| 327 | lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB)) |
| 328 | if err != nil { |
| 329 | t.Fatalf("matching key did not release the lease: %v", err) |
| 330 | } |
| 331 | lease.Release() |
| 332 | } |
| 333 | |
| 334 | // TestAbandonSupersededBuildPreservesNewBuildOwnership reproduces the rebind |
| 335 | // interleaving from the #5968 review: a stale async build cleans up after a |
| 336 | // rebind's replacement build already published its own SharedHostKey and |
| 337 | // session lease on the same live tab. The stale build must drop only its own |
| 338 | // host reference and lease, never the tab's. |
| 339 | func TestAbandonSupersededBuildPreservesNewBuildOwnership(t *testing.T) { |
| 340 | isolateDesktopUserDirs(t) |
| 341 | dir := config.SessionDir() |
| 342 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 343 | t.Fatalf("mkdir sessions: %v", err) |
| 344 | } |
| 345 | oldPath := filepath.Join(dir, "superseded-old.jsonl") |
| 346 | newPath := filepath.Join(dir, "superseded-new.jsonl") |
| 347 | newKey := sessionRuntimeKey(newPath) |
| 348 | |
| 349 | app := &App{tabs: map[string]*WorkspaceTab{}} |
| 350 | // The stale build and the replacement build each hold one reference on |
| 351 | // the same workspace-root host (the common same-root rebind). |
| 352 | app.acquireSharedHost("root") |
| 353 | app.acquireSharedHost("root") |
| 354 | |
| 355 | tab := &WorkspaceTab{ID: "tab", SharedHostKey: "root"} |
| 356 | app.tabs["tab"] = tab |
| 357 | t.Cleanup(tab.releaseSessionLease) |
| 358 | if err := tab.ensureSessionLease(newPath); err != nil { |
| 359 | t.Fatalf("ensureSessionLease(new): %v", err) |
| 360 | } |
| 361 | |
| 362 | // Stale build abandons with ITS key material: the old lease path and the |
| 363 | // root key it acquired. |
| 364 | app.abandonSupersededBuild(tab, nil, "root", sessionRuntimeKey(oldPath)) |
| 365 | |
| 366 | app.mu.RLock() |
| 367 | hostKey := tab.SharedHostKey |
| 368 | app.mu.RUnlock() |
| 369 | if hostKey != "root" { |
| 370 | t.Fatalf("stale build cleared the tab's SharedHostKey: %q", hostKey) |
| 371 | } |
| 372 | app.sharedHostsMu.Lock() |
| 373 | entry := app.sharedHosts["root"] |
| 374 | refs := 0 |
| 375 | if entry != nil { |
| 376 | refs = entry.refs |
| 377 | } |
| 378 | app.sharedHostsMu.Unlock() |
| 379 | if refs != 1 { |
| 380 | t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the live build's reference)", refs) |
| 381 | } |
| 382 | if got := tab.sessionLeaseRuntimeKey(); got != newKey { |
| 383 | t.Fatalf("stale build released the replacement build's lease: key = %q, want %q", got, newKey) |
| 384 | } |
| 385 | |
| 386 | // Removal shape: the abandoned build's own key still on the tab is the |
| 387 | // last reference and must be released. |
| 388 | app.abandonSupersededBuild(tab, nil, "", newKey) |
| 389 | lease, err := agent.TryAcquireSessionLease(newKey) |
| 390 | if err != nil { |
| 391 | t.Fatalf("matching-key abandon did not release the lease: %v", err) |
| 392 | } |
| 393 | lease.Release() |
| 394 | } |
| 395 | |
| 396 | // TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot reproduces the #5968 |
| 397 | // review scenario: an async startTabControllerBuild is stalled right after |
| 398 | // binding its session lease (holding its controller, lease, and shared-host |
| 399 | // reference), a rebind to a different session starts meanwhile, and the |
| 400 | // stalled build then tries to finish. The rebind must bump the generation |
| 401 | // BEFORE snapshotting tab.Ctrl, so the stale build can only fall into its |
| 402 | // superseded branches: the rebound controller must survive un-overwritten, |
| 403 | // the stale build's lease and host reference must be released, and the |
| 404 | // replacement build's ownership must stay intact. |
| 405 | func TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot(t *testing.T) { |
| 406 | isolateDesktopUserDirs(t) |
| 407 | root := globalTabWorkspaceRoot() |
| 408 | dir := desktopSessionDir(root) |
| 409 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 410 | t.Fatalf("mkdir session dir: %v", err) |
| 411 | } |
| 412 | oldPath := filepath.Join(dir, "rebind-race-old.jsonl") |
| 413 | newPath := filepath.Join(dir, "rebind-race-new.jsonl") |
| 414 | writeHistoryTestSession(t, oldPath, "old prompt") |
| 415 | writeHistoryTestSession(t, newPath, "new prompt") |
| 416 | oldKey := sessionRuntimeKey(oldPath) |
| 417 | newKey := sessionRuntimeKey(newPath) |
| 418 | |
| 419 | app := NewApp() |
| 420 | app.readyHook = func() {} |
| 421 | tab := &WorkspaceTab{ |
| 422 | ID: "tab", |
| 423 | Scope: "global", |
| 424 | WorkspaceRoot: root, |
| 425 | SessionPath: oldPath, |
| 426 | sink: &tabEventSink{tabID: "tab", app: app}, |
| 427 | disabledMCP: map[string]ServerView{}, |
| 428 | } |
| 429 | app.tabs[tab.ID] = tab |
| 430 | app.tabOrder = []string{tab.ID} |
| 431 | app.activeTabID = tab.ID |
| 432 | t.Cleanup(func() { |
| 433 | app.mu.RLock() |
| 434 | ctrl := tab.Ctrl |
| 435 | app.mu.RUnlock() |
| 436 | if ctrl != nil { |
| 437 | ctrl.Close() |
| 438 | } |
| 439 | tab.releaseSessionLease() |
| 440 | }) |
| 441 | |
| 442 | // Stall the async build inside its lease bind: at that point it already |
| 443 | // holds its controller, its lease, and its shared-host reference. |
| 444 | stalled := make(chan struct{}) |
| 445 | releaseHook := make(chan struct{}) |
| 446 | var once sync.Once |
| 447 | sessionLeaseAcquireHookForTest = func() { |
| 448 | once.Do(func() { |
| 449 | close(stalled) |
| 450 | <-releaseHook |
| 451 | }) |
| 452 | } |
| 453 | t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil }) |
| 454 | t.Cleanup(func() { |
| 455 | select { |
| 456 | case <-releaseHook: |
| 457 | default: |
| 458 | close(releaseHook) |
| 459 | } |
| 460 | }) |
| 461 | |
| 462 | // startTabControllerBuild only backgrounds the build when a Wails context |
| 463 | // exists; expand its goroutine branch by hand so a.ctx can stay nil (the |
| 464 | // nil-ctx emit guards are what every other build test relies on too). |
| 465 | buildCtx, cancel := context.WithCancel(context.Background()) |
| 466 | app.mu.Lock() |
| 467 | tab.buildGeneration++ |
| 468 | generation := tab.buildGeneration |
| 469 | tab.buildCancel = cancel |
| 470 | app.mu.Unlock() |
| 471 | buildDone := make(chan struct{}) |
| 472 | go func() { |
| 473 | defer close(buildDone) |
| 474 | app.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel) |
| 475 | }() |
| 476 | select { |
| 477 | case <-stalled: |
| 478 | case <-time.After(15 * time.Second): |
| 479 | t.Fatal("async build did not reach the lease bind") |
| 480 | } |
| 481 | |
| 482 | loaded, err := agent.LoadSession(newPath) |
| 483 | if err != nil { |
| 484 | t.Fatalf("LoadSession(new): %v", err) |
| 485 | } |
| 486 | rebindErr := make(chan error, 1) |
| 487 | go func() { |
| 488 | rebindErr <- app.rebindTabToLoadedSessionPath(tab, newPath, loaded) |
| 489 | }() |
| 490 | |
| 491 | // Wait until the rebind's validation section has invalidated the async |
| 492 | // build (startTabControllerBuild set generation 1; the rebind bumps to 2) |
| 493 | // while the async build is still stalled pre-swap. |
| 494 | deadline := time.Now().Add(15 * time.Second) |
| 495 | for { |
| 496 | app.mu.RLock() |
| 497 | generation := tab.buildGeneration |
| 498 | app.mu.RUnlock() |
| 499 | if generation >= 2 { |
| 500 | break |
| 501 | } |
| 502 | if time.Now().After(deadline) { |
| 503 | t.Fatal("rebind did not bump the build generation") |
| 504 | } |
| 505 | time.Sleep(time.Millisecond) |
| 506 | } |
| 507 | |
| 508 | close(releaseHook) |
| 509 | select { |
| 510 | case err := <-rebindErr: |
| 511 | if err != nil { |
| 512 | t.Fatalf("rebindTabToLoadedSessionPath: %v", err) |
| 513 | } |
| 514 | case <-time.After(30 * time.Second): |
| 515 | t.Fatal("rebind did not finish after releasing the stalled build") |
| 516 | } |
| 517 | select { |
| 518 | case <-buildDone: |
| 519 | case <-time.After(30 * time.Second): |
| 520 | t.Fatal("stale async build did not finish its superseded cleanup") |
| 521 | } |
| 522 | |
| 523 | app.mu.RLock() |
| 524 | builtCtrl := tab.Ctrl |
| 525 | sessionPath := tab.SessionPath |
| 526 | app.mu.RUnlock() |
| 527 | if builtCtrl == nil { |
| 528 | t.Fatal("rebind left the tab without a controller") |
| 529 | } |
| 530 | if got := builtCtrl.SessionPath(); got != newPath { |
| 531 | t.Fatalf("stale async build overwrote the rebound controller: session path = %q, want %q", got, newPath) |
| 532 | } |
| 533 | if sessionPath != newPath { |
| 534 | t.Fatalf("tab.SessionPath = %q, want %q", sessionPath, newPath) |
| 535 | } |
| 536 | if got := tab.sessionLeaseRuntimeKey(); got != newKey { |
| 537 | t.Fatalf("tab lease key = %q, want the rebound session %q", got, newKey) |
| 538 | } |
| 539 | // The stale build's lease must be gone (released by its superseded |
| 540 | // cleanup or replaced by the rebound build's adopt) — the old session |
| 541 | // must be acquirable again. |
| 542 | lease, err := agent.TryAcquireSessionLease(oldKey) |
| 543 | if err != nil { |
| 544 | t.Fatalf("stale build leaked its session lease: %v", err) |
| 545 | } |
| 546 | lease.Release() |
| 547 | // Exactly one shared-host reference may remain: the rebound build's. The |
| 548 | // stale build must have released its own. |
| 549 | app.sharedHostsMu.Lock() |
| 550 | refs := 0 |
| 551 | for _, entry := range app.sharedHosts { |
| 552 | refs += entry.refs |
| 553 | } |
| 554 | app.sharedHostsMu.Unlock() |
| 555 | if refs != 1 { |
| 556 | t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the rebound build's)", refs) |
| 557 | } |
| 558 | } |
| 559 | |
| 560 | // TestMetaForTabConcurrentWithBuildSwap polls MetaForTab (the frontend's boot |
| 561 | // probe) while a fake build goroutine flips Ready/Label/StartupErr/model under |
| 562 | // a.mu — the write pattern of buildTabControllerWithContext. Run with -race. |
| 563 | func TestMetaForTabConcurrentWithBuildSwap(t *testing.T) { |
| 564 | isolateDesktopUserDirs(t) |
| 565 | tab := &WorkspaceTab{ID: "tab", Scope: "project", WorkspaceRoot: t.TempDir()} |
| 566 | app := &App{ |
| 567 | tabs: map[string]*WorkspaceTab{"tab": tab}, |
| 568 | tabOrder: []string{"tab"}, |
| 569 | activeTabID: "tab", |
| 570 | } |
| 571 | |
| 572 | const iterations = 100 |
| 573 | done := make(chan struct{}) |
| 574 | go func() { |
| 575 | defer close(done) |
| 576 | for i := 0; i < iterations; i++ { |
| 577 | app.mu.Lock() |
| 578 | tab.Ready = !tab.Ready |
| 579 | tab.Label = fmt.Sprintf("model-%d", i) |
| 580 | tab.StartupErr = "" |
| 581 | tab.model = fmt.Sprintf("provider/m%d", i) |
| 582 | tab.goal = fmt.Sprintf("goal-%d", i) |
| 583 | tab.tokenMode = "full" |
| 584 | app.mu.Unlock() |
| 585 | } |
| 586 | }() |
| 587 | for i := 0; i < iterations; i++ { |
| 588 | meta := app.MetaForTab("tab") |
| 589 | if meta.EventChannel == "" { |
| 590 | t.Fatal("MetaForTab returned zero meta for a live tab") |
| 591 | } |
| 592 | } |
| 593 | <-done |
| 594 | } |
| 595 |