detached 77cef84

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +225 -141
M internal/usecase/daemon/daemon.go +6 -2
@@ -5,6 +5,7 @@ import (
55 "context"
66 "errors"
77 "log/slog"
8+ "math"
89 "os"
910 "slices"
1011 "sort"
@@ -300,13 +301,16 @@ func WithConfig(cfg domain.Config) Option {
300301 // allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller
301302 // holds d.mu. The persisted high-water mark is loaded by New before any named
302303 // session can be resumed or created.
303-func (d *Daemon) allocateLifecycleCreatedAtLocked() int64 {
304+func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) {
304305 now := d.nowUnixNano()
305306 if now <= d.lastAllocatedCreatedAt {
307+ if d.lastAllocatedCreatedAt == math.MaxInt64 {
308+ return 0, errors.New("daemon: lifecycle identities exhausted")
309+ }
306310 now = d.lastAllocatedCreatedAt + 1
307311 }
308312 d.lastAllocatedCreatedAt = now
309- return now
313+ return now, nil
310314 }
311315
312316 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. Caller301 // allocateLifecycleCreatedAtLocked returns a unique lifecycle timestamp. Caller
301 // holds d.mu. The persisted high-water mark is loaded by New before any named302 // 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 + 1310 now = d.lastAllocatedCreatedAt + 1
307 }311 }
308 d.lastAllocatedCreatedAt = now312 d.lastAllocatedCreatedAt = now
309- return now313+ 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) {
4848 curTab := 0
4949 for _, s := range sessions {
5050 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+ }
5256 sessionAttention := false
5357 for i, tb := range s.tabs {
5458 name := tabDisplayName(tb, i)
@@ -71,11 +75,14 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
7175 views = append(views, view)
7276 }
7377 for _, s := range stopped {
78+ createdAt := s.createdAt
7479 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,
7986 })
8087 }
8188 return views, curTab
@@ -383,116 +390,102 @@ func (d *Daemon) sessionByID(id domain.SessionID) *session {
383390 return d.sessions[id]
384391 }
385392
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+ )
425404 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+ }
438416 }
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)
441419 }
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")
445424 return false
446425 }
426+ finishRenderLifecycleCleanups(cleanups)
447427 if old != nil && old != ac {
448428 d.unregisterPreview(old)
449429 old.clearPreviousSession()
450430 old.setSession(nil)
451431 d.notifyDetachedAsync(old, ports.ReasonDetach)
452432 }
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+ }
454439 return true
455440 }
456441
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
464448 }
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
469454 }
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
476460 }
477461
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) {
480465 if d.sessions[target.Session] != targetSess {
481- d.mu.Unlock()
482- return nil
466+ return nil, nil, nil, false
483467 }
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+
484478 targetSess.mu.Lock()
485479 matches := targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt)
486- targetSess.mu.Unlock()
487480 if !matches {
488- d.mu.Unlock()
489- return nil
481+ targetSess.mu.Unlock()
482+ return nil, nil, nil, false
490483 }
491484 from.mu.Lock()
492485 if from.client != ac {
493486 from.mu.Unlock()
494- d.mu.Unlock()
495- return nil
487+ targetSess.mu.Unlock()
488+ return nil, nil, nil, false
496489 }
497490 term := from.terminal
498491 env := copyEnvironment(from.env)
@@ -500,7 +493,6 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
500493 ac.setSession(nil)
501494 from.mu.Unlock()
502495
503- targetSess.mu.Lock()
504496 old := targetSess.client
505497 targetSess.terminal = term
506498 targetSess.env = copyEnvironment(env)
@@ -509,8 +501,8 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
509501 }
510502 targetSess.mu.Unlock()
511503
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.
514506 cleanups := d.handoffCoordinator(from, targetSess, old, ac)
515507 ac.setSession(targetSess)
516508 targetSess.mu.Lock()
@@ -518,24 +510,17 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
518510 targetSess.mu.Unlock()
519511 d.touchMRU(targetSess)
520512 ac.recordPreviousSession(from)
521- d.mu.Unlock()
522- finishRenderLifecycleCleanups(cleanups)
523- return old
513+ return targetSess, old, cleanups, true
524514 }
525515
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) {
534520 from.mu.Lock()
535521 if from.client != ac {
536522 from.mu.Unlock()
537- d.mu.Unlock()
538- return false
523+ return nil, nil, false
539524 }
540525 term := from.terminal
541526 env := copyEnvironment(from.env)
@@ -543,10 +528,8 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
543528 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)
544529 if err != nil {
545530 from.mu.Unlock()
546- d.mu.Unlock()
547531 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
550533 }
551534 from.client = nil
552535 ac.setSession(nil)
@@ -559,7 +542,44 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
559542 targetSess.mu.Unlock()
560543 d.touchMRU(targetSess)
561544 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+ }
562578 d.mu.Unlock()
579+ if !switched {
580+ d.invalidateRender(from, ac, true, "picker.go")
581+ return false
582+ }
563583 finishRenderLifecycleCleanups(cleanups)
564584 d.firstPaint(targetSess, ac, ac.size)
565585 return true
@@ -48,7 +48,11 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
48 curTab := 048 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 := false56 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, curTab88 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 true396+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. Resolve398+ 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 false401+ 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 = true406+ 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 false409+ } 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 switched418+ 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 false424 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 true439 return true
455 }440 }
456 441
457-// switchCurrentTarget validates and updates a same-session target as one d.mu442+// 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 false452+ active.mu.Unlock()
453+ return active, stoppedSession{}, false, matches
469 }454 }
470- sess.active = target.TabIndex455+ stopped, ok := d.stopped[target.Name]
471- return true456+ 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 nil482+ 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 nil488+ return nil, nil, nil, false
496 }489 }
497 term := from.terminal490 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.client496 old := targetSess.client
505 targetSess.terminal = term497 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 claimed504+ // 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.terminal525 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 = nil534 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 true585 return true
M internal/usecase/daemon/picker_test.go +33 -6
@@ -67,6 +67,31 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
6767 require.Equal(t, []picker.TabEntry{{Name: "1"}, {Name: "2"}}, views[0].Tabs)
6868 require.Equal(t, "beta ", views[1].Name)
6969 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)
7095 }
7196
7297 func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
@@ -403,6 +428,8 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
403428 sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac}
404429 sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
405430 ac.setSession(sess1)
431+ d.sessions[sess1.id] = sess1
432+ d.sessions[sess2.id] = sess2
406433
407434 sess1.tabs[0].focusedPane().screen.Write([]byte("paint while switching"))
408435 paintDone := make(chan struct{})
@@ -412,17 +439,17 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
412439 }()
413440 <-enteredSend
414441
442+ switchStarted := make(chan struct{})
415443 switchDone := make(chan struct{})
416444 go func() {
417- ac.setSession(sess2)
445+ close(switchStarted)
446+ d.stealClientForTarget(sess1, ac, sess2, picker.Target{Session: sess2.id})
418447 close(switchDone)
419448 }()
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
425450
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.
426453 close(releaseSend)
427454 select {
428455 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 <-enteredSend440 <-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) {
131131
132132 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
133133 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+
134153 tbSize := tabSize(sz)
135154 var names []string
136155 if len(restoredTabNames) > 0 {
@@ -159,18 +178,6 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
159178
160179 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
161180 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- }
174181
175182 sctx, cancel := context.WithCancel(d.serveCtx)
176183 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 []string154 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 (
44 "context"
55 "io"
66 "log/slog"
7+ "math"
78 "strings"
89 "sync"
910 "sync/atomic"
@@ -1533,6 +1534,28 @@ func TestNamedSessionLifecycleTimestampsAreMonotonicAcrossClockRegression(t *tes
15331534 require.Equal(t, []int64{100, 101, 102}, got)
15341535 }
15351536
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+
15361559 func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {
15371560 store, _ := newMockStore(t)
15381561 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 (
1616 )
1717
1818 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
2426 }
2527
2628 // TabEntry is one tab row; Name is drawn emphasized, Detail muted.
@@ -93,29 +95,30 @@ type row struct {
9395 detail string // " (paneTitle)" segment, muted; "" for headers
9496 attention bool // draw the attention marker right after the name, before detail; tab rows only
9597 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;
9799 // 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
101104 }
102105
103106 func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
104107 m := &Model{selected: -1}
105108 activeSelection := -1
106109 for _, session := range sessions {
107- targetName := ""
108- if session.Stopped {
110+ targetName := session.TargetName
111+ if targetName == "" && (session.Stopped || session.ExpectedCreatedAt != nil) {
109112 targetName = session.Name
110113 }
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})
112115 active := session.Active
113116 if active < 0 || active >= len(session.Tabs) {
114117 active = 0
115118 }
116119 for i, tab := range session.Tabs {
117120 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})
119122 if session.ID == cur && i == curTab {
120123 m.selected = idx
121124 }
@@ -181,7 +184,7 @@ func (m *Model) Selected() (Target, bool) {
181184 if r.header {
182185 return Target{}, false
183186 }
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
185188 }
186189
187190 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.SessionID19+ ID domain.SessionID
20- Name string20+ Name string
21- Tabs []TabEntry21+ TargetName string
22- Active int22+ Tabs []TabEntry
23- Stopped bool23+ 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 headers95 detail string // " (paneTitle)" segment, muted; "" for headers
94 attention bool // draw the attention marker right after the name, before detail; tab rows only96 attention bool // draw the attention marker right after the name, before detail; tab rows only
95 session domain.SessionID97 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 string100+ targetName string
99- tabIndex int101+ tabIndex int
100- stopped bool102+ 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 := -1108 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.Name112 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.Active115 active := session.Active
113 if active < 0 || active >= len(session.Tabs) {116 if active < 0 || active >= len(session.Tabs) {
114 active = 0117 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 = idx123 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{}, false185 return Target{}, false
183 }186 }
184- return Target{Session: r.session, Name: r.targetName, TabIndex: r.tabIndex, Stopped: r.stopped}, true187+ 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close