| 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 |