fix(daemon): harden lifecycle target resolution
(cherry picked from commit c371881099a589803c99de1fb30e380bf27f47c8)
- commit
- 77cef841e39340761210611d2b333b52a934b29a
- author
- brice · · 2026-07-15 07:15 UTC
- committer
- brice · · 2026-07-15 07:49 UTC
- parent
- 2d52091
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +225 -141
M internal/usecase/daemon/daemon.go +6 -2
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "log/slog" | |
| 8 | + | "math" | |
| 8 | 9 | "os" | |
| 9 | 10 | "slices" | |
| 10 | 11 | "sort" | |
| @@ -300,13 +301,16 @@ func WithConfig(cfg domain.Config) Option { | |||
| 300 | 301 | // allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller | |
| 301 | 302 | // holds d.mu. The persisted high-water mark is loaded by New before any named | |
| 302 | 303 | // session can be resumed or created. | |
| 303 | - | func (d *Daemon) allocateLifecycleCreatedAtLocked() int64 { | |
| 304 | + | func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) { | |
| 304 | 305 | now := d.nowUnixNano() | |
| 305 | 306 | if now <= d.lastAllocatedCreatedAt { | |
| 307 | + | if d.lastAllocatedCreatedAt == math.MaxInt64 { | |
| 308 | + | return 0, errors.New("daemon: lifecycle identities exhausted") | |
| 309 | + | } | |
| 306 | 310 | now = d.lastAllocatedCreatedAt + 1 | |
| 307 | 311 | } | |
| 308 | 312 | d.lastAllocatedCreatedAt = now | |
| 309 | - | return now | |
| 313 | + | return now, nil | |
| 310 | 314 | } | |
| 311 | 315 | ||
| 312 | 316 | func (d *Daemon) nowUnixNano() int64 { | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "log/slog" | 7 | "log/slog" | ||
| 8 | + | "math" | |||
| 8 | "os" | 9 | "os" | ||
| 9 | "slices" | 10 | "slices" | ||
| 10 | "sort" | 11 | "sort" | ||
| @@ -300,13 +301,16 @@ func WithConfig(cfg domain.Config) Option { | |||||
| 300 | // allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller | 301 | // allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller | ||
| 301 | // holds d.mu. The persisted high-water mark is loaded by New before any named | 302 | // holds d.mu. The persisted high-water mark is loaded by New before any named | ||
| 302 | // session can be resumed or created. | 303 | // session can be resumed or created. | ||
| 303 | - | func (d *Daemon) allocateLifecycleCreatedAtLocked() int64 { | 304 | + | func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) { |
| 304 | now := d.nowUnixNano() | 305 | now := d.nowUnixNano() | ||
| 305 | if now <= d.lastAllocatedCreatedAt { | 306 | if now <= d.lastAllocatedCreatedAt { | ||
| 307 | + | if d.lastAllocatedCreatedAt == math.MaxInt64 { | |||
| 308 | + | return 0, errors.New("daemon: lifecycle identities exhausted") | |||
| 309 | + | } | |||
| 306 | now = d.lastAllocatedCreatedAt + 1 | 310 | now = d.lastAllocatedCreatedAt + 1 | ||
| 307 | } | 311 | } | ||
| 308 | d.lastAllocatedCreatedAt = now | 312 | d.lastAllocatedCreatedAt = now | ||
| 309 | - | return now | 313 | + | return now, nil |
| 310 | } | 314 | } | ||
| 311 | 315 | ||||
| 312 | func (d *Daemon) nowUnixNano() int64 { | 316 | func (d *Daemon) nowUnixNano() int64 { | ||
M internal/usecase/daemon/picker.go +127 -107
| @@ -48,7 +48,11 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 48 | 48 | curTab := 0 | |
| 49 | 49 | for _, s := range sessions { | |
| 50 | 50 | s.mu.Lock() | |
| 51 | - | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} | |
| 51 | + | view := picker.SessionView{ID: s.id, Name: s.name, TargetName: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} | |
| 52 | + | if !s.ephemeral { | |
| 53 | + | createdAt := s.createdAt | |
| 54 | + | view.ExpectedCreatedAt = &createdAt | |
| 55 | + | } | |
| 52 | 56 | sessionAttention := false | |
| 53 | 57 | for i, tb := range s.tabs { | |
| 54 | 58 | name := tabDisplayName(tb, i) | |
| @@ -71,11 +75,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 71 | 75 | views = append(views, view) | |
| 72 | 76 | } | |
| 73 | 77 | for _, s := range stopped { | |
| 78 | + | createdAt := s.createdAt | |
| 74 | 79 | views = append(views, picker.SessionView{ | |
| 75 | - | ID: domain.SessionID("stopped:" + s.name), | |
| 76 | - | Name: s.name, | |
| 77 | - | Tabs: []picker.TabEntry{{}}, | |
| 78 | - | Stopped: true, | |
| 80 | + | ID: domain.SessionID("stopped:" + s.name), | |
| 81 | + | Name: s.name, | |
| 82 | + | TargetName: s.name, | |
| 83 | + | Tabs: []picker.TabEntry{{}}, | |
| 84 | + | Stopped: true, | |
| 85 | + | ExpectedCreatedAt: &createdAt, | |
| 79 | 86 | }) | |
| 80 | 87 | } | |
| 81 | 88 | return views, curTab | |
| @@ -383,116 +390,102 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||
| 383 | 390 | return d.sessions[id] | |
| 384 | 391 | } | |
| 385 | 392 | ||
| 386 | - | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool { | |
| 387 | - | if target.Stopped { | |
| 388 | - | if d.resumeStoppedAndSwitch(sess, ac, target) { | |
| 389 | - | return true | |
| 390 | - | } | |
| 391 | - | // The target may have become active after the palette snapshot. Resolve | |
| 392 | - | // it by name, but leave lifecycle validation to the locked hand-off. | |
| 393 | - | if target.Name == "" { | |
| 394 | - | return false | |
| 395 | - | } | |
| 396 | - | d.mu.Lock() | |
| 397 | - | targetSess := d.findByNameLocked(target.Name) | |
| 398 | - | d.mu.Unlock() | |
| 399 | - | if targetSess == nil { | |
| 400 | - | return false | |
| 401 | - | } | |
| 402 | - | target.Session, target.Stopped = targetSess.id, false | |
| 403 | - | if d.switchToActiveTarget(sess, ac, targetSess, target) { | |
| 404 | - | return true | |
| 405 | - | } | |
| 406 | - | // The active target may have stopped between lookup and hand-off. | |
| 407 | - | target.Stopped = true | |
| 408 | - | return d.resumeStoppedAndSwitch(sess, ac, target) | |
| 409 | - | } | |
| 410 | - | ||
| 411 | - | targetSess := d.sessionByID(target.Session) | |
| 412 | - | if targetSess == nil { | |
| 413 | - | if target.Name != "" { | |
| 414 | - | // The target may have stopped after the snapshot. The resume path | |
| 415 | - | // checks the expected lifecycle under d.mu before creating anything. | |
| 416 | - | target.Stopped = true | |
| 417 | - | return d.resumeStoppedAndSwitch(sess, ac, target) | |
| 418 | - | } | |
| 419 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 420 | - | return false | |
| 421 | - | } | |
| 422 | - | if d.switchToActiveTarget(sess, ac, targetSess, target) { | |
| 423 | - | return true | |
| 424 | - | } | |
| 393 | + | // switchToTarget resolves named lifecycle targets and commits their transition | |
| 394 | + | // while d.mu is held. A named palette result is allowed to cross an | |
| 395 | + | // active/stopped transition, but it never follows a same-name replacement. | |
| 396 | + | func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) bool { | |
| 397 | + | d.mu.Lock() | |
| 398 | + | var ( | |
| 399 | + | targetSess *session | |
| 400 | + | old *attachedClient | |
| 401 | + | cleanups []renderLifecycleCleanup | |
| 402 | + | switched bool | |
| 403 | + | ) | |
| 425 | 404 | if target.Name != "" { | |
| 426 | - | // The active target may have stopped between lookup and hand-off. | |
| 427 | - | target.Stopped = true | |
| 428 | - | return d.resumeStoppedAndSwitch(sess, ac, target) | |
| 429 | - | } | |
| 430 | - | return false | |
| 431 | - | } | |
| 432 | - | ||
| 433 | - | func (d *Daemon) switchToActiveTarget(sess *session, ac *attachedClient, targetSess *session, target picker.Target) bool { | |
| 434 | - | if targetSess == sess { | |
| 435 | - | switched := d.switchCurrentTarget(sess, target) | |
| 436 | - | if switched { | |
| 437 | - | d.activateTab(sess, sess.activeTab()) | |
| 405 | + | active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target) | |
| 406 | + | if ok { | |
| 407 | + | if isStopped { | |
| 408 | + | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |
| 409 | + | } else { | |
| 410 | + | // The snapshot ID may describe the stopped representation. The | |
| 411 | + | // locked name/lifecycle resolver chose this active incarnation. | |
| 412 | + | resolvedTarget := target | |
| 413 | + | resolvedTarget.Session = active.id | |
| 414 | + | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget) | |
| 415 | + | } | |
| 438 | 416 | } | |
| 439 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 440 | - | return switched | |
| 417 | + | } else if active := d.sessions[target.Session]; active != nil { | |
| 418 | + | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target) | |
| 441 | 419 | } | |
| 442 | - | old := d.stealClientForTarget(sess, ac, targetSess, target) | |
| 443 | - | if ac.currentSession() != targetSess { | |
| 444 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 420 | + | d.mu.Unlock() | |
| 421 | + | ||
| 422 | + | if !switched { | |
| 423 | + | d.invalidateRender(from, ac, true, "picker.go") | |
| 445 | 424 | return false | |
| 446 | 425 | } | |
| 426 | + | finishRenderLifecycleCleanups(cleanups) | |
| 447 | 427 | if old != nil && old != ac { | |
| 448 | 428 | d.unregisterPreview(old) | |
| 449 | 429 | old.clearPreviousSession() | |
| 450 | 430 | old.setSession(nil) | |
| 451 | 431 | d.notifyDetachedAsync(old, ports.ReasonDetach) | |
| 452 | 432 | } | |
| 453 | - | d.firstPaint(targetSess, ac, ac.size) | |
| 433 | + | if targetSess == from { | |
| 434 | + | d.activateTab(from, from.activeTab()) | |
| 435 | + | d.invalidateRender(from, ac, true, "picker.go") | |
| 436 | + | } else { | |
| 437 | + | d.firstPaint(targetSess, ac, ac.size) | |
| 438 | + | } | |
| 454 | 439 | return true | |
| 455 | 440 | } | |
| 456 | 441 | ||
| 457 | - | // switchCurrentTarget validates and updates a same-session target as one d.mu | |
| 458 | - | // commit, matching cross-session hand-off lifecycle semantics. | |
| 459 | - | func (d *Daemon) switchCurrentTarget(sess *session, target picker.Target) bool { | |
| 460 | - | d.mu.Lock() | |
| 461 | - | defer d.mu.Unlock() | |
| 462 | - | if d.sessions[target.Session] != sess { | |
| 463 | - | return false | |
| 442 | + | // resolveNamedLifecycleTargetLocked chooses exactly one current representation | |
| 443 | + | // of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle | |
| 444 | + | // identity here closes the lookup-to-handoff window for palette targets. | |
| 445 | + | func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, stoppedSession, bool, bool) { | |
| 446 | + | if target.Name == "" { | |
| 447 | + | return nil, stoppedSession{}, false, false | |
| 464 | 448 | } | |
| 465 | - | sess.mu.Lock() | |
| 466 | - | defer sess.mu.Unlock() | |
| 467 | - | if !targetMatchesLifecycle(target, sess.name, sess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) { | |
| 468 | - | return false | |
| 449 | + | if active := d.findByNameLocked(target.Name); active != nil { | |
| 450 | + | active.mu.Lock() | |
| 451 | + | matches := targetMatchesLifecycle(target, active.name, active.createdAt) | |
| 452 | + | active.mu.Unlock() | |
| 453 | + | return active, stoppedSession{}, false, matches | |
| 469 | 454 | } | |
| 470 | - | sess.active = target.TabIndex | |
| 471 | - | return true | |
| 472 | - | } | |
| 473 | - | ||
| 474 | - | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | |
| 475 | - | return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt) | |
| 455 | + | stopped, ok := d.stopped[target.Name] | |
| 456 | + | if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |
| 457 | + | return nil, stoppedSession{}, false, false | |
| 458 | + | } | |
| 459 | + | return nil, stopped, true, true | |
| 476 | 460 | } | |
| 477 | 461 | ||
| 478 | - | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |
| 479 | - | d.mu.Lock() | |
| 462 | + | // switchToActiveTargetLocked commits an active target handoff. Caller holds | |
| 463 | + | // d.mu and releases it before completing render cleanup or first paint. | |
| 464 | + | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) { | |
| 480 | 465 | if d.sessions[target.Session] != targetSess { | |
| 481 | - | d.mu.Unlock() | |
| 482 | - | return nil | |
| 466 | + | return nil, nil, nil, false | |
| 483 | 467 | } | |
| 468 | + | if targetSess == from { | |
| 469 | + | targetSess.mu.Lock() | |
| 470 | + | defer targetSess.mu.Unlock() | |
| 471 | + | if !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | |
| 472 | + | return nil, nil, nil, false | |
| 473 | + | } | |
| 474 | + | targetSess.active = target.TabIndex | |
| 475 | + | return targetSess, nil, nil, true | |
| 476 | + | } | |
| 477 | + | ||
| 484 | 478 | targetSess.mu.Lock() | |
| 485 | 479 | matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) | |
| 486 | - | targetSess.mu.Unlock() | |
| 487 | 480 | if !matches { | |
| 488 | - | d.mu.Unlock() | |
| 489 | - | return nil | |
| 481 | + | targetSess.mu.Unlock() | |
| 482 | + | return nil, nil, nil, false | |
| 490 | 483 | } | |
| 491 | 484 | from.mu.Lock() | |
| 492 | 485 | if from.client != ac { | |
| 493 | 486 | from.mu.Unlock() | |
| 494 | - | d.mu.Unlock() | |
| 495 | - | return nil | |
| 487 | + | targetSess.mu.Unlock() | |
| 488 | + | return nil, nil, nil, false | |
| 496 | 489 | } | |
| 497 | 490 | term := from.terminal | |
| 498 | 491 | env := copyEnvironment(from.env) | |
| @@ -500,7 +493,6 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 500 | 493 | ac.setSession(nil) | |
| 501 | 494 | from.mu.Unlock() | |
| 502 | 495 | ||
| 503 | - | targetSess.mu.Lock() | |
| 504 | 496 | old := targetSess.client | |
| 505 | 497 | targetSess.terminal = term | |
| 506 | 498 | targetSess.env = copyEnvironment(env) | |
| @@ -509,8 +501,8 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 509 | 501 | } | |
| 510 | 502 | targetSess.mu.Unlock() | |
| 511 | 503 | ||
| 512 | - | // Do not expose targetSess.client until the target coordinator has claimed | |
| 513 | - | // this attachment and the output dependency chain has been rebased. | |
| 504 | + | // Prepare source invalidation, output rebase, and destination coordinator | |
| 505 | + | // identity before publishing the destination attachment. | |
| 514 | 506 | cleanups := d.handoffCoordinator(from, targetSess, old, ac) | |
| 515 | 507 | ac.setSession(targetSess) | |
| 516 | 508 | targetSess.mu.Lock() | |
| @@ -518,24 +510,17 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 518 | 510 | targetSess.mu.Unlock() | |
| 519 | 511 | d.touchMRU(targetSess) | |
| 520 | 512 | ac.recordPreviousSession(from) | |
| 521 | - | d.mu.Unlock() | |
| 522 | - | finishRenderLifecycleCleanups(cleanups) | |
| 523 | - | return old | |
| 513 | + | return targetSess, old, cleanups, true | |
| 524 | 514 | } | |
| 525 | 515 | ||
| 526 | - | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool { | |
| 527 | - | d.mu.Lock() | |
| 528 | - | stopped, ok := d.stopped[target.Name] | |
| 529 | - | if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |
| 530 | - | d.mu.Unlock() | |
| 531 | - | d.invalidateRender(from, ac, true, "picker.go") | |
| 532 | - | return false | |
| 533 | - | } | |
| 516 | + | // resumeStoppedAndSwitchLocked creates the stopped representation and commits | |
| 517 | + | // the handoff while d.mu is held. Creation failure leaves the source client | |
| 518 | + | // and stopped record untouched. | |
| 519 | + | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool) { | |
| 534 | 520 | from.mu.Lock() | |
| 535 | 521 | if from.client != ac { | |
| 536 | 522 | from.mu.Unlock() | |
| 537 | - | d.mu.Unlock() | |
| 538 | - | return false | |
| 523 | + | return nil, nil, false | |
| 539 | 524 | } | |
| 540 | 525 | term := from.terminal | |
| 541 | 526 | env := copyEnvironment(from.env) | |
| @@ -543,10 +528,8 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 543 | 528 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames) | |
| 544 | 529 | if err != nil { | |
| 545 | 530 | from.mu.Unlock() | |
| 546 | - | d.mu.Unlock() | |
| 547 | 531 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | |
| 548 | - | d.invalidateRender(from, ac, true, "picker.go") | |
| 549 | - | return false | |
| 532 | + | return nil, nil, false | |
| 550 | 533 | } | |
| 551 | 534 | from.client = nil | |
| 552 | 535 | ac.setSession(nil) | |
| @@ -559,7 +542,44 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 559 | 542 | targetSess.mu.Unlock() | |
| 560 | 543 | d.touchMRU(targetSess) | |
| 561 | 544 | ac.recordPreviousSession(from) | |
| 545 | + | return targetSess, cleanups, true | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | |
| 549 | + | return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt) | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | // stealClientForTarget is retained for direct-ID callers and tests. Named | |
| 553 | + | // targets must use switchToTarget so resolution and commit share d.mu. | |
| 554 | + | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |
| 555 | + | d.mu.Lock() | |
| 556 | + | _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target) | |
| 557 | + | d.mu.Unlock() | |
| 558 | + | if !switched { | |
| 559 | + | return nil | |
| 560 | + | } | |
| 561 | + | finishRenderLifecycleCleanups(cleanups) | |
| 562 | + | return old | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves | |
| 566 | + | // its stopped target and commits creation under one d.mu critical section. | |
| 567 | + | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool { | |
| 568 | + | d.mu.Lock() | |
| 569 | + | stopped, ok := d.stopped[target.Name] | |
| 570 | + | var ( | |
| 571 | + | targetSess *session | |
| 572 | + | cleanups []renderLifecycleCleanup | |
| 573 | + | switched bool | |
| 574 | + | ) | |
| 575 | + | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |
| 576 | + | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |
| 577 | + | } | |
| 562 | 578 | d.mu.Unlock() | |
| 579 | + | if !switched { | |
| 580 | + | d.invalidateRender(from, ac, true, "picker.go") | |
| 581 | + | return false | |
| 582 | + | } | |
| 563 | 583 | finishRenderLifecycleCleanups(cleanups) | |
| 564 | 584 | d.firstPaint(targetSess, ac, ac.size) | |
| 565 | 585 | return true | |
| @@ -48,7 +48,11 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 48 | curTab := 0 | 48 | curTab := 0 | ||
| 49 | for _, s := range sessions { | 49 | for _, s := range sessions { | ||
| 50 | s.mu.Lock() | 50 | s.mu.Lock() | ||
| 51 | - | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} | 51 | + | view := picker.SessionView{ID: s.id, Name: s.name, TargetName: s.name, Active: s.active, Tabs: make([]picker.TabEntry, len(s.tabs))} |
| 52 | + | if !s.ephemeral { | |||
| 53 | + | createdAt := s.createdAt | |||
| 54 | + | view.ExpectedCreatedAt = &createdAt | |||
| 55 | + | } | |||
| 52 | sessionAttention := false | 56 | sessionAttention := false | ||
| 53 | for i, tb := range s.tabs { | 57 | for i, tb := range s.tabs { | ||
| 54 | name := tabDisplayName(tb, i) | 58 | name := tabDisplayName(tb, i) | ||
| @@ -71,11 +75,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 71 | views = append(views, view) | 75 | views = append(views, view) | ||
| 72 | } | 76 | } | ||
| 73 | for _, s := range stopped { | 77 | for _, s := range stopped { | ||
| 78 | + | createdAt := s.createdAt | |||
| 74 | views = append(views, picker.SessionView{ | 79 | views = append(views, picker.SessionView{ | ||
| 75 | - | ID: domain.SessionID("stopped:" + s.name), | 80 | + | ID: domain.SessionID("stopped:" + s.name), |
| 76 | - | Name: s.name, | 81 | + | Name: s.name, |
| 77 | - | Tabs: []picker.TabEntry{{}}, | 82 | + | TargetName: s.name, |
| 78 | - | Stopped: true, | 83 | + | Tabs: []picker.TabEntry{{}}, |
| 84 | + | Stopped: true, | |||
| 85 | + | ExpectedCreatedAt: &createdAt, | |||
| 79 | }) | 86 | }) | ||
| 80 | } | 87 | } | ||
| 81 | return views, curTab | 88 | return views, curTab | ||
| @@ -383,116 +390,102 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session { | |||||
| 383 | return d.sessions[id] | 390 | return d.sessions[id] | ||
| 384 | } | 391 | } | ||
| 385 | 392 | ||||
| 386 | - | func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) bool { | 393 | + | // switchToTarget resolves named lifecycle targets and commits their transition |
| 387 | - | if target.Stopped { | 394 | + | // while d.mu is held. A named palette result is allowed to cross an |
| 388 | - | if d.resumeStoppedAndSwitch(sess, ac, target) { | 395 | + | // active/stopped transition, but it never follows a same-name replacement. |
| 389 | - | return true | 396 | + | func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) bool { |
| 390 | - | } | 397 | + | d.mu.Lock() |
| 391 | - | // The target may have become active after the palette snapshot. Resolve | 398 | + | var ( |
| 392 | - | // it by name, but leave lifecycle validation to the locked hand-off. | 399 | + | targetSess *session |
| 393 | - | if target.Name == "" { | 400 | + | old *attachedClient |
| 394 | - | return false | 401 | + | cleanups []renderLifecycleCleanup |
| 395 | - | } | 402 | + | switched bool |
| 396 | - | d.mu.Lock() | 403 | + | ) |
| 397 | - | targetSess := d.findByNameLocked(target.Name) | |||
| 398 | - | d.mu.Unlock() | |||
| 399 | - | if targetSess == nil { | |||
| 400 | - | return false | |||
| 401 | - | } | |||
| 402 | - | target.Session, target.Stopped = targetSess.id, false | |||
| 403 | - | if d.switchToActiveTarget(sess, ac, targetSess, target) { | |||
| 404 | - | return true | |||
| 405 | - | } | |||
| 406 | - | // The active target may have stopped between lookup and hand-off. | |||
| 407 | - | target.Stopped = true | |||
| 408 | - | return d.resumeStoppedAndSwitch(sess, ac, target) | |||
| 409 | - | } | |||
| 410 | - | ||||
| 411 | - | targetSess := d.sessionByID(target.Session) | |||
| 412 | - | if targetSess == nil { | |||
| 413 | - | if target.Name != "" { | |||
| 414 | - | // The target may have stopped after the snapshot. The resume path | |||
| 415 | - | // checks the expected lifecycle under d.mu before creating anything. | |||
| 416 | - | target.Stopped = true | |||
| 417 | - | return d.resumeStoppedAndSwitch(sess, ac, target) | |||
| 418 | - | } | |||
| 419 | - | d.invalidateRender(sess, ac, true, "picker.go") | |||
| 420 | - | return false | |||
| 421 | - | } | |||
| 422 | - | if d.switchToActiveTarget(sess, ac, targetSess, target) { | |||
| 423 | - | return true | |||
| 424 | - | } | |||
| 425 | if target.Name != "" { | 404 | if target.Name != "" { | ||
| 426 | - | // The active target may have stopped between lookup and hand-off. | 405 | + | active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target) |
| 427 | - | target.Stopped = true | 406 | + | if ok { |
| 428 | - | return d.resumeStoppedAndSwitch(sess, ac, target) | 407 | + | if isStopped { |
| 429 | - | } | 408 | + | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) |
| 430 | - | return false | 409 | + | } else { |
| 431 | - | } | 410 | + | // The snapshot ID may describe the stopped representation. The |
| 432 | - | 411 | + | // locked name/lifecycle resolver chose this active incarnation. | |
| 433 | - | func (d *Daemon) switchToActiveTarget(sess *session, ac *attachedClient, targetSess *session, target picker.Target) bool { | 412 | + | resolvedTarget := target |
| 434 | - | if targetSess == sess { | 413 | + | resolvedTarget.Session = active.id |
| 435 | - | switched := d.switchCurrentTarget(sess, target) | 414 | + | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget) |
| 436 | - | if switched { | 415 | + | } |
| 437 | - | d.activateTab(sess, sess.activeTab()) | |||
| 438 | } | 416 | } | ||
| 439 | - | d.invalidateRender(sess, ac, true, "picker.go") | 417 | + | } else if active := d.sessions[target.Session]; active != nil { |
| 440 | - | return switched | 418 | + | targetSess, old, cleanups, switched = d.switchToActiveTargetLocked(from, ac, active, target) |
| 441 | } | 419 | } | ||
| 442 | - | old := d.stealClientForTarget(sess, ac, targetSess, target) | 420 | + | d.mu.Unlock() |
| 443 | - | if ac.currentSession() != targetSess { | 421 | + | |
| 444 | - | d.invalidateRender(sess, ac, true, "picker.go") | 422 | + | if !switched { |
| 423 | + | d.invalidateRender(from, ac, true, "picker.go") | |||
| 445 | return false | 424 | return false | ||
| 446 | } | 425 | } | ||
| 426 | + | finishRenderLifecycleCleanups(cleanups) | |||
| 447 | if old != nil && old != ac { | 427 | if old != nil && old != ac { | ||
| 448 | d.unregisterPreview(old) | 428 | d.unregisterPreview(old) | ||
| 449 | old.clearPreviousSession() | 429 | old.clearPreviousSession() | ||
| 450 | old.setSession(nil) | 430 | old.setSession(nil) | ||
| 451 | d.notifyDetachedAsync(old, ports.ReasonDetach) | 431 | d.notifyDetachedAsync(old, ports.ReasonDetach) | ||
| 452 | } | 432 | } | ||
| 453 | - | d.firstPaint(targetSess, ac, ac.size) | 433 | + | if targetSess == from { |
| 434 | + | d.activateTab(from, from.activeTab()) | |||
| 435 | + | d.invalidateRender(from, ac, true, "picker.go") | |||
| 436 | + | } else { | |||
| 437 | + | d.firstPaint(targetSess, ac, ac.size) | |||
| 438 | + | } | |||
| 454 | return true | 439 | return true | ||
| 455 | } | 440 | } | ||
| 456 | 441 | ||||
| 457 | - | // switchCurrentTarget validates and updates a same-session target as one d.mu | 442 | + | // resolveNamedLifecycleTargetLocked chooses exactly one current representation |
| 458 | - | // commit, matching cross-session hand-off lifecycle semantics. | 443 | + | // of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle |
| 459 | - | func (d *Daemon) switchCurrentTarget(sess *session, target picker.Target) bool { | 444 | + | // identity here closes the lookup-to-handoff window for palette targets. |
| 460 | - | d.mu.Lock() | 445 | + | func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, stoppedSession, bool, bool) { |
| 461 | - | defer d.mu.Unlock() | 446 | + | if target.Name == "" { |
| 462 | - | if d.sessions[target.Session] != sess { | 447 | + | return nil, stoppedSession{}, false, false |
| 463 | - | return false | |||
| 464 | } | 448 | } | ||
| 465 | - | sess.mu.Lock() | 449 | + | if active := d.findByNameLocked(target.Name); active != nil { |
| 466 | - | defer sess.mu.Unlock() | 450 | + | active.mu.Lock() |
| 467 | - | if !targetMatchesLifecycle(target, sess.name, sess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) { | 451 | + | matches := targetMatchesLifecycle(target, active.name, active.createdAt) |
| 468 | - | return false | 452 | + | active.mu.Unlock() |
| 453 | + | return active, stoppedSession{}, false, matches | |||
| 469 | } | 454 | } | ||
| 470 | - | sess.active = target.TabIndex | 455 | + | stopped, ok := d.stopped[target.Name] |
| 471 | - | return true | 456 | + | if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { |
| 472 | - | } | 457 | + | return nil, stoppedSession{}, false, false |
| 473 | - | 458 | + | } | |
| 474 | - | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | 459 | + | return nil, stopped, true, true |
| 475 | - | return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt) | |||
| 476 | } | 460 | } | ||
| 477 | 461 | ||||
| 478 | - | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | 462 | + | // switchToActiveTargetLocked commits an active target handoff. Caller holds |
| 479 | - | d.mu.Lock() | 463 | + | // d.mu and releases it before completing render cleanup or first paint. |
| 464 | + | func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target) (*session, *attachedClient, []renderLifecycleCleanup, bool) { | |||
| 480 | if d.sessions[target.Session] != targetSess { | 465 | if d.sessions[target.Session] != targetSess { | ||
| 481 | - | d.mu.Unlock() | 466 | + | return nil, nil, nil, false |
| 482 | - | return nil | |||
| 483 | } | 467 | } | ||
| 468 | + | if targetSess == from { | |||
| 469 | + | targetSess.mu.Lock() | |||
| 470 | + | defer targetSess.mu.Unlock() | |||
| 471 | + | if !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) { | |||
| 472 | + | return nil, nil, nil, false | |||
| 473 | + | } | |||
| 474 | + | targetSess.active = target.TabIndex | |||
| 475 | + | return targetSess, nil, nil, true | |||
| 476 | + | } | |||
| 477 | + | ||||
| 484 | targetSess.mu.Lock() | 478 | targetSess.mu.Lock() | ||
| 485 | matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) | 479 | matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) | ||
| 486 | - | targetSess.mu.Unlock() | |||
| 487 | if !matches { | 480 | if !matches { | ||
| 488 | - | d.mu.Unlock() | 481 | + | targetSess.mu.Unlock() |
| 489 | - | return nil | 482 | + | return nil, nil, nil, false |
| 490 | } | 483 | } | ||
| 491 | from.mu.Lock() | 484 | from.mu.Lock() | ||
| 492 | if from.client != ac { | 485 | if from.client != ac { | ||
| 493 | from.mu.Unlock() | 486 | from.mu.Unlock() | ||
| 494 | - | d.mu.Unlock() | 487 | + | targetSess.mu.Unlock() |
| 495 | - | return nil | 488 | + | return nil, nil, nil, false |
| 496 | } | 489 | } | ||
| 497 | term := from.terminal | 490 | term := from.terminal | ||
| 498 | env := copyEnvironment(from.env) | 491 | env := copyEnvironment(from.env) | ||
| @@ -500,7 +493,6 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 500 | ac.setSession(nil) | 493 | ac.setSession(nil) | ||
| 501 | from.mu.Unlock() | 494 | from.mu.Unlock() | ||
| 502 | 495 | ||||
| 503 | - | targetSess.mu.Lock() | |||
| 504 | old := targetSess.client | 496 | old := targetSess.client | ||
| 505 | targetSess.terminal = term | 497 | targetSess.terminal = term | ||
| 506 | targetSess.env = copyEnvironment(env) | 498 | targetSess.env = copyEnvironment(env) | ||
| @@ -509,8 +501,8 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 509 | } | 501 | } | ||
| 510 | targetSess.mu.Unlock() | 502 | targetSess.mu.Unlock() | ||
| 511 | 503 | ||||
| 512 | - | // Do not expose targetSess.client until the target coordinator has claimed | 504 | + | // Prepare source invalidation, output rebase, and destination coordinator |
| 513 | - | // this attachment and the output dependency chain has been rebased. | 505 | + | // identity before publishing the destination attachment. |
| 514 | cleanups := d.handoffCoordinator(from, targetSess, old, ac) | 506 | cleanups := d.handoffCoordinator(from, targetSess, old, ac) | ||
| 515 | ac.setSession(targetSess) | 507 | ac.setSession(targetSess) | ||
| 516 | targetSess.mu.Lock() | 508 | targetSess.mu.Lock() | ||
| @@ -518,24 +510,17 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 518 | targetSess.mu.Unlock() | 510 | targetSess.mu.Unlock() | ||
| 519 | d.touchMRU(targetSess) | 511 | d.touchMRU(targetSess) | ||
| 520 | ac.recordPreviousSession(from) | 512 | ac.recordPreviousSession(from) | ||
| 521 | - | d.mu.Unlock() | 513 | + | return targetSess, old, cleanups, true |
| 522 | - | finishRenderLifecycleCleanups(cleanups) | |||
| 523 | - | return old | |||
| 524 | } | 514 | } | ||
| 525 | 515 | ||||
| 526 | - | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool { | 516 | + | // resumeStoppedAndSwitchLocked creates the stopped representation and commits |
| 527 | - | d.mu.Lock() | 517 | + | // the handoff while d.mu is held. Creation failure leaves the source client |
| 528 | - | stopped, ok := d.stopped[target.Name] | 518 | + | // and stopped record untouched. |
| 529 | - | if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | 519 | + | func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool) { |
| 530 | - | d.mu.Unlock() | |||
| 531 | - | d.invalidateRender(from, ac, true, "picker.go") | |||
| 532 | - | return false | |||
| 533 | - | } | |||
| 534 | from.mu.Lock() | 520 | from.mu.Lock() | ||
| 535 | if from.client != ac { | 521 | if from.client != ac { | ||
| 536 | from.mu.Unlock() | 522 | from.mu.Unlock() | ||
| 537 | - | d.mu.Unlock() | 523 | + | return nil, nil, false |
| 538 | - | return false | |||
| 539 | } | 524 | } | ||
| 540 | term := from.terminal | 525 | term := from.terminal | ||
| 541 | env := copyEnvironment(from.env) | 526 | env := copyEnvironment(from.env) | ||
| @@ -543,10 +528,8 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 543 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames) | 528 | targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames) | ||
| 544 | if err != nil { | 529 | if err != nil { | ||
| 545 | from.mu.Unlock() | 530 | from.mu.Unlock() | ||
| 546 | - | d.mu.Unlock() | |||
| 547 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | 531 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | ||
| 548 | - | d.invalidateRender(from, ac, true, "picker.go") | 532 | + | return nil, nil, false |
| 549 | - | return false | |||
| 550 | } | 533 | } | ||
| 551 | from.client = nil | 534 | from.client = nil | ||
| 552 | ac.setSession(nil) | 535 | ac.setSession(nil) | ||
| @@ -559,7 +542,44 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 559 | targetSess.mu.Unlock() | 542 | targetSess.mu.Unlock() | ||
| 560 | d.touchMRU(targetSess) | 543 | d.touchMRU(targetSess) | ||
| 561 | ac.recordPreviousSession(from) | 544 | ac.recordPreviousSession(from) | ||
| 545 | + | return targetSess, cleanups, true | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool { | |||
| 549 | + | return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt) | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | // stealClientForTarget is retained for direct-ID callers and tests. Named | |||
| 553 | + | // targets must use switchToTarget so resolution and commit share d.mu. | |||
| 554 | + | func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) *attachedClient { | |||
| 555 | + | d.mu.Lock() | |||
| 556 | + | _, old, cleanups, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target) | |||
| 557 | + | d.mu.Unlock() | |||
| 558 | + | if !switched { | |||
| 559 | + | return nil | |||
| 560 | + | } | |||
| 561 | + | finishRenderLifecycleCleanups(cleanups) | |||
| 562 | + | return old | |||
| 563 | + | } | |||
| 564 | + | ||||
| 565 | + | // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves | |||
| 566 | + | // its stopped target and commits creation under one d.mu critical section. | |||
| 567 | + | func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool { | |||
| 568 | + | d.mu.Lock() | |||
| 569 | + | stopped, ok := d.stopped[target.Name] | |||
| 570 | + | var ( | |||
| 571 | + | targetSess *session | |||
| 572 | + | cleanups []renderLifecycleCleanup | |||
| 573 | + | switched bool | |||
| 574 | + | ) | |||
| 575 | + | if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) { | |||
| 576 | + | targetSess, cleanups, switched = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped) | |||
| 577 | + | } | |||
| 562 | d.mu.Unlock() | 578 | d.mu.Unlock() | ||
| 579 | + | if !switched { | |||
| 580 | + | d.invalidateRender(from, ac, true, "picker.go") | |||
| 581 | + | return false | |||
| 582 | + | } | |||
| 563 | finishRenderLifecycleCleanups(cleanups) | 583 | finishRenderLifecycleCleanups(cleanups) | ||
| 564 | d.firstPaint(targetSess, ac, ac.size) | 584 | d.firstPaint(targetSess, ac, ac.size) | ||
| 565 | return true | 585 | return true | ||
M internal/usecase/daemon/picker_test.go +33 -6
| @@ -67,6 +67,31 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 67 | 67 | require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs) | |
| 68 | 68 | require.Equal(t, "beta ", views[1].Name) | |
| 69 | 69 | require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs) | |
| 70 | + | ||
| 71 | + | model := picker.New(views, current.id, 0) | |
| 72 | + | model.Down() | |
| 73 | + | model.Down() | |
| 74 | + | target, ok := model.Selected() | |
| 75 | + | require.True(t, ok) | |
| 76 | + | require.Equal(t, "beta", target.Name) | |
| 77 | + | require.NotNil(t, target.ExpectedCreatedAt) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |
| 81 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 82 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 83 | + | defer cancel() | |
| 84 | + | active := &session{id: "active", name: "active", createdAt: 23, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 85 | + | d.sessions[active.id] = active | |
| 86 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24} | |
| 87 | + | ||
| 88 | + | views, _ := d.pickerViews(active) | |
| 89 | + | ||
| 90 | + | require.Len(t, views, 2) | |
| 91 | + | require.NotNil(t, views[0].ExpectedCreatedAt) | |
| 92 | + | require.Equal(t, int64(23), *views[0].ExpectedCreatedAt) | |
| 93 | + | require.NotNil(t, views[1].ExpectedCreatedAt) | |
| 94 | + | require.Equal(t, int64(24), *views[1].ExpectedCreatedAt) | |
| 70 | 95 | } | |
| 71 | 96 | ||
| 72 | 97 | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |
| @@ -403,6 +428,8 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||
| 403 | 428 | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac} | |
| 404 | 429 | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 405 | 430 | ac.setSession(sess1) | |
| 431 | + | d.sessions[sess1.id] = sess1 | |
| 432 | + | d.sessions[sess2.id] = sess2 | |
| 406 | 433 | ||
| 407 | 434 | sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching")) | |
| 408 | 435 | paintDone := make(chan struct{}) | |
| @@ -412,17 +439,17 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||
| 412 | 439 | }() | |
| 413 | 440 | <-enteredSend | |
| 414 | 441 | ||
| 442 | + | switchStarted := make(chan struct{}) | |
| 415 | 443 | switchDone := make(chan struct{}) | |
| 416 | 444 | go func() { | |
| 417 | - | ac.setSession(sess2) | |
| 445 | + | close(switchStarted) | |
| 446 | + | d.stealClientForTarget(sess1, ac, sess2, picker.Target{Session: sess2.id}) | |
| 418 | 447 | close(switchDone) | |
| 419 | 448 | }() | |
| 420 | - | select { | |
| 421 | - | case <-switchDone: | |
| 422 | - | t.Fatal("session switch completed while paint Send was still in flight") | |
| 423 | - | case <-time.After(50 * time.Millisecond): | |
| 424 | - | } | |
| 449 | + | <-switchStarted | |
| 425 | 450 | ||
| 451 | + | // enteredSend proves paint owns ac.sendMu. The real handoff also takes that | |
| 452 | + | // lock, so releasing the transport send is the deterministic handoff gate. | |
| 426 | 453 | close(releaseSend) | |
| 427 | 454 | select { | |
| 428 | 455 | case <-paintDone: | |
| @@ -67,6 +67,31 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||||
| 67 | require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs) | 67 | require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs) | ||
| 68 | require.Equal(t, "beta ", views[1].Name) | 68 | require.Equal(t, "beta ", views[1].Name) | ||
| 69 | require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs) | 69 | require.Equal(t, []picker.TabEntry{{Name: "shell"}, {Name: "logs", Attention: true}}, views[1].Tabs) | ||
| 70 | + | ||||
| 71 | + | model := picker.New(views, current.id, 0) | |||
| 72 | + | model.Down() | |||
| 73 | + | model.Down() | |||
| 74 | + | target, ok := model.Selected() | |||
| 75 | + | require.True(t, ok) | |||
| 76 | + | require.Equal(t, "beta", target.Name) | |||
| 77 | + | require.NotNil(t, target.ExpectedCreatedAt) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||
| 81 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 82 | + | ctx, cancel := context.WithCancel(d.serveCtx) | |||
| 83 | + | defer cancel() | |||
| 84 | + | active := &session{id: "active", name: "active", createdAt: 23, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |||
| 85 | + | d.sessions[active.id] = active | |||
| 86 | + | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24} | |||
| 87 | + | ||||
| 88 | + | views, _ := d.pickerViews(active) | |||
| 89 | + | ||||
| 90 | + | require.Len(t, views, 2) | |||
| 91 | + | require.NotNil(t, views[0].ExpectedCreatedAt) | |||
| 92 | + | require.Equal(t, int64(23), *views[0].ExpectedCreatedAt) | |||
| 93 | + | require.NotNil(t, views[1].ExpectedCreatedAt) | |||
| 94 | + | require.Equal(t, int64(24), *views[1].ExpectedCreatedAt) | |||
| 70 | } | 95 | } | ||
| 71 | 96 | ||||
| 72 | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | 97 | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | ||
| @@ -403,6 +428,8 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||||
| 403 | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac} | 428 | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac} | ||
| 404 | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | 429 | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | ||
| 405 | ac.setSession(sess1) | 430 | ac.setSession(sess1) | ||
| 431 | + | d.sessions[sess1.id] = sess1 | |||
| 432 | + | d.sessions[sess2.id] = sess2 | |||
| 406 | 433 | ||||
| 407 | sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching")) | 434 | sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching")) | ||
| 408 | paintDone := make(chan struct{}) | 435 | paintDone := make(chan struct{}) | ||
| @@ -412,17 +439,17 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||||
| 412 | }() | 439 | }() | ||
| 413 | <-enteredSend | 440 | <-enteredSend | ||
| 414 | 441 | ||||
| 442 | + | switchStarted := make(chan struct{}) | |||
| 415 | switchDone := make(chan struct{}) | 443 | switchDone := make(chan struct{}) | ||
| 416 | go func() { | 444 | go func() { | ||
| 417 | - | ac.setSession(sess2) | 445 | + | close(switchStarted) |
| 446 | + | d.stealClientForTarget(sess1, ac, sess2, picker.Target{Session: sess2.id}) | |||
| 418 | close(switchDone) | 447 | close(switchDone) | ||
| 419 | }() | 448 | }() | ||
| 420 | - | select { | 449 | + | <-switchStarted |
| 421 | - | case <-switchDone: | |||
| 422 | - | t.Fatal("session switch completed while paint Send was still in flight") | |||
| 423 | - | case <-time.After(50 * time.Millisecond): | |||
| 424 | - | } | |||
| 425 | 450 | ||||
| 451 | + | // enteredSend proves paint owns ac.sendMu. The real handoff also takes that | |||
| 452 | + | // lock, so releasing the transport send is the deterministic handoff gate. | |||
| 426 | close(releaseSend) | 453 | close(releaseSend) | ||
| 427 | select { | 454 | select { | ||
| 428 | case <-paintDone: | 455 | case <-paintDone: | ||
M internal/usecase/daemon/session.go +19 -12
| @@ -131,6 +131,25 @@ func (d *Daemon) touchMRU(sess *session) { | |||
| 131 | 131 | ||
| 132 | 132 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) { | |
| 133 | 133 | env = copyEnvironment(env) | |
| 134 | + | stopped, resuming := d.stopped[name] | |
| 135 | + | var createdAt int64 | |
| 136 | + | if !ephemeral { | |
| 137 | + | if resuming { | |
| 138 | + | // A stopped session is the same lifecycle when resumed; it must retain | |
| 139 | + | // the persisted identity rather than receive a fresh timestamp. | |
| 140 | + | createdAt = stopped.createdAt | |
| 141 | + | if createdAt > d.lastAllocatedCreatedAt { | |
| 142 | + | d.lastAllocatedCreatedAt = createdAt | |
| 143 | + | } | |
| 144 | + | } else { | |
| 145 | + | var err error | |
| 146 | + | createdAt, err = d.allocateLifecycleCreatedAtLocked() | |
| 147 | + | if err != nil { | |
| 148 | + | return nil, err | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 134 | 153 | tbSize := tabSize(sz) | |
| 135 | 154 | var names []string | |
| 136 | 155 | if len(restoredTabNames) > 0 { | |
| @@ -159,18 +178,6 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 159 | 178 | ||
| 160 | 179 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 161 | 180 | d.nextID++ | |
| 162 | - | stopped, resuming := d.stopped[name] | |
| 163 | - | var createdAt int64 | |
| 164 | - | if !ephemeral && resuming { | |
| 165 | - | // A stopped session is the same lifecycle when resumed; it must retain | |
| 166 | - | // the persisted identity rather than receive a fresh timestamp. | |
| 167 | - | createdAt = stopped.createdAt | |
| 168 | - | if createdAt > d.lastAllocatedCreatedAt { | |
| 169 | - | d.lastAllocatedCreatedAt = createdAt | |
| 170 | - | } | |
| 171 | - | } else { | |
| 172 | - | createdAt = d.allocateLifecycleCreatedAtLocked() | |
| 173 | - | } | |
| 174 | 181 | ||
| 175 | 182 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 176 | 183 | for _, tb := range tabs { | |
| @@ -131,6 +131,25 @@ func (d *Daemon) touchMRU(sess *session) { | |||||
| 131 | 131 | ||||
| 132 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) { | 132 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) { | ||
| 133 | env = copyEnvironment(env) | 133 | env = copyEnvironment(env) | ||
| 134 | + | stopped, resuming := d.stopped[name] | |||
| 135 | + | var createdAt int64 | |||
| 136 | + | if !ephemeral { | |||
| 137 | + | if resuming { | |||
| 138 | + | // A stopped session is the same lifecycle when resumed; it must retain | |||
| 139 | + | // the persisted identity rather than receive a fresh timestamp. | |||
| 140 | + | createdAt = stopped.createdAt | |||
| 141 | + | if createdAt > d.lastAllocatedCreatedAt { | |||
| 142 | + | d.lastAllocatedCreatedAt = createdAt | |||
| 143 | + | } | |||
| 144 | + | } else { | |||
| 145 | + | var err error | |||
| 146 | + | createdAt, err = d.allocateLifecycleCreatedAtLocked() | |||
| 147 | + | if err != nil { | |||
| 148 | + | return nil, err | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 134 | tbSize := tabSize(sz) | 153 | tbSize := tabSize(sz) | ||
| 135 | var names []string | 154 | var names []string | ||
| 136 | if len(restoredTabNames) > 0 { | 155 | if len(restoredTabNames) > 0 { | ||
| @@ -159,18 +178,6 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 159 | 178 | ||||
| 160 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | 179 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | ||
| 161 | d.nextID++ | 180 | d.nextID++ | ||
| 162 | - | stopped, resuming := d.stopped[name] | |||
| 163 | - | var createdAt int64 | |||
| 164 | - | if !ephemeral && resuming { | |||
| 165 | - | // A stopped session is the same lifecycle when resumed; it must retain | |||
| 166 | - | // the persisted identity rather than receive a fresh timestamp. | |||
| 167 | - | createdAt = stopped.createdAt | |||
| 168 | - | if createdAt > d.lastAllocatedCreatedAt { | |||
| 169 | - | d.lastAllocatedCreatedAt = createdAt | |||
| 170 | - | } | |||
| 171 | - | } else { | |||
| 172 | - | createdAt = d.allocateLifecycleCreatedAtLocked() | |||
| 173 | - | } | |||
| 174 | 181 | ||||
| 175 | sctx, cancel := context.WithCancel(d.serveCtx) | 182 | sctx, cancel := context.WithCancel(d.serveCtx) | ||
| 176 | for _, tb := range tabs { | 183 | for _, tb := range tabs { | ||
M internal/usecase/daemon/session_test.go +23 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "io" | |
| 6 | 6 | "log/slog" | |
| 7 | + | "math" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "sync" | |
| 9 | 10 | "sync/atomic" | |
| @@ -1533,6 +1534,28 @@ func TestNamedSessionLifecycleTimestampsAreMonotonicAcrossClockRegression(t *tes | |||
| 1533 | 1534 | require.Equal(t, []int64{100, 101, 102}, got) | |
| 1534 | 1535 | } | |
| 1535 | 1536 | ||
| 1537 | + | func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T) { | |
| 1538 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 1539 | + | d.lastAllocatedCreatedAt = math.MaxInt64 | |
| 1540 | + | d.nextID = 17 | |
| 1541 | + | d.stopped["retained"] = stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9} | |
| 1542 | + | ||
| 1543 | + | sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |
| 1544 | + | ||
| 1545 | + | require.ErrorContains(t, err, "lifecycle identities exhausted") | |
| 1546 | + | require.Nil(t, sess) | |
| 1547 | + | require.Empty(t, d.sessions) | |
| 1548 | + | require.Equal(t, uint64(17), d.nextID) | |
| 1549 | + | require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt) | |
| 1550 | + | require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"]) | |
| 1551 | + | ||
| 1552 | + | _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "routed", Size: domain.Size{Cols: 80, Rows: 24}}, nil) | |
| 1553 | + | require.ErrorContains(t, err, "lifecycle identities exhausted") | |
| 1554 | + | require.Empty(t, d.sessions) | |
| 1555 | + | require.Equal(t, uint64(17), d.nextID) | |
| 1556 | + | require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"]) | |
| 1557 | + | } | |
| 1558 | + | ||
| 1536 | 1559 | func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) { | |
| 1537 | 1560 | store, _ := newMockStore(t) | |
| 1538 | 1561 | seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store)) | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | "log/slog" | 6 | "log/slog" | ||
| 7 | + | "math" | |||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "sync" | 9 | "sync" | ||
| 9 | "sync/atomic" | 10 | "sync/atomic" | ||
| @@ -1533,6 +1534,28 @@ func TestNamedSessionLifecycleTimestampsAreMonotonicAcrossClockRegression(t *tes | |||||
| 1533 | require.Equal(t, []int64{100, 101, 102}, got) | 1534 | require.Equal(t, []int64{100, 101, 102}, got) | ||
| 1534 | } | 1535 | } | ||
| 1535 | 1536 | ||||
| 1537 | + | func TestNamedSessionLifecycleExhaustionDoesNotMutateSessionState(t *testing.T) { | |||
| 1538 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 1539 | + | d.lastAllocatedCreatedAt = math.MaxInt64 | |||
| 1540 | + | d.nextID = 17 | |||
| 1541 | + | d.stopped["retained"] = stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9} | |||
| 1542 | + | ||||
| 1543 | + | sess, err := d.createSessionLocked("new", false, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, d.baseEnv) | |||
| 1544 | + | ||||
| 1545 | + | require.ErrorContains(t, err, "lifecycle identities exhausted") | |||
| 1546 | + | require.Nil(t, sess) | |||
| 1547 | + | require.Empty(t, d.sessions) | |||
| 1548 | + | require.Equal(t, uint64(17), d.nextID) | |||
| 1549 | + | require.Equal(t, int64(math.MaxInt64), d.lastAllocatedCreatedAt) | |||
| 1550 | + | require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"]) | |||
| 1551 | + | ||||
| 1552 | + | _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "routed", Size: domain.Size{Cols: 80, Rows: 24}}, nil) | |||
| 1553 | + | require.ErrorContains(t, err, "lifecycle identities exhausted") | |||
| 1554 | + | require.Empty(t, d.sessions) | |||
| 1555 | + | require.Equal(t, uint64(17), d.nextID) | |||
| 1556 | + | require.Equal(t, stoppedSession{name: "retained", cwd: "/tmp", createdAt: 9}, d.stopped["retained"]) | |||
| 1557 | + | } | |||
| 1558 | + | ||||
| 1536 | func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) { | 1559 | func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) { | ||
| 1537 | store, _ := newMockStore(t) | 1560 | store, _ := newMockStore(t) | ||
| 1538 | seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store)) | 1561 | seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(store)) | ||
M internal/usecase/picker/model.go +17 -14
| @@ -16,11 +16,13 @@ const ( | |||
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | type SessionView struct { | |
| 19 | - | ID domain.SessionID | |
| 20 | - | Name string | |
| 21 | - | Tabs []TabEntry | |
| 22 | - | Active int | |
| 23 | - | Stopped bool | |
| 19 | + | ID domain.SessionID | |
| 20 | + | Name string | |
| 21 | + | TargetName string | |
| 22 | + | Tabs []TabEntry | |
| 23 | + | Active int | |
| 24 | + | Stopped bool | |
| 25 | + | ExpectedCreatedAt *int64 | |
| 24 | 26 | } | |
| 25 | 27 | ||
| 26 | 28 | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | |
| @@ -93,29 +95,30 @@ type row struct { | |||
| 93 | 95 | detail string // " (paneTitle)" segment, muted; "" for headers | |
| 94 | 96 | attention bool // draw the attention marker right after the name, before detail; tab rows only | |
| 95 | 97 | session domain.SessionID | |
| 96 | - | // targetName is the stopped-session lookup name threaded into Target; | |
| 98 | + | // targetName is the named-session lookup name threaded into Target; | |
| 97 | 99 | // distinct from dispName, which is what gets drawn. | |
| 98 | - | targetName string | |
| 99 | - | tabIndex int | |
| 100 | - | stopped bool | |
| 100 | + | targetName string | |
| 101 | + | tabIndex int | |
| 102 | + | stopped bool | |
| 103 | + | expectedCreatedAt *int64 | |
| 101 | 104 | } | |
| 102 | 105 | ||
| 103 | 106 | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | |
| 104 | 107 | m := &Model{selected: -1} | |
| 105 | 108 | activeSelection := -1 | |
| 106 | 109 | for _, session := range sessions { | |
| 107 | - | targetName := "" | |
| 108 | - | if session.Stopped { | |
| 110 | + | targetName := session.TargetName | |
| 111 | + | if targetName == "" && (session.Stopped || session.ExpectedCreatedAt != nil) { | |
| 109 | 112 | targetName = session.Name | |
| 110 | 113 | } | |
| 111 | - | m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped}) | |
| 114 | + | m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt}) | |
| 112 | 115 | active := session.Active | |
| 113 | 116 | if active < 0 || active >= len(session.Tabs) { | |
| 114 | 117 | active = 0 | |
| 115 | 118 | } | |
| 116 | 119 | for i, tab := range session.Tabs { | |
| 117 | 120 | idx := len(m.rows) | |
| 118 | - | m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped}) | |
| 121 | + | m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt}) | |
| 119 | 122 | if session.ID == cur && i == curTab { | |
| 120 | 123 | m.selected = idx | |
| 121 | 124 | } | |
| @@ -181,7 +184,7 @@ func (m *Model) Selected() (Target, bool) { | |||
| 181 | 184 | if r.header { | |
| 182 | 185 | return Target{}, false | |
| 183 | 186 | } | |
| 184 | - | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true | |
| 187 | + | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAt}, true | |
| 185 | 188 | } | |
| 186 | 189 | ||
| 187 | 190 | func (m *Model) Clone() *Model { | |
| @@ -16,11 +16,13 @@ const ( | |||||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | type SessionView struct { | 18 | type SessionView struct { | ||
| 19 | - | ID domain.SessionID | 19 | + | ID domain.SessionID |
| 20 | - | Name string | 20 | + | Name string |
| 21 | - | Tabs []TabEntry | 21 | + | TargetName string |
| 22 | - | Active int | 22 | + | Tabs []TabEntry |
| 23 | - | Stopped bool | 23 | + | Active int |
| 24 | + | Stopped bool | |||
| 25 | + | ExpectedCreatedAt *int64 | |||
| 24 | } | 26 | } | ||
| 25 | 27 | ||||
| 26 | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | 28 | // TabEntry is one tab row; Name is drawn emphasized, Detail muted. | ||
| @@ -93,29 +95,30 @@ type row struct { | |||||
| 93 | detail string // " (paneTitle)" segment, muted; "" for headers | 95 | detail string // " (paneTitle)" segment, muted; "" for headers | ||
| 94 | attention bool // draw the attention marker right after the name, before detail; tab rows only | 96 | attention bool // draw the attention marker right after the name, before detail; tab rows only | ||
| 95 | session domain.SessionID | 97 | session domain.SessionID | ||
| 96 | - | // targetName is the stopped-session lookup name threaded into Target; | 98 | + | // targetName is the named-session lookup name threaded into Target; |
| 97 | // distinct from dispName, which is what gets drawn. | 99 | // distinct from dispName, which is what gets drawn. | ||
| 98 | - | targetName string | 100 | + | targetName string |
| 99 | - | tabIndex int | 101 | + | tabIndex int |
| 100 | - | stopped bool | 102 | + | stopped bool |
| 103 | + | expectedCreatedAt *int64 | |||
| 101 | } | 104 | } | ||
| 102 | 105 | ||||
| 103 | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | 106 | func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model { | ||
| 104 | m := &Model{selected: -1} | 107 | m := &Model{selected: -1} | ||
| 105 | activeSelection := -1 | 108 | activeSelection := -1 | ||
| 106 | for _, session := range sessions { | 109 | for _, session := range sessions { | ||
| 107 | - | targetName := "" | 110 | + | targetName := session.TargetName |
| 108 | - | if session.Stopped { | 111 | + | if targetName == "" && (session.Stopped || session.ExpectedCreatedAt != nil) { |
| 109 | targetName = session.Name | 112 | targetName = session.Name | ||
| 110 | } | 113 | } | ||
| 111 | - | m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped}) | 114 | + | m.rows = append(m.rows, row{header: true, dispName: session.Name, session: session.ID, targetName: targetName, tabIndex: -1, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt}) |
| 112 | active := session.Active | 115 | active := session.Active | ||
| 113 | if active < 0 || active >= len(session.Tabs) { | 116 | if active < 0 || active >= len(session.Tabs) { | ||
| 114 | active = 0 | 117 | active = 0 | ||
| 115 | } | 118 | } | ||
| 116 | for i, tab := range session.Tabs { | 119 | for i, tab := range session.Tabs { | ||
| 117 | idx := len(m.rows) | 120 | idx := len(m.rows) | ||
| 118 | - | m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped}) | 121 | + | m.rows = append(m.rows, row{dispName: tab.Name, detail: tab.Detail, attention: tab.Attention, session: session.ID, targetName: targetName, tabIndex: i, stopped: session.Stopped, expectedCreatedAt: session.ExpectedCreatedAt}) |
| 119 | if session.ID == cur && i == curTab { | 122 | if session.ID == cur && i == curTab { | ||
| 120 | m.selected = idx | 123 | m.selected = idx | ||
| 121 | } | 124 | } | ||
| @@ -181,7 +184,7 @@ func (m *Model) Selected() (Target, bool) { | |||||
| 181 | if r.header { | 184 | if r.header { | ||
| 182 | return Target{}, false | 185 | return Target{}, false | ||
| 183 | } | 186 | } | ||
| 184 | - | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true | 187 | + | return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped, ExpectedCreatedAt: r.expectedCreatedAt}, true |
| 185 | } | 188 | } | ||
| 186 | 189 | ||||
| 187 | func (m *Model) Clone() *Model { | 190 | func (m *Model) Clone() *Model { | ||