| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "maps" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | "sync" |
| 14 | "sync/atomic" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | tea "charm.land/bubbletea/v2" |
| 19 | |
| 20 | "reasonix/internal/agent" |
| 21 | "reasonix/internal/control" |
| 22 | "reasonix/internal/event" |
| 23 | "reasonix/internal/i18n" |
| 24 | "reasonix/internal/provider" |
| 25 | "reasonix/internal/session" |
| 26 | ) |
| 27 | |
| 28 | // newCanonicalTakeoverTUI builds an exclusive-session TUI over a fresh |
| 29 | // sessions-v4 catalog sharing the CLI's cached session service, plus a second |
| 30 | // ("held") identity the fake serve pretends to release. |
| 31 | func newCanonicalTakeoverTUI(t *testing.T) (*chatTUI, *control.Controller, *session.Service, session.SessionRef) { |
| 32 | t.Helper() |
| 33 | dir := t.TempDir() |
| 34 | sessionDir := filepath.Join(dir, "sessions") |
| 35 | if err := os.MkdirAll(sessionDir, 0o700); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | service := cliSessionService(sessionDir) |
| 39 | if service == nil { |
| 40 | t.Fatal("session service unavailable for test workspace") |
| 41 | } |
| 42 | ctrl := newOwnedTestController(t, control.Options{ |
| 43 | Executor: agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard), |
| 44 | SessionDir: sessionDir, SessionService: service, ExclusiveSession: true, |
| 45 | }) |
| 46 | if _, err := ctrl.BindFreshSession(t.Context(), "fresh-cli"); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | held, err := service.Create(t.Context(), session.CreateOptions{SessionID: "held"}) |
| 50 | if err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | if err := service.Close(t.Context(), held.Ref()); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | m := newTestChatTUI() |
| 57 | m.ctrl = ctrl |
| 58 | m.leases = control.NewSessionLeaseKeeper() |
| 59 | t.Cleanup(m.leases.Release) |
| 60 | m.takeover = newCLITakeoverManager(nil, m.leases) |
| 61 | t.Cleanup(func() { |
| 62 | ctrl.Close() |
| 63 | _ = service.CloseAll(context.Background()) |
| 64 | }) |
| 65 | // Registered last so it runs first: the manager's loop reads the serve |
| 66 | // discovery seam that withFakeCanonicalDiscovery restores on cleanup, so a |
| 67 | // loop outliving its test races the next test's fixture. |
| 68 | takeover := m.takeover |
| 69 | t.Cleanup(func() { _ = takeover.Close() }) |
| 70 | return &m, ctrl, service, held.Ref() |
| 71 | } |
| 72 | |
| 73 | // fakeCanonicalServe impersonates the resident serve: it grants the identity |
| 74 | // handoff, accepts mirrored frames, and can flag a reclaim. |
| 75 | type fakeCanonicalServe struct { |
| 76 | mu sync.Mutex |
| 77 | base string |
| 78 | handoffBody map[string]any |
| 79 | // routes, when set, lists every route the serve grants; the grant echoes |
| 80 | // the requested route and a distinct mirror id. Unlisted routes are |
| 81 | // refused like a serve that does not hold the session. |
| 82 | routes map[string]bool |
| 83 | handoffs int |
| 84 | framesPath []string |
| 85 | mirrorEnd []string |
| 86 | reclaim atomic.Bool |
| 87 | } |
| 88 | |
| 89 | func newFakeCanonicalServe(t *testing.T, route string) *fakeCanonicalServe { |
| 90 | t.Helper() |
| 91 | return newFakeCanonicalServeRoutes(t, route) |
| 92 | } |
| 93 | |
| 94 | func newFakeCanonicalServeRoutes(t *testing.T, routes ...string) *fakeCanonicalServe { |
| 95 | t.Helper() |
| 96 | f := &fakeCanonicalServe{} |
| 97 | f.handoffBody = map[string]any{ |
| 98 | "sessionPath": routes[0], "mirrorId": "mirror-1", "handoffId": "handoff-1", |
| 99 | "returnHandoffId": "return-1", "sourceWriterId": "serve-writer", |
| 100 | "targetWriterId": agent.SessionWriterID(), "status": "handed_off", |
| 101 | } |
| 102 | if len(routes) > 1 { |
| 103 | f.routes = make(map[string]bool, len(routes)) |
| 104 | for _, route := range routes { |
| 105 | f.routes[route] = true |
| 106 | } |
| 107 | } |
| 108 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 109 | switch r.URL.Path { |
| 110 | case "/auth/token": |
| 111 | w.WriteHeader(http.StatusNoContent) |
| 112 | case "/handoff": |
| 113 | var body map[string]any |
| 114 | _ = json.NewDecoder(r.Body).Decode(&body) |
| 115 | requested, _ := body["sessionPath"].(string) |
| 116 | f.mu.Lock() |
| 117 | f.handoffs++ |
| 118 | f.handoffBody["__received"] = body |
| 119 | response := make(map[string]any, len(f.handoffBody)) |
| 120 | maps.Copy(response, f.handoffBody) |
| 121 | if f.routes != nil { |
| 122 | if !f.routes[requested] { |
| 123 | f.mu.Unlock() |
| 124 | http.Error(w, "session is not held by this serve process", http.StatusConflict) |
| 125 | return |
| 126 | } |
| 127 | response["sessionPath"] = requested |
| 128 | response["mirrorId"] = "mirror-" + strconv.Itoa(f.handoffs) |
| 129 | } |
| 130 | f.mu.Unlock() |
| 131 | _ = json.NewEncoder(w).Encode(response) |
| 132 | case "/external/frames": |
| 133 | var body struct { |
| 134 | SessionPath string `json:"sessionPath"` |
| 135 | MirrorID string `json:"mirrorId"` |
| 136 | } |
| 137 | _ = json.NewDecoder(r.Body).Decode(&body) |
| 138 | f.mu.Lock() |
| 139 | f.framesPath = append(f.framesPath, body.SessionPath) |
| 140 | f.mu.Unlock() |
| 141 | _ = json.NewEncoder(w).Encode(map[string]any{"reclaimRequested": f.reclaim.Load(), "reclaimMode": "wait"}) |
| 142 | case "/mirror-end": |
| 143 | var body struct { |
| 144 | SessionPath string `json:"sessionPath"` |
| 145 | } |
| 146 | _ = json.NewDecoder(r.Body).Decode(&body) |
| 147 | f.mu.Lock() |
| 148 | f.mirrorEnd = append(f.mirrorEnd, body.SessionPath) |
| 149 | f.mu.Unlock() |
| 150 | w.WriteHeader(http.StatusNoContent) |
| 151 | default: |
| 152 | w.WriteHeader(http.StatusNotFound) |
| 153 | } |
| 154 | })) |
| 155 | t.Cleanup(srv.Close) |
| 156 | f.base = srv.URL |
| 157 | return f |
| 158 | } |
| 159 | |
| 160 | func (f *fakeCanonicalServe) mirrorEnds() []string { |
| 161 | f.mu.Lock() |
| 162 | defer f.mu.Unlock() |
| 163 | return append([]string(nil), f.mirrorEnd...) |
| 164 | } |
| 165 | |
| 166 | func (f *fakeCanonicalServe) handoffCount() int { |
| 167 | f.mu.Lock() |
| 168 | defer f.mu.Unlock() |
| 169 | return f.handoffs |
| 170 | } |
| 171 | |
| 172 | func withFakeCanonicalDiscovery(t *testing.T, base string) { |
| 173 | t.Helper() |
| 174 | previous := discoverCLIServesForTakeover |
| 175 | discoverCLIServesForTakeover = func() []cliServeRecord { |
| 176 | return []cliServeRecord{{pid: 1, base: base, token: "test-token"}} |
| 177 | } |
| 178 | t.Cleanup(func() { discoverCLIServesForTakeover = previous }) |
| 179 | } |
| 180 | |
| 181 | // TestCanonicalTakeoverCommandTakesOverIdentity proves the /takeover command |
| 182 | // against an identity route: the grant is validated, the controller attaches |
| 183 | // through OpenSession, and the mirror manager activates on the route key. |
| 184 | func TestCanonicalTakeoverCommandTakesOverIdentity(t *testing.T) { |
| 185 | route := cliCanonicalRoute("held") |
| 186 | fake := newFakeCanonicalServe(t, route) |
| 187 | withFakeCanonicalDiscovery(t, fake.base) |
| 188 | m, ctrl, _, held := newCanonicalTakeoverTUI(t) |
| 189 | |
| 190 | m.runCanonicalTakeoverCommand(route) |
| 191 | |
| 192 | if got := m.pendingTakeoverPath; got != "" { |
| 193 | t.Fatalf("pending takeover target = %q after success", got) |
| 194 | } |
| 195 | if ref, bound := ctrl.SessionRef(); !bound || ref != held { |
| 196 | t.Fatalf("controller ref after takeover = %+v (bound %v), want %+v", ref, bound, held) |
| 197 | } |
| 198 | binding, _, _, _ := m.takeover.snapshot() |
| 199 | if binding == nil || binding.path != route || !binding.canonical { |
| 200 | t.Fatalf("mirror binding = %+v, want canonical route %q", binding, route) |
| 201 | } |
| 202 | fake.mu.Lock() |
| 203 | received, _ := fake.handoffBody["__received"].(map[string]any) |
| 204 | fake.mu.Unlock() |
| 205 | if received == nil || received["sessionPath"] != route || received["targetWriterId"] != agent.SessionWriterID() { |
| 206 | t.Fatalf("handoff request = %+v", received) |
| 207 | } |
| 208 | if err := m.takeover.Close(); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | // TestCanonicalTakeoverCommandReportsRefusedGrant proves a refusing serve |
| 214 | // surfaces the failure without touching the controller's session. |
| 215 | func TestCanonicalTakeoverCommandReportsRefusedGrant(t *testing.T) { |
| 216 | route := cliCanonicalRoute("held") |
| 217 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 218 | switch r.URL.Path { |
| 219 | case "/auth/token": |
| 220 | w.WriteHeader(http.StatusNoContent) |
| 221 | default: |
| 222 | http.Error(w, "session is not held by this serve process", http.StatusConflict) |
| 223 | } |
| 224 | })) |
| 225 | defer srv.Close() |
| 226 | withFakeCanonicalDiscovery(t, srv.URL) |
| 227 | m, ctrl, _, held := newCanonicalTakeoverTUI(t) |
| 228 | // Model the refusal that matters: another runtime really owns the writer, |
| 229 | // so the refused grant is the only way in. |
| 230 | holder, err := session.NewService("local", session.NewFilesystemPersistence(session.RootForLegacyDir(ctrl.SessionDir()))) |
| 231 | if err != nil { |
| 232 | t.Fatal(err) |
| 233 | } |
| 234 | // Shutdown, not CloseAll: the binding below is never unbound, and only the |
| 235 | // terminal boundary releases its writer lease before TempDir removal. |
| 236 | // Windows refuses to unlink a still-open ownership lock. |
| 237 | t.Cleanup(func() { _ = holder.Shutdown(context.Background()) }) |
| 238 | if _, err := holder.Open(t.Context(), held); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | |
| 242 | m.runCanonicalTakeoverCommand(route) |
| 243 | |
| 244 | if ref, bound := ctrl.SessionRef(); !bound || ref.SessionID != "fresh-cli" { |
| 245 | t.Fatalf("controller ref after refused takeover = %+v (bound %v), want the original fresh session", ref, bound) |
| 246 | } |
| 247 | if binding, _, _, _ := m.takeover.snapshot(); binding != nil { |
| 248 | t.Fatalf("mirror binding activated despite refusal: %+v", binding) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | // TestCanonicalReclaimYieldsWithoutLegacyLease proves the yield half: a |
| 253 | // reclaim signal against a canonical binding returns the mirror without any |
| 254 | // legacy path-lease reservation, and mirror-end carries the identity route. |
| 255 | func TestCanonicalReclaimYieldsWithoutLegacyLease(t *testing.T) { |
| 256 | route := cliCanonicalRoute("held") |
| 257 | fake := newFakeCanonicalServe(t, route) |
| 258 | m, ctrl, _, held := newCanonicalTakeoverTUI(t) |
| 259 | withFakeCanonicalDiscovery(t, fake.base) |
| 260 | m.runCanonicalTakeoverCommand(route) |
| 261 | if ref, bound := ctrl.SessionRef(); !bound || ref != held { |
| 262 | t.Fatalf("takeover did not attach: %+v bound=%v", ref, bound) |
| 263 | } |
| 264 | |
| 265 | exited := make(chan struct{}, 1) |
| 266 | m.takeover.SetYieldCallback(func() { exited <- struct{}{} }) |
| 267 | fake.reclaim.Store(true) |
| 268 | m.takeover.Emit(event.Event{Kind: event.Text, Text: "answer"}) |
| 269 | select { |
| 270 | case <-exited: |
| 271 | case <-time.After(5 * time.Second): |
| 272 | t.Fatal("reclaim did not yield the canonical mirror") |
| 273 | } |
| 274 | if !m.takeover.Returned() { |
| 275 | t.Fatal("canonical mirror not marked returned") |
| 276 | } |
| 277 | fake.mu.Lock() |
| 278 | ends := append([]string(nil), fake.mirrorEnd...) |
| 279 | fake.mu.Unlock() |
| 280 | if len(ends) != 1 || ends[0] != route { |
| 281 | t.Fatalf("mirror-end requests = %v, want one for %q", ends, route) |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | // TestCanonicalReclaimKeepsTUIAliveAndSwitchesSession proves that reclaiming |
| 286 | // one identity releases its writer while leaving the CLI process available to |
| 287 | // resume another identity. |
| 288 | func TestCanonicalReclaimKeepsTUIAliveAndSwitchesSession(t *testing.T) { |
| 289 | route := cliCanonicalRoute("held") |
| 290 | fake := newFakeCanonicalServe(t, route) |
| 291 | withFakeCanonicalDiscovery(t, fake.base) |
| 292 | m, ctrl, service, held := newCanonicalTakeoverTUI(t) |
| 293 | if err := ctrl.RecordSessionMessages(t.Context(), "reclaim-test", []provider.Message{{ |
| 294 | ID: agent.NewMessageID(), Role: provider.RoleUser, Content: "existing session", |
| 295 | }}); err != nil { |
| 296 | t.Fatal(err) |
| 297 | } |
| 298 | m.runCanonicalTakeoverCommand(route) |
| 299 | |
| 300 | yielded := make(chan struct{}, 1) |
| 301 | m.takeover.SetYieldCallback(func() { yielded <- struct{}{} }) |
| 302 | fake.reclaim.Store(true) |
| 303 | m.takeover.Emit(event.Event{Kind: event.Text, Text: "answer"}) |
| 304 | select { |
| 305 | case <-yielded: |
| 306 | case <-time.After(5 * time.Second): |
| 307 | t.Fatal("reclaim did not yield the identity") |
| 308 | } |
| 309 | |
| 310 | if next, cmd := m.Update(tuiSessionReclaimedMsg{}); cmd != nil { |
| 311 | t.Fatalf("reclaim updated TUI with quit command %T", cmd) |
| 312 | } else { |
| 313 | updated := next.(chatTUI) |
| 314 | m = &updated |
| 315 | } |
| 316 | // The reclaimed conversation stays rendered with a notice instead of a |
| 317 | // forced chooser; only the switch/takeover/exit commands are accepted. |
| 318 | if !m.sessionReclaimed || m.resumePick != nil { |
| 319 | t.Fatalf("reclaim state = %v picker=%v, want live TUI on the reclaimed session", m.sessionReclaimed, m.resumePick != nil) |
| 320 | } |
| 321 | if reclaimInputAllowed("hello") { |
| 322 | t.Fatal("reclaim gate accepted plain text") |
| 323 | } |
| 324 | if !reclaimInputAllowed("/resume 2") { |
| 325 | t.Fatal("reclaim gate rejected /resume") |
| 326 | } |
| 327 | dir, err := service.SessionDir(t.Context(), held) |
| 328 | if err != nil { |
| 329 | t.Fatal(err) |
| 330 | } |
| 331 | if session.ProbeWriterHeld(dir) { |
| 332 | t.Fatal("reclaimed identity writer lock is still held by the CLI") |
| 333 | } |
| 334 | |
| 335 | // /resume opens the chooser on demand and switching clears the reclaim state. |
| 336 | m.runResumeCommand("/resume") |
| 337 | if m.resumePick == nil { |
| 338 | t.Fatal("/resume after reclaim did not open the session chooser") |
| 339 | } |
| 340 | entries := m.resumePick.entries |
| 341 | for i, entry := range entries { |
| 342 | if entry.target.canonical() && entry.target.ref.SessionID == "fresh-cli" { |
| 343 | m.resumePick.sel = i |
| 344 | if m.resumePick.quick != nil { |
| 345 | m.resumePick.quick.selected = i |
| 346 | } |
| 347 | break |
| 348 | } |
| 349 | } |
| 350 | if m.resumePick.sel < 0 || m.resumePick.sel >= len(entries) || !entries[m.resumePick.sel].target.canonical() || entries[m.resumePick.sel].target.ref.SessionID != "fresh-cli" { |
| 351 | t.Fatalf("resume picker did not expose fresh-cli: %+v", entries) |
| 352 | } |
| 353 | // Confirm through the same key path used by the live picker. Calling |
| 354 | // applyResumePick directly would miss routing or quick-picker regressions. |
| 355 | next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) |
| 356 | if cmd != nil { |
| 357 | t.Fatalf("resume after reclaim returned unexpected command %T", cmd) |
| 358 | } |
| 359 | updated := next.(chatTUI) |
| 360 | m = &updated |
| 361 | if ref, bound := ctrl.SessionRef(); !bound || ref.SessionID != "fresh-cli" { |
| 362 | t.Fatalf("controller after reclaim resume = %+v bound=%v, want fresh-cli", ref, bound) |
| 363 | } |
| 364 | if m.sessionReclaimed || m.takeover.Returned() { |
| 365 | t.Fatal("reclaim marker remained after switching to another session") |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | // TestCanonicalReclaimCanTakeSameSessionAgain covers the complete ownership |
| 370 | // round trip: CLI takeover, desktop reclaim, then CLI takeover once more. |
| 371 | func TestCanonicalReclaimCanTakeSameSessionAgain(t *testing.T) { |
| 372 | route := cliCanonicalRoute("held") |
| 373 | fake := newFakeCanonicalServe(t, route) |
| 374 | withFakeCanonicalDiscovery(t, fake.base) |
| 375 | m, ctrl, _, held := newCanonicalTakeoverTUI(t) |
| 376 | m.runCanonicalTakeoverCommand(route) |
| 377 | |
| 378 | yielded := make(chan struct{}, 1) |
| 379 | m.takeover.SetYieldCallback(func() { yielded <- struct{}{} }) |
| 380 | fake.reclaim.Store(true) |
| 381 | m.takeover.Emit(event.Event{Kind: event.Text, Text: "answer"}) |
| 382 | select { |
| 383 | case <-yielded: |
| 384 | case <-time.After(5 * time.Second): |
| 385 | t.Fatal("reclaim did not yield the identity") |
| 386 | } |
| 387 | if next, cmd := m.Update(tuiSessionReclaimedMsg{}); cmd != nil { |
| 388 | t.Fatalf("reclaim updated TUI with quit command %T", cmd) |
| 389 | } else { |
| 390 | updated := next.(chatTUI) |
| 391 | m = &updated |
| 392 | } |
| 393 | if _, bound := ctrl.SessionRef(); bound { |
| 394 | t.Fatal("controller remained bound after desktop reclaim") |
| 395 | } |
| 396 | |
| 397 | fake.reclaim.Store(false) |
| 398 | // The user-facing path: after a reclaim the notice says "/takeover takes |
| 399 | // it back" — the command must resolve the remembered reclaimed target |
| 400 | // without a prior /resume (which would re-populate pendingTakeoverPath). |
| 401 | m.runTakeoverCommand("/takeover") |
| 402 | if ref, bound := ctrl.SessionRef(); !bound || ref != held { |
| 403 | t.Fatalf("controller after second takeover = %+v bound=%v, want %+v", ref, bound, held) |
| 404 | } |
| 405 | if m.sessionReclaimed || m.takeover.Returned() { |
| 406 | t.Fatal("second takeover left the CLI in reclaimed mode") |
| 407 | } |
| 408 | } |
| 409 | |
| 410 | // TestDiscoverCLIServesSkipsDeadPIDs pins the discovery prune: state files |
| 411 | // whose recorded process is gone must not surface as takeover candidates — |
| 412 | // dialing their stale ports only produces connection-refused noise. |
| 413 | func TestDiscoverCLIServesSkipsDeadPIDs(t *testing.T) { |
| 414 | home := t.TempDir() |
| 415 | stateDir := filepath.Join(home, "remote") |
| 416 | if err := os.MkdirAll(stateDir, 0o700); err != nil { |
| 417 | t.Fatal(err) |
| 418 | } |
| 419 | t.Setenv("REASONIX_HOME", home) |
| 420 | write := func(slug, addr string, pid int) { |
| 421 | t.Helper() |
| 422 | state := map[string]any{"pid": pid, "addr": addr, "workspace": "/tmp/" + slug} |
| 423 | data, err := json.Marshal(state) |
| 424 | if err != nil { |
| 425 | t.Fatal(err) |
| 426 | } |
| 427 | if err := os.WriteFile(filepath.Join(stateDir, "serve-"+slug+".json"), data, 0o600); err != nil { |
| 428 | t.Fatal(err) |
| 429 | } |
| 430 | if err := os.WriteFile(filepath.Join(stateDir, "serve-"+slug+".token"), []byte("tok-"+slug), 0o600); err != nil { |
| 431 | t.Fatal(err) |
| 432 | } |
| 433 | } |
| 434 | write("dead-one", "127.0.0.1:44173", 111) |
| 435 | write("alive-one", "127.0.0.1:33863", 222) |
| 436 | |
| 437 | previous := cliServeProcessAlive |
| 438 | cliServeProcessAlive = func(pid int) bool { return pid == 222 } |
| 439 | t.Cleanup(func() { cliServeProcessAlive = previous }) |
| 440 | |
| 441 | records := discoverCLIServes() |
| 442 | if len(records) != 1 || records[0].base != "http://127.0.0.1:33863" || records[0].token != "tok-alive-one" { |
| 443 | t.Fatalf("discovery after prune = %+v, want only the alive record", records) |
| 444 | } |
| 445 | } |
| 446 | |
| 447 | // TestCanonicalTakeoverDoesNotRetryOnServeVerdict pins the retry rule: a serve |
| 448 | // that answers — here the wait-mode "still running" verdict, which already |
| 449 | // cost one bounded drain window — is not asked again through a second |
| 450 | // discovery pass, so the synchronous worst case is one round, not two. |
| 451 | func TestCanonicalTakeoverDoesNotRetryOnServeVerdict(t *testing.T) { |
| 452 | var handoffs atomic.Int32 |
| 453 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 454 | switch r.URL.Path { |
| 455 | case "/auth/token": |
| 456 | w.WriteHeader(http.StatusNoContent) |
| 457 | case "/handoff": |
| 458 | handoffs.Add(1) |
| 459 | http.Error(w, "session is still running; retry with mode=interrupt", http.StatusConflict) |
| 460 | default: |
| 461 | w.WriteHeader(http.StatusNotFound) |
| 462 | } |
| 463 | })) |
| 464 | defer srv.Close() |
| 465 | var discoveries atomic.Int32 |
| 466 | previous := discoverCLIServesForTakeover |
| 467 | discoverCLIServesForTakeover = func() []cliServeRecord { |
| 468 | discoveries.Add(1) |
| 469 | return []cliServeRecord{{pid: 1, base: srv.URL, token: "test-token"}} |
| 470 | } |
| 471 | t.Cleanup(func() { discoverCLIServesForTakeover = previous }) |
| 472 | |
| 473 | binding, err := cliTakeoverIdentityHeldSession(cliCanonicalRoute("held"), nil) |
| 474 | |
| 475 | if binding != nil || err == nil || !strings.Contains(err.Error(), "still running") { |
| 476 | t.Fatalf("takeover = %+v, %v; want the serve's verdict", binding, err) |
| 477 | } |
| 478 | if cliServeUnreachable(err) { |
| 479 | t.Fatal("an HTTP verdict was classified as a transport failure") |
| 480 | } |
| 481 | if got := handoffs.Load(); got != 1 { |
| 482 | t.Fatalf("handoff requests = %d, want exactly 1 after a verdict", got) |
| 483 | } |
| 484 | if got := discoveries.Load(); got != 1 { |
| 485 | t.Fatalf("discovery passes = %d, want 1 after a verdict", got) |
| 486 | } |
| 487 | } |
| 488 | |
| 489 | // TestCanonicalTakeoverRediscoversAfterTransportFailure keeps the one |
| 490 | // re-discovery pass for the case it exists for: every recorded serve was |
| 491 | // unreachable (a desktop reconnect respawned it), and the fresh state file |
| 492 | // names the live serve. |
| 493 | func TestCanonicalTakeoverRediscoversAfterTransportFailure(t *testing.T) { |
| 494 | route := cliCanonicalRoute("held") |
| 495 | fake := newFakeCanonicalServe(t, route) |
| 496 | var discoveries atomic.Int32 |
| 497 | previous := discoverCLIServesForTakeover |
| 498 | discoverCLIServesForTakeover = func() []cliServeRecord { |
| 499 | if discoveries.Add(1) == 1 { |
| 500 | // Nothing listens on port 1: the dial fails at the transport. |
| 501 | return []cliServeRecord{{pid: 1, base: "http://127.0.0.1:1", token: "stale-token"}} |
| 502 | } |
| 503 | return []cliServeRecord{{pid: 2, base: fake.base, token: "test-token"}} |
| 504 | } |
| 505 | t.Cleanup(func() { discoverCLIServesForTakeover = previous }) |
| 506 | |
| 507 | binding, err := cliTakeoverIdentityHeldSession(route, nil) |
| 508 | |
| 509 | if err != nil || binding == nil || binding.path != route { |
| 510 | t.Fatalf("takeover = %+v, %v; want a grant from the re-discovered serve", binding, err) |
| 511 | } |
| 512 | if got := discoveries.Load(); got != 2 { |
| 513 | t.Fatalf("discovery passes = %d, want 2 (one after the transport failure)", got) |
| 514 | } |
| 515 | if fake.handoffCount() != 1 { |
| 516 | t.Fatalf("handoff requests to the live serve = %d, want 1", fake.handoffCount()) |
| 517 | } |
| 518 | } |
| 519 | |
| 520 | // TestCanonicalTakeoverOfFreeSessionResumes covers the promise the reclaim |
| 521 | // notice makes after the desktop closed the session it took back: no runtime |
| 522 | // holds the identity any more, so there is nothing to hand over and |
| 523 | // /takeover resumes it instead of failing with "no resident serve holds this |
| 524 | // session". Both shapes of "closed" are covered: the serve exited, and a |
| 525 | // resident serve that no longer holds the session and refuses. |
| 526 | func TestCanonicalTakeoverOfFreeSessionResumes(t *testing.T) { |
| 527 | t.Run("the serve exited", func(t *testing.T) { |
| 528 | previous := discoverCLIServesForTakeover |
| 529 | discoverCLIServesForTakeover = func() []cliServeRecord { return nil } |
| 530 | t.Cleanup(func() { discoverCLIServesForTakeover = previous }) |
| 531 | m, ctrl, _, held := newCanonicalTakeoverTUI(t) |
| 532 | |
| 533 | m.runCanonicalTakeoverCommand(cliCanonicalRoute("held")) |
| 534 | |
| 535 | if ref, bound := ctrl.SessionRef(); !bound || ref != held { |
| 536 | t.Fatalf("controller after /takeover of a free session = %+v bound=%v, want %+v", ref, bound, held) |
| 537 | } |
| 538 | if binding, _, _, _ := m.takeover.snapshot(); binding != nil { |
| 539 | t.Fatalf("resuming a free session activated a mirror: %+v", binding) |
| 540 | } |
| 541 | }) |
| 542 | t.Run("a resident serve no longer holds it", func(t *testing.T) { |
| 543 | route := cliCanonicalRoute("held") |
| 544 | fake := newFakeCanonicalServe(t, route) |
| 545 | withFakeCanonicalDiscovery(t, fake.base) |
| 546 | m, ctrl, _, held := newCanonicalTakeoverTUI(t) |
| 547 | m.runCanonicalTakeoverCommand(route) |
| 548 | yielded := make(chan struct{}, 1) |
| 549 | m.takeover.SetYieldCallback(func() { yielded <- struct{}{} }) |
| 550 | fake.reclaim.Store(true) |
| 551 | m.takeover.Emit(event.Event{Kind: event.Text, Text: "answer"}) |
| 552 | select { |
| 553 | case <-yielded: |
| 554 | case <-time.After(5 * time.Second): |
| 555 | t.Fatal("reclaim did not yield the identity") |
| 556 | } |
| 557 | next, _ := m.Update(tuiSessionReclaimedMsg{}) |
| 558 | updated := next.(chatTUI) |
| 559 | m = &updated |
| 560 | // The desktop closed the tab: the serve stays resident but refuses, |
| 561 | // and nobody holds the writer. |
| 562 | refusing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 563 | if r.URL.Path == "/auth/token" { |
| 564 | w.WriteHeader(http.StatusNoContent) |
| 565 | return |
| 566 | } |
| 567 | http.Error(w, "session is not held by this serve process", http.StatusConflict) |
| 568 | })) |
| 569 | defer refusing.Close() |
| 570 | withFakeCanonicalDiscovery(t, refusing.URL) |
| 571 | |
| 572 | m.runTakeoverCommand("/takeover") |
| 573 | |
| 574 | if ref, bound := ctrl.SessionRef(); !bound || ref != held { |
| 575 | t.Fatalf("controller after /takeover = %+v bound=%v, want %+v resumed", ref, bound, held) |
| 576 | } |
| 577 | if m.sessionReclaimed || m.takeover.Returned() { |
| 578 | t.Fatal("/takeover of the freed session left the CLI in reclaimed mode") |
| 579 | } |
| 580 | if binding, _, _, _ := m.takeover.snapshot(); binding != nil { |
| 581 | t.Fatalf("resuming a free session activated a mirror: %+v", binding) |
| 582 | } |
| 583 | }) |
| 584 | } |
| 585 | |
| 586 | // TestCanonicalTakeoverOfActiveSessionIsRejected keeps /takeover from |
| 587 | // "resuming" the identity this controller already writes. |
| 588 | func TestCanonicalTakeoverOfActiveSessionIsRejected(t *testing.T) { |
| 589 | previous := discoverCLIServesForTakeover |
| 590 | discoverCLIServesForTakeover = func() []cliServeRecord { return nil } |
| 591 | t.Cleanup(func() { discoverCLIServesForTakeover = previous }) |
| 592 | m, ctrl, _, _ := newCanonicalTakeoverTUI(t) |
| 593 | |
| 594 | m.runCanonicalTakeoverCommand(cliCanonicalRoute("fresh-cli")) |
| 595 | |
| 596 | if ref, bound := ctrl.SessionRef(); !bound || ref.SessionID != "fresh-cli" { |
| 597 | t.Fatalf("controller = %+v bound=%v, want the active session untouched", ref, bound) |
| 598 | } |
| 599 | out := strings.Join(m.transcript, "\n") |
| 600 | if !strings.Contains(out, i18n.M.ResumeAlreadyActive) { |
| 601 | t.Fatalf("transcript missing the already-active notice:\n%s", out) |
| 602 | } |
| 603 | if strings.Contains(out, i18n.M.ResumedTitle) { |
| 604 | t.Fatalf("/takeover of the active session replayed the transcript:\n%s", out) |
| 605 | } |
| 606 | } |
| 607 |