detached 951a71d

refactor(daemon): generalize attachment session core

commit
951a71d01e9026c87eb4d13878bdd2cfc59e6494
author
brice · · 2026-07-30 14:32 UTC
committer
brice · · 2026-07-30 23:38 UTC
parent
4d54d83
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
83 files · +1083 -620
M internal/usecase/daemon/attachment_role.go +60 -37
@@ -12,42 +12,56 @@ const (
1212
1313 // attachmentRole derives the attachment's role from the session-owned
1414 // registries. There is deliberately no second role field that could drift.
15-func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
16- if s == nil || ac == nil {
15+func attachmentSessionRole(entry attachmentSession, ac *attachedClient) attachmentRole {
16+ if entry == nil || ac == nil || entry.core() == nil {
1717 return attachmentDetached
1818 }
19- s.mu.Lock()
20- defer s.mu.Unlock()
21- return s.attachmentRoleLocked(ac)
19+ core := entry.core()
20+ core.mu.Lock()
21+ defer core.mu.Unlock()
22+ return attachmentSessionRoleLocked(entry, ac)
2223 }
2324
24-// attachmentRoleLocked requires s.mu. Active membership wins if an invalid
25-// intermediate state contains the same attachment in both registries.
26-func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole {
27- if ac == nil {
25+// attachmentSessionRoleLocked requires entry.core().mu. Active membership wins
26+// if an invalid intermediate state contains the same attachment in both
27+// registries.
28+func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) attachmentRole {
29+ if entry == nil || ac == nil || entry.core() == nil {
2830 return attachmentDetached
2931 }
30- if s.client == ac {
32+ core := entry.core()
33+ if core.client == ac {
3134 return attachmentActive
3235 }
33- if _, ok := s.snatched[ac]; ok {
36+ if _, ok := core.snatched[ac]; ok {
3437 return attachmentSnatched
3538 }
3639 return attachmentDetached
3740 }
3841
39-func (s *session) addSnatchedLocked(ac *attachedClient) {
40- if ac == nil {
42+func addSnatchedLocked(entry attachmentSession, ac *attachedClient) {
43+ if entry == nil || ac == nil || entry.core() == nil {
4144 return
4245 }
43- if s.snatched == nil {
44- s.snatched = make(map[*attachedClient]struct{})
46+ core := entry.core()
47+ if core.snatched == nil {
48+ core.snatched = make(map[*attachedClient]struct{})
4549 }
46- s.snatched[ac] = struct{}{}
50+ core.snatched[ac] = struct{}{}
51+}
52+
53+func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
54+ return attachmentSessionRole(s, ac)
4755 }
4856
57+func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole {
58+ return attachmentSessionRoleLocked(s, ac)
59+}
60+
61+func (s *session) addSnatchedLocked(ac *attachedClient) { addSnatchedLocked(s, ac) }
62+
4963 type attachmentRoleToken struct {
50- sess *session
64+ sess attachmentSession
5165 ac *attachedClient
5266 role attachmentRole
5367 generation uint64
@@ -64,8 +78,8 @@ type attachmentRoleToken struct {
6478 // active coordinator lease at an architecture commit point. The caller must
6579 // hold s.mu; coordinator acquisition follows the canonical session ->
6680 // coordinator order.
67-func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken {
68- if s == nil || ac == nil {
81+func attachmentTokenLocked(entry attachmentSession, ac *attachedClient) attachmentRoleToken {
82+ if entry == nil || entry.core() == nil || ac == nil {
6983 return attachmentRoleToken{}
7084 }
7185 transport := ac.transportSnapshot()
@@ -73,40 +87,45 @@ func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken
7387 return attachmentRoleToken{}
7488 }
7589 token := attachmentRoleToken{
76- sess: s,
90+ sess: entry,
7791 ac: ac,
78- role: s.attachmentRoleLocked(ac),
92+ role: attachmentSessionRoleLocked(entry, ac),
7993 generation: ac.roleGeneration.Load(),
8094 transport: transport,
8195 }
8296 if token.role == attachmentActive {
83- if rc := s.renderCoordinator(); rc != nil {
97+ if rc := entry.core().coordinator.Load(); rc != nil {
8498 token.lease = rc.attachmentLease(ac)
8599 }
86100 }
87101 return token
88102 }
89103
90-func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {
91- if s == nil || ac == nil || tr == nil {
104+func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken {
105+ return attachmentTokenLocked(s, ac)
106+}
107+
108+func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentRoleToken {
109+ if entry == nil || entry.core() == nil || ac == nil || tr == nil {
92110 return attachmentRoleToken{}
93111 }
94- s.mu.Lock()
112+ core := entry.core()
113+ core.mu.Lock()
95114 transport := ac.transportSnapshot()
96115 if transport.transport != tr {
97- s.mu.Unlock()
116+ core.mu.Unlock()
98117 return attachmentRoleToken{}
99118 }
100119 token := attachmentRoleToken{
101- sess: s,
120+ sess: entry,
102121 ac: ac,
103- role: s.attachmentRoleLocked(ac),
122+ role: attachmentSessionRoleLocked(entry, ac),
104123 generation: ac.roleGeneration.Load(),
105124 transport: transport,
106125 }
107- s.mu.Unlock()
126+ core.mu.Unlock()
108127 if token.role == attachmentActive {
109- if rc := s.renderCoordinator(); rc != nil {
128+ if rc := core.coordinator.Load(); rc != nil {
110129 token.lease = rc.attachmentLease(ac)
111130 }
112131 }
@@ -114,20 +133,24 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
114133 return token
115134 }
116135
136+func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {
137+ return attachmentToken(s, ac, tr)
138+}
139+
117140 func (t attachmentRoleToken) current() bool {
118141 return t.sess != nil && t.ac != nil &&
119142 t.ac.roleGeneration.Load() == t.generation &&
120143 t.ac.transportSnapshotCurrent(t.transport) &&
121- t.sess.attachmentRole(t.ac) == t.role
144+ attachmentSessionRole(t.sess, t.ac) == t.role
122145 }
123146
124147 // activeCurrent admits post-transition effects only for the exact published
125148 // role, transport incarnation, current-session link, and coordinator lease.
126149 func (t attachmentRoleToken) activeCurrent() bool {
127- if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess {
150+ if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentAttachmentSession() != t.sess {
128151 return false
129152 }
130- rc := t.sess.renderCoordinator()
153+ rc := t.sess.core().coordinator.Load()
131154 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
132155 }
133156
@@ -141,11 +164,11 @@ func (t attachmentRoleToken) activeEffect() bool {
141164 return t.activeCurrent()
142165 }
143166
144-func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) {
167+func beginActiveLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) {
145168 if sess == nil || ac == nil || lease == nil {
146169 return nil, false
147170 }
148- token := sess.attachmentToken(ac, ac.transport())
171+ token := attachmentToken(sess, ac, ac.transport())
149172 token.lease = lease
150173 return ac.beginRoleEffect(token)
151174 }
@@ -166,10 +189,10 @@ func (t attachmentRoleToken) activeEffectSessionLocked() bool {
166189 }
167190 if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil ||
168191 t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) ||
169- t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive {
192+ t.ac.currentAttachmentSession() != t.sess || attachmentSessionRoleLocked(t.sess, t.ac) != attachmentActive {
170193 return false
171194 }
172- rc := t.sess.renderCoordinator()
195+ rc := t.sess.core().coordinator.Load()
173196 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
174197 }
175198
@@ -12,42 +12,56 @@ const (
12 12
13 // attachmentRole derives the attachment's role from the session-owned13 // attachmentRole derives the attachment's role from the session-owned
14 // registries. There is deliberately no second role field that could drift.14 // registries. There is deliberately no second role field that could drift.
15-func (s *session) attachmentRole(ac *attachedClient) attachmentRole {15+func attachmentSessionRole(entry attachmentSession, ac *attachedClient) attachmentRole {
16- if s == nil || ac == nil {16+ if entry == nil || ac == nil || entry.core() == nil {
17 return attachmentDetached17 return attachmentDetached
18 }18 }
19- s.mu.Lock()19+ core := entry.core()
20- defer s.mu.Unlock()20+ core.mu.Lock()
21- return s.attachmentRoleLocked(ac)21+ defer core.mu.Unlock()
22+ return attachmentSessionRoleLocked(entry, ac)
22 }23 }
23 24
24-// attachmentRoleLocked requires s.mu. Active membership wins if an invalid25+// attachmentSessionRoleLocked requires entry.core().mu. Active membership wins
25-// intermediate state contains the same attachment in both registries.26+// if an invalid intermediate state contains the same attachment in both
26-func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole {27+// registries.
27- if ac == nil {28+func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) attachmentRole {
29+ if entry == nil || ac == nil || entry.core() == nil {
28 return attachmentDetached30 return attachmentDetached
29 }31 }
30- if s.client == ac {32+ core := entry.core()
33+ if core.client == ac {
31 return attachmentActive34 return attachmentActive
32 }35 }
33- if _, ok := s.snatched[ac]; ok {36+ if _, ok := core.snatched[ac]; ok {
34 return attachmentSnatched37 return attachmentSnatched
35 }38 }
36 return attachmentDetached39 return attachmentDetached
37 }40 }
38 41
39-func (s *session) addSnatchedLocked(ac *attachedClient) {42+func addSnatchedLocked(entry attachmentSession, ac *attachedClient) {
40- if ac == nil {43+ if entry == nil || ac == nil || entry.core() == nil {
41 return44 return
42 }45 }
43- if s.snatched == nil {46+ core := entry.core()
44- s.snatched = make(map[*attachedClient]struct{})47+ if core.snatched == nil {
48+ core.snatched = make(map[*attachedClient]struct{})
45 }49 }
46- s.snatched[ac] = struct{}{}50+ core.snatched[ac] = struct{}{}
51+}
52+
53+func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
54+ return attachmentSessionRole(s, ac)
47 }55 }
48 56
57+func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole {
58+ return attachmentSessionRoleLocked(s, ac)
59+}
60+
61+func (s *session) addSnatchedLocked(ac *attachedClient) { addSnatchedLocked(s, ac) }
62+
49 type attachmentRoleToken struct {63 type attachmentRoleToken struct {
50- sess *session64+ sess attachmentSession
51 ac *attachedClient65 ac *attachedClient
52 role attachmentRole66 role attachmentRole
53 generation uint6467 generation uint64
@@ -64,8 +78,8 @@ type attachmentRoleToken struct {
64 // active coordinator lease at an architecture commit point. The caller must78 // active coordinator lease at an architecture commit point. The caller must
65 // hold s.mu; coordinator acquisition follows the canonical session ->79 // hold s.mu; coordinator acquisition follows the canonical session ->
66 // coordinator order.80 // coordinator order.
67-func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken {81+func attachmentTokenLocked(entry attachmentSession, ac *attachedClient) attachmentRoleToken {
68- if s == nil || ac == nil {82+ if entry == nil || entry.core() == nil || ac == nil {
69 return attachmentRoleToken{}83 return attachmentRoleToken{}
70 }84 }
71 transport := ac.transportSnapshot()85 transport := ac.transportSnapshot()
@@ -73,40 +87,45 @@ func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken
73 return attachmentRoleToken{}87 return attachmentRoleToken{}
74 }88 }
75 token := attachmentRoleToken{89 token := attachmentRoleToken{
76- sess: s,90+ sess: entry,
77 ac: ac,91 ac: ac,
78- role: s.attachmentRoleLocked(ac),92+ role: attachmentSessionRoleLocked(entry, ac),
79 generation: ac.roleGeneration.Load(),93 generation: ac.roleGeneration.Load(),
80 transport: transport,94 transport: transport,
81 }95 }
82 if token.role == attachmentActive {96 if token.role == attachmentActive {
83- if rc := s.renderCoordinator(); rc != nil {97+ if rc := entry.core().coordinator.Load(); rc != nil {
84 token.lease = rc.attachmentLease(ac)98 token.lease = rc.attachmentLease(ac)
85 }99 }
86 }100 }
87 return token101 return token
88 }102 }
89 103
90-func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {104+func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken {
91- if s == nil || ac == nil || tr == nil {105+ return attachmentTokenLocked(s, ac)
106+}
107+
108+func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentRoleToken {
109+ if entry == nil || entry.core() == nil || ac == nil || tr == nil {
92 return attachmentRoleToken{}110 return attachmentRoleToken{}
93 }111 }
94- s.mu.Lock()112+ core := entry.core()
113+ core.mu.Lock()
95 transport := ac.transportSnapshot()114 transport := ac.transportSnapshot()
96 if transport.transport != tr {115 if transport.transport != tr {
97- s.mu.Unlock()116+ core.mu.Unlock()
98 return attachmentRoleToken{}117 return attachmentRoleToken{}
99 }118 }
100 token := attachmentRoleToken{119 token := attachmentRoleToken{
101- sess: s,120+ sess: entry,
102 ac: ac,121 ac: ac,
103- role: s.attachmentRoleLocked(ac),122+ role: attachmentSessionRoleLocked(entry, ac),
104 generation: ac.roleGeneration.Load(),123 generation: ac.roleGeneration.Load(),
105 transport: transport,124 transport: transport,
106 }125 }
107- s.mu.Unlock()126+ core.mu.Unlock()
108 if token.role == attachmentActive {127 if token.role == attachmentActive {
109- if rc := s.renderCoordinator(); rc != nil {128+ if rc := core.coordinator.Load(); rc != nil {
110 token.lease = rc.attachmentLease(ac)129 token.lease = rc.attachmentLease(ac)
111 }130 }
112 }131 }
@@ -114,20 +133,24 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach
114 return token133 return token
115 }134 }
116 135
136+func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {
137+ return attachmentToken(s, ac, tr)
138+}
139+
117 func (t attachmentRoleToken) current() bool {140 func (t attachmentRoleToken) current() bool {
118 return t.sess != nil && t.ac != nil &&141 return t.sess != nil && t.ac != nil &&
119 t.ac.roleGeneration.Load() == t.generation &&142 t.ac.roleGeneration.Load() == t.generation &&
120 t.ac.transportSnapshotCurrent(t.transport) &&143 t.ac.transportSnapshotCurrent(t.transport) &&
121- t.sess.attachmentRole(t.ac) == t.role144+ attachmentSessionRole(t.sess, t.ac) == t.role
122 }145 }
123 146
124 // activeCurrent admits post-transition effects only for the exact published147 // activeCurrent admits post-transition effects only for the exact published
125 // role, transport incarnation, current-session link, and coordinator lease.148 // role, transport incarnation, current-session link, and coordinator lease.
126 func (t attachmentRoleToken) activeCurrent() bool {149 func (t attachmentRoleToken) activeCurrent() bool {
127- if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess {150+ if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentAttachmentSession() != t.sess {
128 return false151 return false
129 }152 }
130- rc := t.sess.renderCoordinator()153+ rc := t.sess.core().coordinator.Load()
131 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)154 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
132 }155 }
133 156
@@ -141,11 +164,11 @@ func (t attachmentRoleToken) activeEffect() bool {
141 return t.activeCurrent()164 return t.activeCurrent()
142 }165 }
143 166
144-func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) {167+func beginActiveLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) {
145 if sess == nil || ac == nil || lease == nil {168 if sess == nil || ac == nil || lease == nil {
146 return nil, false169 return nil, false
147 }170 }
148- token := sess.attachmentToken(ac, ac.transport())171+ token := attachmentToken(sess, ac, ac.transport())
149 token.lease = lease172 token.lease = lease
150 return ac.beginRoleEffect(token)173 return ac.beginRoleEffect(token)
151 }174 }
@@ -166,10 +189,10 @@ func (t attachmentRoleToken) activeEffectSessionLocked() bool {
166 }189 }
167 if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil ||190 if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil ||
168 t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) ||191 t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) ||
169- t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive {192+ t.ac.currentAttachmentSession() != t.sess || attachmentSessionRoleLocked(t.sess, t.ac) != attachmentActive {
170 return false193 return false
171 }194 }
172- rc := t.sess.renderCoordinator()195+ rc := t.sess.core().coordinator.Load()
173 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)196 return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
174 }197 }
175 198
M internal/usecase/daemon/attachment_transition.go +13 -11
@@ -5,8 +5,8 @@ import "errors"
55 var errAttachmentTransition = errors.New("attachment transition is no longer valid")
66
77 type attachmentTransitionRequest struct {
8- source *session
9- target *session
8+ source attachmentSession
9+ target attachmentSession
1010 next *attachedClient
1111 expectedRole attachmentRole
1212 targetRole attachmentRole
@@ -38,7 +38,7 @@ type attachmentTransitionRequest struct {
3838 activationBarrier bool
3939 }
4040
41-func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool {
41+func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source attachmentSession, req attachmentTransitionRequest) bool {
4242 return token.sess == source && token.ac == req.next && token.role == req.expectedRole &&
4343 token.transport.transport == req.expectedTransport.transport &&
4444 token.transport.incarnation == req.expectedTransport.incarnation
@@ -48,10 +48,10 @@ func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *sess
4848 // source, sourceCoordinator.mu. This is the canonical handoff preflight: every
4949 // client-originated navigation, delete, or create intent is bound to one exact
5050 // role generation, transport incarnation, and coordinator lease.
51-func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
51+func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
5252 if token.sess != source || token.ac != req.next || token.role != req.expectedRole ||
53- token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role ||
54- req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {
53+ token.generation != req.next.roleGeneration.Load() || attachmentSessionRoleLocked(source, req.next) != token.role ||
54+ req.next.currentAttachmentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {
5555 return false
5656 }
5757 if token.role != attachmentActive {
@@ -85,18 +85,18 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
8585 source = req.target
8686 }
8787 if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||
88- d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target ||
88+ d.sessions[source.core().id] != source || req.target != nil && d.sessions[req.target.core().id] != req.target ||
8989 req.activationBarrier && (req.sourceToken == nil || source != req.target ||
9090 req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {
9191 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
9292 }
9393 if req.target != nil {
94- req.target.mu.Lock()
95- req.expectedTargetCurrent = req.target.client
94+ req.target.core().mu.Lock()
95+ req.expectedTargetCurrent = req.target.core().client
9696 if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {
9797 req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()
9898 }
99- req.target.mu.Unlock()
99+ req.target.core().mu.Unlock()
100100 }
101101 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
102102 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
@@ -174,7 +174,9 @@ func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult)
174174 ac.output.rebase()
175175 }
176176 ac.captureFrames = nil
177- d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false)
177+ if sess, ok := localSession(result.published.sess); ok {
178+ d.applyHostThemeSendLocked(sess, ac, ac.getClientTheme(), false)
179+ }
178180 // The first paint still requests a reset, but the old panel dependency chain
179181 // has already been rebased atomically under the activation barrier.
180182 result.published.rebase = false
@@ -5,8 +5,8 @@ import "errors"
5 var errAttachmentTransition = errors.New("attachment transition is no longer valid")5 var errAttachmentTransition = errors.New("attachment transition is no longer valid")
6 6
7 type attachmentTransitionRequest struct {7 type attachmentTransitionRequest struct {
8- source *session8+ source attachmentSession
9- target *session9+ target attachmentSession
10 next *attachedClient10 next *attachedClient
11 expectedRole attachmentRole11 expectedRole attachmentRole
12 targetRole attachmentRole12 targetRole attachmentRole
@@ -38,7 +38,7 @@ type attachmentTransitionRequest struct {
38 activationBarrier bool38 activationBarrier bool
39 }39 }
40 40
41-func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool {41+func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source attachmentSession, req attachmentTransitionRequest) bool {
42 return token.sess == source && token.ac == req.next && token.role == req.expectedRole &&42 return token.sess == source && token.ac == req.next && token.role == req.expectedRole &&
43 token.transport.transport == req.expectedTransport.transport &&43 token.transport.transport == req.expectedTransport.transport &&
44 token.transport.incarnation == req.expectedTransport.incarnation44 token.transport.incarnation == req.expectedTransport.incarnation
@@ -48,10 +48,10 @@ func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *sess
48 // source, sourceCoordinator.mu. This is the canonical handoff preflight: every48 // source, sourceCoordinator.mu. This is the canonical handoff preflight: every
49 // client-originated navigation, delete, or create intent is bound to one exact49 // client-originated navigation, delete, or create intent is bound to one exact
50 // role generation, transport incarnation, and coordinator lease.50 // role generation, transport incarnation, and coordinator lease.
51-func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {51+func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
52 if token.sess != source || token.ac != req.next || token.role != req.expectedRole ||52 if token.sess != source || token.ac != req.next || token.role != req.expectedRole ||
53- token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role ||53+ token.generation != req.next.roleGeneration.Load() || attachmentSessionRoleLocked(source, req.next) != token.role ||
54- req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {54+ req.next.currentAttachmentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {
55 return false55 return false
56 }56 }
57 if token.role != attachmentActive {57 if token.role != attachmentActive {
@@ -85,18 +85,18 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
85 source = req.target85 source = req.target
86 }86 }
87 if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||87 if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing ||
88- d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target ||88+ d.sessions[source.core().id] != source || req.target != nil && d.sessions[req.target.core().id] != req.target ||
89 req.activationBarrier && (req.sourceToken == nil || source != req.target ||89 req.activationBarrier && (req.sourceToken == nil || source != req.target ||
90 req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {90 req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) {
91 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition91 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
92 }92 }
93 if req.target != nil {93 if req.target != nil {
94- req.target.mu.Lock()94+ req.target.core().mu.Lock()
95- req.expectedTargetCurrent = req.target.client95+ req.expectedTargetCurrent = req.target.core().client
96 if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {96 if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive {
97 req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()97 req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot()
98 }98 }
99- req.target.mu.Unlock()99+ req.target.core().mu.Unlock()
100 }100 }
101 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {101 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
102 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition102 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
@@ -174,7 +174,9 @@ func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult)
174 ac.output.rebase()174 ac.output.rebase()
175 }175 }
176 ac.captureFrames = nil176 ac.captureFrames = nil
177- d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false)177+ if sess, ok := localSession(result.published.sess); ok {
178+ d.applyHostThemeSendLocked(sess, ac, ac.getClientTheme(), false)
179+ }
178 // The first paint still requests a reset, but the old panel dependency chain180 // The first paint still requests a reset, but the old panel dependency chain
179 // has already been rebased atomically under the activation barrier.181 // has already been rebased atomically under the activation barrier.
180 result.published.rebase = false182 result.published.rebase = false
M internal/usecase/daemon/attachment_transition_prelocked_test.go +3 -3
@@ -25,7 +25,7 @@ func TestAttachmentTransitionPrelockedMatchesWrapper(t *testing.T) {
2525 setup := func(t *testing.T) (*Daemon, *session, *attachedClient, *attachedClient) {
2626 t.Helper()
2727 d := newTestDaemon(t, nil, stubClock{})
28- sess := &session{id: domain.SessionID("work")}
28+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
2929 old := &attachedClient{tr: &closeTrackingTransport{}}
3030 old.setSession(sess)
3131 sess.client = old
@@ -120,8 +120,8 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure
120120 t.Parallel()
121121
122122 d := newTestDaemon(t, nil, stubClock{})
123- source := &session{id: domain.SessionID("source")}
124- target := &session{id: domain.SessionID("target"), active: 0}
123+ source := &session{sessionCore: sessionCore{id: domain.SessionID("source")}}
124+ target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}, active: 0}
125125 next := &attachedClient{tr: &closeTrackingTransport{}}
126126 next.setSession(source)
127127 source.addSnatchedLocked(next)
@@ -25,7 +25,7 @@ func TestAttachmentTransitionPrelockedMatchesWrapper(t *testing.T) {
25 setup := func(t *testing.T) (*Daemon, *session, *attachedClient, *attachedClient) {25 setup := func(t *testing.T) (*Daemon, *session, *attachedClient, *attachedClient) {
26 t.Helper()26 t.Helper()
27 d := newTestDaemon(t, nil, stubClock{})27 d := newTestDaemon(t, nil, stubClock{})
28- sess := &session{id: domain.SessionID("work")}28+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
29 old := &attachedClient{tr: &closeTrackingTransport{}}29 old := &attachedClient{tr: &closeTrackingTransport{}}
30 old.setSession(sess)30 old.setSession(sess)
31 sess.client = old31 sess.client = old
@@ -120,8 +120,8 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure
120 t.Parallel()120 t.Parallel()
121 121
122 d := newTestDaemon(t, nil, stubClock{})122 d := newTestDaemon(t, nil, stubClock{})
123- source := &session{id: domain.SessionID("source")}123+ source := &session{sessionCore: sessionCore{id: domain.SessionID("source")}}
124- target := &session{id: domain.SessionID("target"), active: 0}124+ target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}, active: 0}
125 next := &attachedClient{tr: &closeTrackingTransport{}}125 next := &attachedClient{tr: &closeTrackingTransport{}}
126 next.setSession(source)126 next.setSession(source)
127 source.addSnatchedLocked(next)127 source.addSnatchedLocked(next)
M internal/usecase/daemon/attachment_transition_publish.go +47 -41
@@ -4,14 +4,14 @@ package daemon
44 // is frozen and d.mu is held. No caller may create or delete a target before
55 // this succeeds.
66 func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {
7- if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source {
7+ if req.sourceToken == nil || req.source == nil || req.source.core() == nil || d.sessions[req.source.core().id] != req.source {
88 return false
99 }
1010 d.notices.routingMu.Lock()
1111 defer d.notices.routingMu.Unlock()
12- req.source.mu.Lock()
13- defer req.source.mu.Unlock()
14- coordinator := req.source.renderCoordinator()
12+ req.source.core().mu.Lock()
13+ defer req.source.core().mu.Unlock()
14+ coordinator := req.source.core().coordinator.Load()
1515 if coordinator == nil {
1616 return false
1717 }
@@ -29,14 +29,14 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
2929 }
3030 d.mu.Lock()
3131 defer d.mu.Unlock()
32- if d.closing || d.sessions[token.sess.id] != token.sess {
32+ if d.closing || token.sess.core() == nil || d.sessions[token.sess.core().id] != token.sess {
3333 return false
3434 }
3535 d.notices.routingMu.Lock()
3636 defer d.notices.routingMu.Unlock()
37- token.sess.mu.Lock()
38- defer token.sess.mu.Unlock()
39- coordinator := token.sess.renderCoordinator()
37+ token.sess.core().mu.Lock()
38+ defer token.sess.core().mu.Unlock()
39+ coordinator := token.sess.core().coordinator.Load()
4040 if coordinator == nil {
4141 return false
4242 }
@@ -49,15 +49,16 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
4949 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
5050 }
5151
52-func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
52+func transitionSourceTabCurrentLocked(source attachmentSession, expected *tab) bool {
5353 return transitionSourceTabCurrentForRequestLocked(source, expected, false)
5454 }
5555
56-func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool {
56+func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expected *tab, transferExpected bool) bool {
5757 if expected == nil {
5858 return true
5959 }
60- if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected {
60+ local, ok := localSession(source)
61+ if !ok || local.active < 0 || local.active >= len(local.tabs) || local.tabs[local.active] != expected {
6162 return false
6263 }
6364 expected.mu.Lock()
@@ -78,7 +79,7 @@ func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (at
7879 // own d.mu, notices.routingMu, and the ordered source/target session locks.
7980 type attachmentPublication struct {
8081 req attachmentTransitionRequest
81- source *session
82+ source attachmentSession
8283 old *attachedClient
8384 sourceCoordinator *renderCoordinator
8485 targetCoordinator *renderCoordinator
@@ -105,7 +106,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
105106 if !req.preflighted || !req.roleEffectsFrozen ||
106107 !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
107108 source == nil || req.target == nil || req.next == nil || d.closing ||
108- d.sessions[source.id] != source || d.sessions[req.target.id] != req.target ||
109+ d.sessions[source.core().id] != source || d.sessions[req.target.core().id] != req.target ||
109110 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
110111 req.preserveRole && req.sourceToken == nil {
111112 return nil, errAttachmentTransition
@@ -113,15 +114,17 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
113114
114115 var targetCoordinator *renderCoordinator
115116 if req.targetRole == attachmentActive {
116- targetCoordinator = d.ensureRenderCoordinatorPrelocked(req.target)
117+ if target, ok := localSession(req.target); ok {
118+ targetCoordinator = d.ensureRenderCoordinatorPrelocked(target)
119+ }
117120 }
118- sourceCoordinator := source.renderCoordinator()
119- if source.attachmentRoleLocked(req.next) != req.expectedRole ||
121+ sourceCoordinator := source.core().coordinator.Load()
122+ if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||
120123 !req.next.transportSnapshotCurrent(req.expectedTransport) {
121124 return nil, errAttachmentTransition
122125 }
123126
124- old := req.target.client
127+ old := req.target.core().client
125128 if old != req.expectedTargetCurrent || source != req.target && old == req.next {
126129 return nil, errAttachmentTransition
127130 }
@@ -135,7 +138,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
135138 invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
136139 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
137140 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
138- invalid = invalid || req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs))
141+ invalid = invalid || req.activateTargetTab && !req.target.activateTargetLocked(req.targetTabIndex)
139142 if invalid {
140143 publication.unlockCoordinators()
141144 return nil, errAttachmentTransition
@@ -148,14 +151,17 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
148151 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
149152 req := publication.req
150153 if req.targetRole == attachmentActive {
151- d.demoteParkedActiveForSessionLocked(req.target)
154+ if target, ok := localSession(req.target); ok {
155+ d.demoteParkedActiveForSessionLocked(target)
156+ }
152157 }
153158 if req.copySourceEnvironment {
154- req.target.terminal = publication.source.terminal
155- req.target.env = copyEnvironment(publication.source.env)
156- }
157- if req.activateTargetTab {
158- req.target.active = req.targetTabIndex
159+ source, sourceOK := localSession(publication.source)
160+ target, targetOK := localSession(req.target)
161+ if sourceOK && targetOK {
162+ target.terminal = source.terminal
163+ target.env = copyEnvironment(source.env)
164+ }
159165 }
160166 return req.preserveRole
161167 }
@@ -168,19 +174,19 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
168174 publication.displacedTransport = publication.old.transportSnapshot()
169175 }
170176 if publication.source != req.target {
171- if publication.source.client == req.next {
172- publication.source.client = nil
177+ if publication.source.core().client == req.next {
178+ publication.source.core().client = nil
173179 }
174- delete(publication.source.snatched, req.next)
180+ delete(publication.source.core().snatched, req.next)
175181 }
176182 if req.targetRole == attachmentActive {
177- delete(req.target.snatched, req.next)
183+ delete(req.target.core().snatched, req.next)
178184 if publication.old != nil && publication.old != req.next {
179- req.target.addSnatchedLocked(publication.old)
185+ addSnatchedLocked(req.target, publication.old)
180186 }
181- req.target.client = req.next
187+ req.target.core().client = req.next
182188 } else {
183- req.target.addSnatchedLocked(req.next)
189+ addSnatchedLocked(req.target, req.next)
184190 }
185191 publication.nextGeneration = req.next.roleGeneration.Add(1)
186192 if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive {
@@ -276,26 +282,26 @@ func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
276282
277283 // lockAttachmentSessions gives every two-session transition one stable order.
278284 // Session IDs are immutable and unique while their lifecycles are registered.
279-func lockAttachmentSessions(a, b *session) func() {
285+func lockAttachmentSessions(a, b attachmentSession) func() {
280286 if a == b {
281- a.mu.Lock()
282- return a.mu.Unlock
287+ a.core().mu.Lock()
288+ return a.core().mu.Unlock
283289 }
284290 first, second := a, b
285- if first.id > second.id {
291+ if first.core().id > second.core().id {
286292 first, second = second, first
287293 }
288- first.mu.Lock()
289- second.mu.Lock()
294+ first.core().mu.Lock()
295+ second.core().mu.Lock()
290296 return func() {
291- second.mu.Unlock()
292- first.mu.Unlock()
297+ second.core().mu.Unlock()
298+ first.core().mu.Unlock()
293299 }
294300 }
295301
296302 // lockAttachmentCoordinators follows the same immutable session-ID order as
297303 // lockAttachmentSessions. Callers already hold the ordered session locks.
298-func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() {
304+func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() {
299305 if aCoordinator == nil && bCoordinator == nil {
300306 return func() {}
301307 }
@@ -308,7 +314,7 @@ func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *
308314 return coordinator.mu.Unlock
309315 }
310316 first, second := aCoordinator, bCoordinator
311- if a.id > b.id {
317+ if a.core().id > b.core().id {
312318 first, second = second, first
313319 }
314320 if first != nil {
@@ -4,14 +4,14 @@ package daemon
4 // is frozen and d.mu is held. No caller may create or delete a target before4 // is frozen and d.mu is held. No caller may create or delete a target before
5 // this succeeds.5 // this succeeds.
6 func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {6 func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {
7- if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source {7+ if req.sourceToken == nil || req.source == nil || req.source.core() == nil || d.sessions[req.source.core().id] != req.source {
8 return false8 return false
9 }9 }
10 d.notices.routingMu.Lock()10 d.notices.routingMu.Lock()
11 defer d.notices.routingMu.Unlock()11 defer d.notices.routingMu.Unlock()
12- req.source.mu.Lock()12+ req.source.core().mu.Lock()
13- defer req.source.mu.Unlock()13+ defer req.source.core().mu.Unlock()
14- coordinator := req.source.renderCoordinator()14+ coordinator := req.source.core().coordinator.Load()
15 if coordinator == nil {15 if coordinator == nil {
16 return false16 return false
17 }17 }
@@ -29,14 +29,14 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
29 }29 }
30 d.mu.Lock()30 d.mu.Lock()
31 defer d.mu.Unlock()31 defer d.mu.Unlock()
32- if d.closing || d.sessions[token.sess.id] != token.sess {32+ if d.closing || token.sess.core() == nil || d.sessions[token.sess.core().id] != token.sess {
33 return false33 return false
34 }34 }
35 d.notices.routingMu.Lock()35 d.notices.routingMu.Lock()
36 defer d.notices.routingMu.Unlock()36 defer d.notices.routingMu.Unlock()
37- token.sess.mu.Lock()37+ token.sess.core().mu.Lock()
38- defer token.sess.mu.Unlock()38+ defer token.sess.core().mu.Unlock()
39- coordinator := token.sess.renderCoordinator()39+ coordinator := token.sess.core().coordinator.Load()
40 if coordinator == nil {40 if coordinator == nil {
41 return false41 return false
42 }42 }
@@ -49,15 +49,16 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
49 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)49 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
50 }50 }
51 51
52-func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {52+func transitionSourceTabCurrentLocked(source attachmentSession, expected *tab) bool {
53 return transitionSourceTabCurrentForRequestLocked(source, expected, false)53 return transitionSourceTabCurrentForRequestLocked(source, expected, false)
54 }54 }
55 55
56-func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool {56+func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expected *tab, transferExpected bool) bool {
57 if expected == nil {57 if expected == nil {
58 return true58 return true
59 }59 }
60- if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected {60+ local, ok := localSession(source)
61+ if !ok || local.active < 0 || local.active >= len(local.tabs) || local.tabs[local.active] != expected {
61 return false62 return false
62 }63 }
63 expected.mu.Lock()64 expected.mu.Lock()
@@ -78,7 +79,7 @@ func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (at
78 // own d.mu, notices.routingMu, and the ordered source/target session locks.79 // own d.mu, notices.routingMu, and the ordered source/target session locks.
79 type attachmentPublication struct {80 type attachmentPublication struct {
80 req attachmentTransitionRequest81 req attachmentTransitionRequest
81- source *session82+ source attachmentSession
82 old *attachedClient83 old *attachedClient
83 sourceCoordinator *renderCoordinator84 sourceCoordinator *renderCoordinator
84 targetCoordinator *renderCoordinator85 targetCoordinator *renderCoordinator
@@ -105,7 +106,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
105 if !req.preflighted || !req.roleEffectsFrozen ||106 if !req.preflighted || !req.roleEffectsFrozen ||
106 !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||107 !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
107 source == nil || req.target == nil || req.next == nil || d.closing ||108 source == nil || req.target == nil || req.next == nil || d.closing ||
108- d.sessions[source.id] != source || d.sessions[req.target.id] != req.target ||109+ d.sessions[source.core().id] != source || d.sessions[req.target.core().id] != req.target ||
109 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||110 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
110 req.preserveRole && req.sourceToken == nil {111 req.preserveRole && req.sourceToken == nil {
111 return nil, errAttachmentTransition112 return nil, errAttachmentTransition
@@ -113,15 +114,17 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
113 114
114 var targetCoordinator *renderCoordinator115 var targetCoordinator *renderCoordinator
115 if req.targetRole == attachmentActive {116 if req.targetRole == attachmentActive {
116- targetCoordinator = d.ensureRenderCoordinatorPrelocked(req.target)117+ if target, ok := localSession(req.target); ok {
118+ targetCoordinator = d.ensureRenderCoordinatorPrelocked(target)
119+ }
117 }120 }
118- sourceCoordinator := source.renderCoordinator()121+ sourceCoordinator := source.core().coordinator.Load()
119- if source.attachmentRoleLocked(req.next) != req.expectedRole ||122+ if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||
120 !req.next.transportSnapshotCurrent(req.expectedTransport) {123 !req.next.transportSnapshotCurrent(req.expectedTransport) {
121 return nil, errAttachmentTransition124 return nil, errAttachmentTransition
122 }125 }
123 126
124- old := req.target.client127+ old := req.target.core().client
125 if old != req.expectedTargetCurrent || source != req.target && old == req.next {128 if old != req.expectedTargetCurrent || source != req.target && old == req.next {
126 return nil, errAttachmentTransition129 return nil, errAttachmentTransition
127 }130 }
@@ -135,7 +138,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
135 invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)138 invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
136 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&139 invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
137 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)140 (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
138- invalid = invalid || req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs))141+ invalid = invalid || req.activateTargetTab && !req.target.activateTargetLocked(req.targetTabIndex)
139 if invalid {142 if invalid {
140 publication.unlockCoordinators()143 publication.unlockCoordinators()
141 return nil, errAttachmentTransition144 return nil, errAttachmentTransition
@@ -148,14 +151,17 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
148 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {151 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
149 req := publication.req152 req := publication.req
150 if req.targetRole == attachmentActive {153 if req.targetRole == attachmentActive {
151- d.demoteParkedActiveForSessionLocked(req.target)154+ if target, ok := localSession(req.target); ok {
155+ d.demoteParkedActiveForSessionLocked(target)
156+ }
152 }157 }
153 if req.copySourceEnvironment {158 if req.copySourceEnvironment {
154- req.target.terminal = publication.source.terminal159+ source, sourceOK := localSession(publication.source)
155- req.target.env = copyEnvironment(publication.source.env)160+ target, targetOK := localSession(req.target)
156- }161+ if sourceOK && targetOK {
157- if req.activateTargetTab {162+ target.terminal = source.terminal
158- req.target.active = req.targetTabIndex163+ target.env = copyEnvironment(source.env)
164+ }
159 }165 }
160 return req.preserveRole166 return req.preserveRole
161 }167 }
@@ -168,19 +174,19 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
168 publication.displacedTransport = publication.old.transportSnapshot()174 publication.displacedTransport = publication.old.transportSnapshot()
169 }175 }
170 if publication.source != req.target {176 if publication.source != req.target {
171- if publication.source.client == req.next {177+ if publication.source.core().client == req.next {
172- publication.source.client = nil178+ publication.source.core().client = nil
173 }179 }
174- delete(publication.source.snatched, req.next)180+ delete(publication.source.core().snatched, req.next)
175 }181 }
176 if req.targetRole == attachmentActive {182 if req.targetRole == attachmentActive {
177- delete(req.target.snatched, req.next)183+ delete(req.target.core().snatched, req.next)
178 if publication.old != nil && publication.old != req.next {184 if publication.old != nil && publication.old != req.next {
179- req.target.addSnatchedLocked(publication.old)185+ addSnatchedLocked(req.target, publication.old)
180 }186 }
181- req.target.client = req.next187+ req.target.core().client = req.next
182 } else {188 } else {
183- req.target.addSnatchedLocked(req.next)189+ addSnatchedLocked(req.target, req.next)
184 }190 }
185 publication.nextGeneration = req.next.roleGeneration.Add(1)191 publication.nextGeneration = req.next.roleGeneration.Add(1)
186 if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive {192 if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive {
@@ -276,26 +282,26 @@ func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
276 282
277 // lockAttachmentSessions gives every two-session transition one stable order.283 // lockAttachmentSessions gives every two-session transition one stable order.
278 // Session IDs are immutable and unique while their lifecycles are registered.284 // Session IDs are immutable and unique while their lifecycles are registered.
279-func lockAttachmentSessions(a, b *session) func() {285+func lockAttachmentSessions(a, b attachmentSession) func() {
280 if a == b {286 if a == b {
281- a.mu.Lock()287+ a.core().mu.Lock()
282- return a.mu.Unlock288+ return a.core().mu.Unlock
283 }289 }
284 first, second := a, b290 first, second := a, b
285- if first.id > second.id {291+ if first.core().id > second.core().id {
286 first, second = second, first292 first, second = second, first
287 }293 }
288- first.mu.Lock()294+ first.core().mu.Lock()
289- second.mu.Lock()295+ second.core().mu.Lock()
290 return func() {296 return func() {
291- second.mu.Unlock()297+ second.core().mu.Unlock()
292- first.mu.Unlock()298+ first.core().mu.Unlock()
293 }299 }
294 }300 }
295 301
296 // lockAttachmentCoordinators follows the same immutable session-ID order as302 // lockAttachmentCoordinators follows the same immutable session-ID order as
297 // lockAttachmentSessions. Callers already hold the ordered session locks.303 // lockAttachmentSessions. Callers already hold the ordered session locks.
298-func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() {304+func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() {
299 if aCoordinator == nil && bCoordinator == nil {305 if aCoordinator == nil && bCoordinator == nil {
300 return func() {}306 return func() {}
301 }307 }
@@ -308,7 +314,7 @@ func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *
308 return coordinator.mu.Unlock314 return coordinator.mu.Unlock
309 }315 }
310 first, second := aCoordinator, bCoordinator316 first, second := aCoordinator, bCoordinator
311- if a.id > b.id {317+ if a.core().id > b.core().id {
312 first, second = second, first318 first, second = second, first
313 }319 }
314 if first != nil {320 if first != nil {
M internal/usecase/daemon/attention.go +4 -10
@@ -87,8 +87,8 @@ type attentionTarget struct {
8787 func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
8888 d.mu.Lock()
8989 sessions := make([]*session, 0, len(d.sessions))
90- for _, sess := range d.sessions {
91- if sess != current {
90+ for _, entry := range d.sessions {
91+ if sess, ok := localSession(entry); ok && sess != current {
9292 sessions = append(sessions, sess)
9393 }
9494 }
@@ -137,10 +137,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) {
137137
138138 func (d *Daemon) repaintAllAttachedClients() {
139139 d.mu.Lock()
140- sessions := make([]*session, 0, len(d.sessions))
141- for _, sess := range d.sessions {
142- sessions = append(sessions, sess)
143- }
140+ sessions := localSessionsSnapshot(d.sessions)
144141 d.mu.Unlock()
145142
146143 for _, sess := range sessions {
@@ -247,10 +244,7 @@ func (d *Daemon) attentionAnimator(ctx context.Context) {
247244
248245 func (d *Daemon) anyAttention() bool {
249246 d.mu.Lock()
250- sessions := make([]*session, 0, len(d.sessions))
251- for _, sess := range d.sessions {
252- sessions = append(sessions, sess)
253- }
247+ sessions := localSessionsSnapshot(d.sessions)
254248 d.mu.Unlock()
255249
256250 for _, sess := range sessions {
@@ -87,8 +87,8 @@ type attentionTarget struct {
87 func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {87 func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) {
88 d.mu.Lock()88 d.mu.Lock()
89 sessions := make([]*session, 0, len(d.sessions))89 sessions := make([]*session, 0, len(d.sessions))
90- for _, sess := range d.sessions {90+ for _, entry := range d.sessions {
91- if sess != current {91+ if sess, ok := localSession(entry); ok && sess != current {
92 sessions = append(sessions, sess)92 sessions = append(sessions, sess)
93 }93 }
94 }94 }
@@ -137,10 +137,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) {
137 137
138 func (d *Daemon) repaintAllAttachedClients() {138 func (d *Daemon) repaintAllAttachedClients() {
139 d.mu.Lock()139 d.mu.Lock()
140- sessions := make([]*session, 0, len(d.sessions))140+ sessions := localSessionsSnapshot(d.sessions)
141- for _, sess := range d.sessions {
142- sessions = append(sessions, sess)
143- }
144 d.mu.Unlock()141 d.mu.Unlock()
145 142
146 for _, sess := range sessions {143 for _, sess := range sessions {
@@ -247,10 +244,7 @@ func (d *Daemon) attentionAnimator(ctx context.Context) {
247 244
248 func (d *Daemon) anyAttention() bool {245 func (d *Daemon) anyAttention() bool {
249 d.mu.Lock()246 d.mu.Lock()
250- sessions := make([]*session, 0, len(d.sessions))247+ sessions := localSessionsSnapshot(d.sessions)
251- for _, sess := range d.sessions {
252- sessions = append(sessions, sess)
253- }
254 d.mu.Unlock()248 d.mu.Unlock()
255 249
256 for _, sess := range sessions {250 for _, sess := range sessions {
M internal/usecase/daemon/attention_test.go +5 -7
@@ -176,7 +176,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
176176 sctxW, cancelW := context.WithCancel(d.serveCtx)
177177 t.Cleanup(cancelW)
178178 tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
179- sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}
179+ sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", client: acW}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}}
180180 acW.setSession(sessW)
181181 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil)
182182 d.sessions[sessW.id] = sessW
@@ -446,7 +446,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
446446 sctxB, cancelB := context.WithCancel(d.serveCtx)
447447 t.Cleanup(cancelB)
448448 tbB := newTestTabWithContext(pB, sctxB, cancelB)
449- sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}
449+ sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", client: acB}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}}
450450 acB.setSession(sessB)
451451 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
452452 d.sessions[sessB.id] = sessB
@@ -502,10 +502,8 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
502502 tab2a.attentionAt = time.Unix(9, 0)
503503 tab2b.attention = true
504504 tab2b.attentionAt = time.Unix(5, 0)
505- sess2 := &session{
506- id: "other",
507- name: "other",
508- ctx: sctx2,
505+ sess2 := &session{sessionCore: sessionCore{id: "other",
506+ name: "other"}, ctx: sctx2,
509507 cancel: cancel2,
510508 tabs: []*tab{tab2a, tab2b},
511509 }
@@ -554,7 +552,7 @@ func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) {
554552 tab2 := newTestTabWithContext(p2, sctx2, cancel2)
555553 tab2.attention = true
556554 tab2.attentionAt = time.Unix(9, 0)
557- sess2 := &session{id: "other", name: "other", ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}}
555+ sess2 := &session{sessionCore: sessionCore{id: "other", name: "other"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}}
558556 d.sessions[sess2.id] = sess2
559557
560558 sess1.mu.Lock()
@@ -176,7 +176,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
176 sctxW, cancelW := context.WithCancel(d.serveCtx)176 sctxW, cancelW := context.WithCancel(d.serveCtx)
177 t.Cleanup(cancelW)177 t.Cleanup(cancelW)
178 tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)178 tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
179- sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}179+ sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", client: acW}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}}
180 acW.setSession(sessW)180 acW.setSession(sessW)
181 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil)181 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil)
182 d.sessions[sessW.id] = sessW182 d.sessions[sessW.id] = sessW
@@ -446,7 +446,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
446 sctxB, cancelB := context.WithCancel(d.serveCtx)446 sctxB, cancelB := context.WithCancel(d.serveCtx)
447 t.Cleanup(cancelB)447 t.Cleanup(cancelB)
448 tbB := newTestTabWithContext(pB, sctxB, cancelB)448 tbB := newTestTabWithContext(pB, sctxB, cancelB)
449- sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}449+ sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", client: acB}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}}
450 acB.setSession(sessB)450 acB.setSession(sessB)
451 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)451 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil)
452 d.sessions[sessB.id] = sessB452 d.sessions[sessB.id] = sessB
@@ -502,10 +502,8 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
502 tab2a.attentionAt = time.Unix(9, 0)502 tab2a.attentionAt = time.Unix(9, 0)
503 tab2b.attention = true503 tab2b.attention = true
504 tab2b.attentionAt = time.Unix(5, 0)504 tab2b.attentionAt = time.Unix(5, 0)
505- sess2 := &session{505+ sess2 := &session{sessionCore: sessionCore{id: "other",
506- id: "other",506+ name: "other"}, ctx: sctx2,
507- name: "other",
508- ctx: sctx2,
509 cancel: cancel2,507 cancel: cancel2,
510 tabs: []*tab{tab2a, tab2b},508 tabs: []*tab{tab2a, tab2b},
511 }509 }
@@ -554,7 +552,7 @@ func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) {
554 tab2 := newTestTabWithContext(p2, sctx2, cancel2)552 tab2 := newTestTabWithContext(p2, sctx2, cancel2)
555 tab2.attention = true553 tab2.attention = true
556 tab2.attentionAt = time.Unix(9, 0)554 tab2.attentionAt = time.Unix(9, 0)
557- sess2 := &session{id: "other", name: "other", ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}}555+ sess2 := &session{sessionCore: sessionCore{id: "other", name: "other"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}}
558 d.sessions[sess2.id] = sess2556 d.sessions[sess2.id] = sess2
559 557
560 sess1.mu.Lock()558 sess1.mu.Lock()
M internal/usecase/daemon/bar_refresh.go +1 -5
@@ -200,11 +200,7 @@ func (d *Daemon) refreshBarScriptsAllSessions() {
200200 func (d *Daemon) sessionsSnapshot() []*session {
201201 d.mu.Lock()
202202 defer d.mu.Unlock()
203- sessions := make([]*session, 0, len(d.sessions))
204- for _, sess := range d.sessions {
205- sessions = append(sessions, sess)
206- }
207- return sessions
203+ return localSessionsSnapshot(d.sessions)
208204 }
209205
210206 func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
@@ -200,11 +200,7 @@ func (d *Daemon) refreshBarScriptsAllSessions() {
200 func (d *Daemon) sessionsSnapshot() []*session {200 func (d *Daemon) sessionsSnapshot() []*session {
201 d.mu.Lock()201 d.mu.Lock()
202 defer d.mu.Unlock()202 defer d.mu.Unlock()
203- sessions := make([]*session, 0, len(d.sessions))203+ return localSessionsSnapshot(d.sessions)
204- for _, sess := range d.sessions {
205- sessions = append(sessions, sess)
206- }
207- return sessions
208 }204 }
209 205
210 func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {206 func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) {
M internal/usecase/daemon/bar_refresh_test.go +2 -2
@@ -220,7 +220,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
220220 d := newBarRefreshTestDaemon(r, 60*time.Second)
221221 sess := newBarRefreshTestSession()
222222 sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
223- d.sessions = map[domain.SessionID]*session{sess.id: sess}
223+ d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}
224224
225225 // Verifies refreshBarScriptsAllSessions iterates every live session and
226226 // dispatches a run for each. This does not exercise force specifically:
@@ -587,7 +587,7 @@ func newBarRefreshTestSession() *session {
587587 inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
588588 active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
589589 active.tree.Focus = layout.PaneID("pane-1")
590- return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()}
590+ return &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()}
591591 }
592592
593593 func waitBarRefreshIdle(t *testing.T, d *Daemon) {
@@ -220,7 +220,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) {
220 d := newBarRefreshTestDaemon(r, 60*time.Second)220 d := newBarRefreshTestDaemon(r, 60*time.Second)
221 sess := newBarRefreshTestSession()221 sess := newBarRefreshTestSession()
222 sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}222 sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}}
223- d.sessions = map[domain.SessionID]*session{sess.id: sess}223+ d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}
224 224
225 // Verifies refreshBarScriptsAllSessions iterates every live session and225 // Verifies refreshBarScriptsAllSessions iterates every live session and
226 // dispatches a run for each. This does not exercise force specifically:226 // dispatches a run for each. This does not exercise force specifically:
@@ -587,7 +587,7 @@ func newBarRefreshTestSession() *session {
587 inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})587 inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22})
588 active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})588 active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22})
589 active.tree.Focus = layout.PaneID("pane-1")589 active.tree.Focus = layout.PaneID("pane-1")
590- return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()}590+ return &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()}
591 }591 }
592 592
593 func waitBarRefreshIdle(t *testing.T, d *Daemon) {593 func waitBarRefreshIdle(t *testing.T, d *Daemon) {
M internal/usecase/daemon/client.go +26 -8
@@ -70,7 +70,7 @@ type attachedClient struct {
7070 initialSnatchedAttempt *initialSnatchedPanelAttempt
7171 size domain.Size
7272 keys *keys.Router
73- sess Guarded[*session]
73+ sess Guarded[attachmentSession]
7474 mouseScan mouse.Scanner
7575 themeMu sync.Mutex
7676 clientTheme themeui.Theme
@@ -79,7 +79,7 @@ type attachedClient struct {
7979 renderStages renderStageHooks // optional render and handoff observability hooks
8080 // previousSession is guarded independently. It is retained through temporary
8181 // setSession(nil) hand-offs and cleared only on terminal teardown.
82- previousSession Guarded[*session]
82+ previousSession Guarded[attachmentSession]
8383 // snatchedInputMu serializes the restricted input parser with its delayed
8484 // standalone-ESC callback. It is independent of routing and transport locks;
8585 // role activation and terminal close clear it only after releasing those locks.
@@ -135,9 +135,17 @@ func (ac *attachedClient) initOverlays() {
135135 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
136136 }
137137
138-func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
138+func (ac *attachedClient) currentAttachmentSession() attachmentSession { return ac.sess.Get() }
139139
140-func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
140+// currentSession is the explicit local-only narrowing used until proxy-aware
141+// consumers are introduced. Local PTY/tab paths must treat non-local entries as
142+// unavailable rather than infer proxy behavior.
143+func (ac *attachedClient) currentSession() *session {
144+ sess, _ := localSession(ac.currentAttachmentSession())
145+ return sess
146+}
147+
148+func (ac *attachedClient) setSession(sess attachmentSession) { ac.sess.Set(sess) }
141149
142150 func (ac *attachedClient) clearPreviousSession() {
143151 if ac != nil {
@@ -610,7 +618,11 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
610618 token.ac.captureFrames = nil
611619 token.ac.sendMu.Unlock()
612620 }
613- d.firstPaintWithLease(token.sess, token.ac, token.ac.size, token.lease)
621+ sess, ok := localSession(token.sess)
622+ if !ok {
623+ return false
624+ }
625+ d.firstPaintWithLease(sess, token.ac, token.ac.size, token.lease)
614626 return true
615627 }
616628
@@ -690,13 +702,14 @@ func (d *Daemon) applyThemeForRole(token attachmentRoleToken, msg ports.Theme) b
690702 }
691703 clientTheme := themeFromMessage(msg)
692704 token.ac.setClientTheme(clientTheme)
693- if !token.activeEffect() || !d.applyHostTheme(token.sess, token.ac, clientTheme, false) {
705+ sess, ok := localSession(token.sess)
706+ if !ok || !token.activeEffect() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
694707 return false
695708 }
696709 if !token.activeEffect() {
697710 return false
698711 }
699- if rc := token.sess.renderCoordinator(); rc != nil {
712+ if rc := token.sess.core().coordinator.Load(); rc != nil {
700713 return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
701714 }
702715 return false
@@ -739,7 +752,12 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por
739752 d.notices.routingMu.Unlock()
740753 return
741754 }
742- repaint := d.mutateClientNotice(token.sess, token.ac, notice)
755+ sess, ok := localSession(token.sess)
756+ if !ok {
757+ d.notices.routingMu.Unlock()
758+ return
759+ }
760+ repaint := d.mutateClientNotice(sess, token.ac, notice)
743761 d.notices.routingMu.Unlock()
744762 if repaint && token.activeEffect() {
745763 d.repaintForNotice(token.ac)
@@ -70,7 +70,7 @@ type attachedClient struct {
70 initialSnatchedAttempt *initialSnatchedPanelAttempt70 initialSnatchedAttempt *initialSnatchedPanelAttempt
71 size domain.Size71 size domain.Size
72 keys *keys.Router72 keys *keys.Router
73- sess Guarded[*session]73+ sess Guarded[attachmentSession]
74 mouseScan mouse.Scanner74 mouseScan mouse.Scanner
75 themeMu sync.Mutex75 themeMu sync.Mutex
76 clientTheme themeui.Theme76 clientTheme themeui.Theme
@@ -79,7 +79,7 @@ type attachedClient struct {
79 renderStages renderStageHooks // optional render and handoff observability hooks79 renderStages renderStageHooks // optional render and handoff observability hooks
80 // previousSession is guarded independently. It is retained through temporary80 // previousSession is guarded independently. It is retained through temporary
81 // setSession(nil) hand-offs and cleared only on terminal teardown.81 // setSession(nil) hand-offs and cleared only on terminal teardown.
82- previousSession Guarded[*session]82+ previousSession Guarded[attachmentSession]
83 // snatchedInputMu serializes the restricted input parser with its delayed83 // snatchedInputMu serializes the restricted input parser with its delayed
84 // standalone-ESC callback. It is independent of routing and transport locks;84 // standalone-ESC callback. It is independent of routing and transport locks;
85 // role activation and terminal close clear it only after releasing those locks.85 // role activation and terminal close clear it only after releasing those locks.
@@ -135,9 +135,17 @@ func (ac *attachedClient) initOverlays() {
135 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })135 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
136 }136 }
137 137
138-func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }138+func (ac *attachedClient) currentAttachmentSession() attachmentSession { return ac.sess.Get() }
139 139
140-func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }140+// currentSession is the explicit local-only narrowing used until proxy-aware
141+// consumers are introduced. Local PTY/tab paths must treat non-local entries as
142+// unavailable rather than infer proxy behavior.
143+func (ac *attachedClient) currentSession() *session {
144+ sess, _ := localSession(ac.currentAttachmentSession())
145+ return sess
146+}
147+
148+func (ac *attachedClient) setSession(sess attachmentSession) { ac.sess.Set(sess) }
141 149
142 func (ac *attachedClient) clearPreviousSession() {150 func (ac *attachedClient) clearPreviousSession() {
143 if ac != nil {151 if ac != nil {
@@ -610,7 +618,11 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
610 token.ac.captureFrames = nil618 token.ac.captureFrames = nil
611 token.ac.sendMu.Unlock()619 token.ac.sendMu.Unlock()
612 }620 }
613- d.firstPaintWithLease(token.sess, token.ac, token.ac.size, token.lease)621+ sess, ok := localSession(token.sess)
622+ if !ok {
623+ return false
624+ }
625+ d.firstPaintWithLease(sess, token.ac, token.ac.size, token.lease)
614 return true626 return true
615 }627 }
616 628
@@ -690,13 +702,14 @@ func (d *Daemon) applyThemeForRole(token attachmentRoleToken, msg ports.Theme) b
690 }702 }
691 clientTheme := themeFromMessage(msg)703 clientTheme := themeFromMessage(msg)
692 token.ac.setClientTheme(clientTheme)704 token.ac.setClientTheme(clientTheme)
693- if !token.activeEffect() || !d.applyHostTheme(token.sess, token.ac, clientTheme, false) {705+ sess, ok := localSession(token.sess)
706+ if !ok || !token.activeEffect() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
694 return false707 return false
695 }708 }
696 if !token.activeEffect() {709 if !token.activeEffect() {
697 return false710 return false
698 }711 }
699- if rc := token.sess.renderCoordinator(); rc != nil {712+ if rc := token.sess.core().coordinator.Load(); rc != nil {
700 return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})713 return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
701 }714 }
702 return false715 return false
@@ -739,7 +752,12 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por
739 d.notices.routingMu.Unlock()752 d.notices.routingMu.Unlock()
740 return753 return
741 }754 }
742- repaint := d.mutateClientNotice(token.sess, token.ac, notice)755+ sess, ok := localSession(token.sess)
756+ if !ok {
757+ d.notices.routingMu.Unlock()
758+ return
759+ }
760+ repaint := d.mutateClientNotice(sess, token.ac, notice)
743 d.notices.routingMu.Unlock()761 d.notices.routingMu.Unlock()
744 if repaint && token.activeEffect() {762 if repaint && token.activeEffect() {
745 d.repaintForNotice(token.ac)763 d.repaintForNotice(token.ac)
M internal/usecase/daemon/client_frame_routing.go +4 -2
@@ -77,7 +77,9 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
7777 }
7878 case ports.MsgResize:
7979 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {
80- d.requestTransactionalResizeForLease(token.sess, token.ac, token.lease, rz.Size, false)
80+ if sess, ok := localSession(token.sess); ok {
81+ d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)
82+ }
8183 }
8284 case ports.MsgTheme:
8385 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
@@ -123,7 +125,7 @@ func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
123125 ac.sendMu.Lock()
124126 ac.output.ack(state)
125127 ac.sendMu.Unlock()
126- if rc := token.sess.renderCoordinator(); rc != nil {
128+ if rc := token.sess.core().coordinator.Load(); rc != nil {
127129 rc.notifyAckForLease(token.lease)
128130 }
129131 return true
@@ -77,7 +77,9 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
77 }77 }
78 case ports.MsgResize:78 case ports.MsgResize:
79 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {79 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {
80- d.requestTransactionalResizeForLease(token.sess, token.ac, token.lease, rz.Size, false)80+ if sess, ok := localSession(token.sess); ok {
81+ d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)
82+ }
81 }83 }
82 case ports.MsgTheme:84 case ports.MsgTheme:
83 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {85 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
@@ -123,7 +125,7 @@ func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
123 ac.sendMu.Lock()125 ac.sendMu.Lock()
124 ac.output.ack(state)126 ac.output.ack(state)
125 ac.sendMu.Unlock()127 ac.sendMu.Unlock()
126- if rc := token.sess.renderCoordinator(); rc != nil {128+ if rc := token.sess.core().coordinator.Load(); rc != nil {
127 rc.notifyAckForLease(token.lease)129 rc.notifyAckForLease(token.lease)
128 }130 }
129 return true131 return true
M internal/usecase/daemon/client_transport_errors.go +10 -8
@@ -29,20 +29,21 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
2929 }
3030
3131 func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {
32- if token.effect == nil {
32+ sess, ok := localSession(token.sess)
33+ if !ok || token.effect == nil {
3334 return false
3435 }
3536 token.effect.bindActionEnd(d, "detach")
3637 token.effect.End()
3738 var parkingToken uint64
3839 if !explicit {
39- parkingToken = d.markParkingInFlight(token.sess, token.ac)
40+ parkingToken = d.markParkingInFlight(sess, token.ac)
4041 }
4142 if !d.detachIfRoleCurrent(token) {
42- d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)
43+ d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
4344 return false
4445 }
45- d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit)
46+ d.finishClientGone(sess, token.ac, token.transport.transport, explicit)
4647 return true
4748 }
4849
@@ -111,15 +112,16 @@ func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.T
111112 }
112113
113114 func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) {
114- if failed != token.transport.transport {
115+ sess, ok := localSession(token.sess)
116+ if !ok || failed != token.transport.transport {
115117 return
116118 }
117- parkingToken := d.markParkingInFlight(token.sess, token.ac)
119+ parkingToken := d.markParkingInFlight(sess, token.ac)
118120 if d.detachIfRoleCurrentUntil(token, done) {
119- d.finishSendErrorDetach(token.sess, token.ac, failed)
121+ d.finishSendErrorDetach(sess, token.ac, failed)
120122 return
121123 }
122- d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)
124+ d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
123125 }
124126
125127 // reserveRoleSendErrorCleanup accounts for cleanup before End releases the
@@ -29,20 +29,21 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
29 }29 }
30 30
31 func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {31 func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {
32- if token.effect == nil {32+ sess, ok := localSession(token.sess)
33+ if !ok || token.effect == nil {
33 return false34 return false
34 }35 }
35 token.effect.bindActionEnd(d, "detach")36 token.effect.bindActionEnd(d, "detach")
36 token.effect.End()37 token.effect.End()
37 var parkingToken uint6438 var parkingToken uint64
38 if !explicit {39 if !explicit {
39- parkingToken = d.markParkingInFlight(token.sess, token.ac)40+ parkingToken = d.markParkingInFlight(sess, token.ac)
40 }41 }
41 if !d.detachIfRoleCurrent(token) {42 if !d.detachIfRoleCurrent(token) {
42- d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)43+ d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
43 return false44 return false
44 }45 }
45- d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit)46+ d.finishClientGone(sess, token.ac, token.transport.transport, explicit)
46 return true47 return true
47 }48 }
48 49
@@ -111,15 +112,16 @@ func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.T
111 }112 }
112 113
113 func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) {114 func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) {
114- if failed != token.transport.transport {115+ sess, ok := localSession(token.sess)
116+ if !ok || failed != token.transport.transport {
115 return117 return
116 }118 }
117- parkingToken := d.markParkingInFlight(token.sess, token.ac)119+ parkingToken := d.markParkingInFlight(sess, token.ac)
118 if d.detachIfRoleCurrentUntil(token, done) {120 if d.detachIfRoleCurrentUntil(token, done) {
119- d.finishSendErrorDetach(token.sess, token.ac, failed)121+ d.finishSendErrorDetach(sess, token.ac, failed)
120 return122 return
121 }123 }
122- d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)124+ d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
123 }125 }
124 126
125 // reserveRoleSendErrorCleanup accounts for cleanup before End releases the127 // reserveRoleSendErrorCleanup accounts for cleanup before End releases the
M internal/usecase/daemon/clipboard.go +12 -6
@@ -89,14 +89,19 @@ func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.
8989 if err != nil {
9090 return "", err
9191 }
92- token.sess.mu.Lock()
92+ sess, ok := localSession(token.sess)
93+ if !ok {
94+ _ = os.Remove(path)
95+ return "", errAttachmentTransition
96+ }
97+ sess.mu.Lock()
9398 if !token.activeEffectSessionLocked() {
94- token.sess.mu.Unlock()
99+ sess.mu.Unlock()
95100 _ = os.Remove(path)
96101 return "", errAttachmentTransition
97102 }
98- token.sess.clipFiles = append(token.sess.clipFiles, path)
99- token.sess.mu.Unlock()
103+ sess.clipFiles = append(sess.clipFiles, path)
104+ sess.mu.Unlock()
100105 return path, nil
101106 }
102107
@@ -130,10 +135,11 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
130135 }
131136
132137 func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) {
133- if !token.activeEffect() {
138+ sess, ok := localSession(token.sess)
139+ if !ok || !token.activeEffect() {
134140 return
135141 }
136- d.injectClipboardPathToTarget(token.sess, path, &token)
142+ d.injectClipboardPathToTarget(sess, path, &token)
137143 }
138144
139145 func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) {
@@ -89,14 +89,19 @@ func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.
89 if err != nil {89 if err != nil {
90 return "", err90 return "", err
91 }91 }
92- token.sess.mu.Lock()92+ sess, ok := localSession(token.sess)
93+ if !ok {
94+ _ = os.Remove(path)
95+ return "", errAttachmentTransition
96+ }
97+ sess.mu.Lock()
93 if !token.activeEffectSessionLocked() {98 if !token.activeEffectSessionLocked() {
94- token.sess.mu.Unlock()99+ sess.mu.Unlock()
95 _ = os.Remove(path)100 _ = os.Remove(path)
96 return "", errAttachmentTransition101 return "", errAttachmentTransition
97 }102 }
98- token.sess.clipFiles = append(token.sess.clipFiles, path)103+ sess.clipFiles = append(sess.clipFiles, path)
99- token.sess.mu.Unlock()104+ sess.mu.Unlock()
100 return path, nil105 return path, nil
101 }106 }
102 107
@@ -130,10 +135,11 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
130 }135 }
131 136
132 func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) {137 func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) {
133- if !token.activeEffect() {138+ sess, ok := localSession(token.sess)
139+ if !ok || !token.activeEffect() {
134 return140 return
135 }141 }
136- d.injectClipboardPathToTarget(token.sess, path, &token)142+ d.injectClipboardPathToTarget(sess, path, &token)
137 }143 }
138 144
139 func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) {145 func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) {
M internal/usecase/daemon/clipboard_test.go +7 -7
@@ -231,7 +231,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
231231 win := newTestTabWithContext(p, sctx, cancel)
232232 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
233233 ac.initOverlays()
234- sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
234+ sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
235235 publishTiledPaneOwners(sess, win)
236236 ac.setSession(sess)
237237 d.sessions[sess.id] = sess
@@ -265,7 +265,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
265265 win := newTestTabWithContext(p, sctx, cancel)
266266 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
267267 ac.initOverlays()
268- sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
268+ sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
269269 publishTiledPaneOwners(sess, win)
270270 ac.setSession(sess)
271271 d.sessions[sess.id] = sess
@@ -290,7 +290,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
290290 win := newTestTabWithContext(p, sctx, cancel)
291291 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
292292 ac.initOverlays()
293- sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
293+ sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
294294 publishTiledPaneOwners(sess, win)
295295 ac.setSession(sess)
296296 d.sessions[sess.id] = sess
@@ -312,7 +312,7 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
312312 sctx, cancel := context.WithCancel(context.Background())
313313 defer cancel()
314314 win := newTestTabWithContext(p, sctx, cancel)
315- sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
315+ sess := &session{sessionCore: sessionCore{id: "clip-noclient", name: "clip-noclient"}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
316316 publishTiledPaneOwners(sess, win)
317317 d.sessions[sess.id] = sess
318318
@@ -356,7 +356,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
356356
357357 sourceTab := sess.activeTab()
358358 p := sourceTab.focusedPane()
359- destination := &session{id: "destination", name: "destination"}
359+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
360360 publishPaneOwner(p, destination, &tab{}, 0)
361361 publishPaneOwner(p, sess, sourceTab, 0)
362362
@@ -424,7 +424,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
424424
425425 sourceTab := sess.activeTab()
426426 p := sourceTab.focusedPane()
427- destination := &session{id: "destination", name: "destination"}
427+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
428428 publishPaneOwner(p, destination, &tab{}, 0)
429429 publishPaneOwner(p, sess, sourceTab, 0)
430430 ac.sendMu.Unlock()
@@ -455,7 +455,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
455455
456456 sourceTab := sess.activeTab()
457457 p := sourceTab.focusedPane()
458- destination := &session{id: "destination", name: "destination"}
458+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
459459 publishPaneOwner(p, destination, &tab{}, 0)
460460 publishPaneOwner(p, sess, sourceTab, 0)
461461 close(oldTransport.release)
@@ -231,7 +231,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
231 win := newTestTabWithContext(p, sctx, cancel)231 win := newTestTabWithContext(p, sctx, cancel)
232 ac := &attachedClient{tr: tr, output: newOutputStateStream()}232 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
233 ac.initOverlays()233 ac.initOverlays()
234- sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}234+ sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
235 publishTiledPaneOwners(sess, win)235 publishTiledPaneOwners(sess, win)
236 ac.setSession(sess)236 ac.setSession(sess)
237 d.sessions[sess.id] = sess237 d.sessions[sess.id] = sess
@@ -265,7 +265,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
265 win := newTestTabWithContext(p, sctx, cancel)265 win := newTestTabWithContext(p, sctx, cancel)
266 ac := &attachedClient{tr: tr, output: newOutputStateStream()}266 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
267 ac.initOverlays()267 ac.initOverlays()
268- sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}268+ sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
269 publishTiledPaneOwners(sess, win)269 publishTiledPaneOwners(sess, win)
270 ac.setSession(sess)270 ac.setSession(sess)
271 d.sessions[sess.id] = sess271 d.sessions[sess.id] = sess
@@ -290,7 +290,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
290 win := newTestTabWithContext(p, sctx, cancel)290 win := newTestTabWithContext(p, sctx, cancel)
291 ac := &attachedClient{tr: tr, output: newOutputStateStream()}291 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
292 ac.initOverlays()292 ac.initOverlays()
293- sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}293+ sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
294 publishTiledPaneOwners(sess, win)294 publishTiledPaneOwners(sess, win)
295 ac.setSession(sess)295 ac.setSession(sess)
296 d.sessions[sess.id] = sess296 d.sessions[sess.id] = sess
@@ -312,7 +312,7 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
312 sctx, cancel := context.WithCancel(context.Background())312 sctx, cancel := context.WithCancel(context.Background())
313 defer cancel()313 defer cancel()
314 win := newTestTabWithContext(p, sctx, cancel)314 win := newTestTabWithContext(p, sctx, cancel)
315- sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel}315+ sess := &session{sessionCore: sessionCore{id: "clip-noclient", name: "clip-noclient"}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
316 publishTiledPaneOwners(sess, win)316 publishTiledPaneOwners(sess, win)
317 d.sessions[sess.id] = sess317 d.sessions[sess.id] = sess
318 318
@@ -356,7 +356,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) {
356 356
357 sourceTab := sess.activeTab()357 sourceTab := sess.activeTab()
358 p := sourceTab.focusedPane()358 p := sourceTab.focusedPane()
359- destination := &session{id: "destination", name: "destination"}359+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
360 publishPaneOwner(p, destination, &tab{}, 0)360 publishPaneOwner(p, destination, &tab{}, 0)
361 publishPaneOwner(p, sess, sourceTab, 0)361 publishPaneOwner(p, sess, sourceTab, 0)
362 362
@@ -424,7 +424,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
424 424
425 sourceTab := sess.activeTab()425 sourceTab := sess.activeTab()
426 p := sourceTab.focusedPane()426 p := sourceTab.focusedPane()
427- destination := &session{id: "destination", name: "destination"}427+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
428 publishPaneOwner(p, destination, &tab{}, 0)428 publishPaneOwner(p, destination, &tab{}, 0)
429 publishPaneOwner(p, sess, sourceTab, 0)429 publishPaneOwner(p, sess, sourceTab, 0)
430 ac.sendMu.Unlock()430 ac.sendMu.Unlock()
@@ -455,7 +455,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) {
455 455
456 sourceTab := sess.activeTab()456 sourceTab := sess.activeTab()
457 p := sourceTab.focusedPane()457 p := sourceTab.focusedPane()
458- destination := &session{id: "destination", name: "destination"}458+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}}
459 publishPaneOwner(p, destination, &tab{}, 0)459 publishPaneOwner(p, destination, &tab{}, 0)
460 publishPaneOwner(p, sess, sourceTab, 0)460 publishPaneOwner(p, sess, sourceTab, 0)
461 close(oldTransport.release)461 close(oldTransport.release)
M internal/usecase/daemon/config.go +1 -4
@@ -336,10 +336,7 @@ func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme {
336336
337337 func (d *Daemon) reapplyThemeAllSessions() {
338338 d.mu.Lock()
339- sessions := make([]*session, 0, len(d.sessions))
340- for _, sess := range d.sessions {
341- sessions = append(sessions, sess)
342- }
339+ sessions := localSessionsSnapshot(d.sessions)
343340 d.mu.Unlock()
344341
345342 for _, sess := range sessions {
@@ -336,10 +336,7 @@ func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme {
336 336
337 func (d *Daemon) reapplyThemeAllSessions() {337 func (d *Daemon) reapplyThemeAllSessions() {
338 d.mu.Lock()338 d.mu.Lock()
339- sessions := make([]*session, 0, len(d.sessions))339+ sessions := localSessionsSnapshot(d.sessions)
340- for _, sess := range d.sessions {
341- sessions = append(sessions, sess)
342- }
343 d.mu.Unlock()340 d.mu.Unlock()
344 341
345 for _, sess := range sessions {342 for _, sess := range sessions {
M internal/usecase/daemon/control.go +9 -13
@@ -122,8 +122,9 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
122122 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
123123 }
124124 var match *session
125- for _, sess := range d.sessions {
126- if !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
125+ for _, entry := range d.sessions {
126+ sess, ok := localSession(entry)
127+ if !ok || !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
127128 continue
128129 }
129130 if match != nil {
@@ -136,13 +137,14 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
136137 }
137138 return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"
138139 }
139- if len(d.sessions) == 0 {
140+ locals := localSessionsSnapshot(d.sessions)
141+ if len(locals) == 0 {
140142 return nil, ports.ErrNoSuchTarget, "no live sessions"
141143 }
142- if len(d.sessions) != 1 {
144+ if len(locals) != 1 {
143145 return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane"
144146 }
145- for _, sess := range d.sessions {
147+ for _, sess := range locals {
146148 return sess, 0, ""
147149 }
148150 panic("unreachable")
@@ -570,10 +572,7 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) {
570572 Active bool `json:"active"`
571573 }
572574 e.d.mu.Lock()
573- sessions := make([]*session, 0, len(e.d.sessions))
574- for _, sess := range e.d.sessions {
575- sessions = append(sessions, sess)
576- }
575+ sessions := localSessionsSnapshot(e.d.sessions)
577576 e.d.mu.Unlock()
578577 snaps := make([]sessionView, 0, len(sessions))
579578 for _, sess := range sessions {
@@ -610,10 +609,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
610609 return "", command.ErrInvalidArguments
611610 }
612611 e.d.mu.Lock()
613- sessions := make([]*session, 0, len(e.d.sessions))
614- for _, sess := range e.d.sessions {
615- sessions = append(sessions, sess)
616- }
612+ sessions := localSessionsSnapshot(e.d.sessions)
617613 e.d.mu.Unlock()
618614 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
619615 // Stopped sessions live in d.stopped and are intentionally not in this listing.
@@ -122,8 +122,9 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
122 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"122 return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together"
123 }123 }
124 var match *session124 var match *session
125- for _, sess := range d.sessions {125+ for _, entry := range d.sessions {
126- if !sess.containsStableIDs(request.TargetTab, request.TargetPane) {126+ sess, ok := localSession(entry)
127+ if !ok || !sess.containsStableIDs(request.TargetTab, request.TargetPane) {
127 continue128 continue
128 }129 }
129 if match != nil {130 if match != nil {
@@ -136,13 +137,14 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u
136 }137 }
137 return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"138 return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane"
138 }139 }
139- if len(d.sessions) == 0 {140+ locals := localSessionsSnapshot(d.sessions)
141+ if len(locals) == 0 {
140 return nil, ports.ErrNoSuchTarget, "no live sessions"142 return nil, ports.ErrNoSuchTarget, "no live sessions"
141 }143 }
142- if len(d.sessions) != 1 {144+ if len(locals) != 1 {
143 return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane"145 return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane"
144 }146 }
145- for _, sess := range d.sessions {147+ for _, sess := range locals {
146 return sess, 0, ""148 return sess, 0, ""
147 }149 }
148 panic("unreachable")150 panic("unreachable")
@@ -570,10 +572,7 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) {
570 Active bool `json:"active"`572 Active bool `json:"active"`
571 }573 }
572 e.d.mu.Lock()574 e.d.mu.Lock()
573- sessions := make([]*session, 0, len(e.d.sessions))575+ sessions := localSessionsSnapshot(e.d.sessions)
574- for _, sess := range e.d.sessions {
575- sessions = append(sessions, sess)
576- }
577 e.d.mu.Unlock()576 e.d.mu.Unlock()
578 snaps := make([]sessionView, 0, len(sessions))577 snaps := make([]sessionView, 0, len(sessions))
579 for _, sess := range sessions {578 for _, sess := range sessions {
@@ -610,10 +609,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
610 return "", command.ErrInvalidArguments609 return "", command.ErrInvalidArguments
611 }610 }
612 e.d.mu.Lock()611 e.d.mu.Lock()
613- sessions := make([]*session, 0, len(e.d.sessions))612+ sessions := localSessionsSnapshot(e.d.sessions)
614- for _, sess := range e.d.sessions {
615- sessions = append(sessions, sess)
616- }
617 e.d.mu.Unlock()613 e.d.mu.Unlock()
618 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))614 rows := make([]ports.RemoteCatalogSession, 0, len(sessions))
619 // Stopped sessions live in d.stopped and are intentionally not in this listing.615 // Stopped sessions live in d.stopped and are intentionally not in this listing.
M internal/usecase/daemon/control_test.go +3 -5
@@ -1149,9 +1149,8 @@ func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session {
11491149 ctx, cancel := context.WithCancel(d.serveCtx)
11501150 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
11511151 tb.ctx, tb.cancel = context.WithCancel(ctx)
1152- sess := &session{
1153- id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},
1154- ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0,
1152+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},
1153+ ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0,
11551154 }
11561155 publishTiledPaneOwners(sess, tb)
11571156 d.mu.Lock()
@@ -1226,8 +1225,7 @@ func (f *controlPTYFactory) close() {
12261225 func addControlSession(d *Daemon, name, tabID, paneID string) *session {
12271226 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
12281227 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
1229- sess := &session{
1230- id: domain.SessionID("sess-" + name), name: name, ephemeral: true, cwd: "/tmp/" + name,
1228+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: true}, cwd: "/tmp/" + name,
12311229 ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
12321230 }
12331231 sess.mruAt.Store(1)
@@ -1149,9 +1149,8 @@ func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session {
1149 ctx, cancel := context.WithCancel(d.serveCtx)1149 ctx, cancel := context.WithCancel(d.serveCtx)
1150 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})1150 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1151 tb.ctx, tb.cancel = context.WithCancel(ctx)1151 tb.ctx, tb.cancel = context.WithCancel(ctx)
1152- sess := &session{1152+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},
1153- id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},1153+ ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0,
1154- ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0,
1155 }1154 }
1156 publishTiledPaneOwners(sess, tb)1155 publishTiledPaneOwners(sess, tb)
1157 d.mu.Lock()1156 d.mu.Lock()
@@ -1226,8 +1225,7 @@ func (f *controlPTYFactory) close() {
1226 func addControlSession(d *Daemon, name, tabID, paneID string) *session {1225 func addControlSession(d *Daemon, name, tabID, paneID string) *session {
1227 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})1226 tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1228 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)1227 tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
1229- sess := &session{1228+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: true}, cwd: "/tmp/" + name,
1230- id: domain.SessionID("sess-" + name), name: name, ephemeral: true, cwd: "/tmp/" + name,
1231 ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},1229 ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
1232 }1230 }
1233 sess.mruAt.Store(1)1231 sess.mruAt.Store(1)
M internal/usecase/daemon/copymode_test.go +1 -1
@@ -48,7 +48,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin
4848 ac := &attachedClient{output: newOutputStateStream()}
4949 failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)}
5050 ac.replaceTransport(failed)
51- sess := &session{name: "work", client: ac}
51+ sess := &session{sessionCore: sessionCore{name: "work", client: ac}}
5252 ac.setSession(sess)
5353
5454 used, err := d.boundedSendOutputErrTransport(ac, []byte("copy"))
@@ -48,7 +48,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin
48 ac := &attachedClient{output: newOutputStateStream()}48 ac := &attachedClient{output: newOutputStateStream()}
49 failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)}49 failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)}
50 ac.replaceTransport(failed)50 ac.replaceTransport(failed)
51- sess := &session{name: "work", client: ac}51+ sess := &session{sessionCore: sessionCore{name: "work", client: ac}}
52 ac.setSession(sess)52 ac.setSession(sess)
53 53
54 used, err := d.boundedSendOutputErrTransport(ac, []byte("copy"))54 used, err := d.boundedSendOutputErrTransport(ac, []byte("copy"))
M internal/usecase/daemon/daemon.go +36 -8
@@ -69,7 +69,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24}
6969
7070 type Daemon struct {
7171 mu sync.Mutex
72- sessions map[domain.SessionID]*session
72+ sessions map[domain.SessionID]attachmentSession
7373 stopped map[string]stoppedSession
7474 // creating reserves names while durable creation I/O runs without mu.
7575 creating map[string]struct{}
@@ -576,7 +576,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
576576 }
577577 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
578578 d := &Daemon{
579- sessions: make(map[domain.SessionID]*session),
579+ sessions: make(map[domain.SessionID]attachmentSession),
580580 stopped: make(map[string]stoppedSession),
581581 creating: make(map[string]struct{}),
582582 parked: make(map[uint64]*parkedAttachment),
@@ -896,12 +896,36 @@ func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, c
896896 }
897897 }
898898
899-func (d *Daemon) sessionsSnapshotLocked() []*session {
900- snapshot := make([]*session, 0, len(d.sessions))
901- for _, s := range d.sessions {
902- snapshot = append(snapshot, s)
899+// registerSessionLocked publishes an exact attachment-session identity. Caller
900+// holds d.mu.
901+func (d *Daemon) registerSessionLocked(entry attachmentSession) bool {
902+ if entry == nil {
903+ return false
904+ }
905+ core := entry.core()
906+ if core == nil || core.id == "" || d.sessions[core.id] != nil {
907+ return false
903908 }
904- return snapshot
909+ d.sessions[core.id] = entry
910+ return true
911+}
912+
913+// unregisterSessionLocked removes only the exact registered identity. Caller
914+// holds d.mu.
915+func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool {
916+ if entry == nil {
917+ return false
918+ }
919+ core := entry.core()
920+ if core == nil || d.sessions[core.id] != entry {
921+ return false
922+ }
923+ delete(d.sessions, core.id)
924+ return true
925+}
926+
927+func (d *Daemon) sessionsSnapshotLocked() []*session {
928+ return localSessionsSnapshot(d.sessions)
905929 }
906930
907931 // handleConn reads the first frame off a fresh connection and routes it. A
@@ -941,7 +965,11 @@ func (d *Daemon) handleList(tr ports.Transport) {
941965 d.mu.Lock()
942966 infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))
943967 liveNames := make(map[string]struct{}, len(d.sessions))
944- for _, s := range d.sessions {
968+ for _, entry := range d.sessions {
969+ s, ok := localSession(entry)
970+ if !ok {
971+ continue
972+ }
945973 s.mu.Lock()
946974 info := ports.SessionInfo{
947975 SessionID: string(s.id),
@@ -69,7 +69,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24}
69 69
70 type Daemon struct {70 type Daemon struct {
71 mu sync.Mutex71 mu sync.Mutex
72- sessions map[domain.SessionID]*session72+ sessions map[domain.SessionID]attachmentSession
73 stopped map[string]stoppedSession73 stopped map[string]stoppedSession
74 // creating reserves names while durable creation I/O runs without mu.74 // creating reserves names while durable creation I/O runs without mu.
75 creating map[string]struct{}75 creating map[string]struct{}
@@ -576,7 +576,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
576 }576 }
577 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())577 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
578 d := &Daemon{578 d := &Daemon{
579- sessions: make(map[domain.SessionID]*session),579+ sessions: make(map[domain.SessionID]attachmentSession),
580 stopped: make(map[string]stoppedSession),580 stopped: make(map[string]stoppedSession),
581 creating: make(map[string]struct{}),581 creating: make(map[string]struct{}),
582 parked: make(map[uint64]*parkedAttachment),582 parked: make(map[uint64]*parkedAttachment),
@@ -896,12 +896,36 @@ func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, c
896 }896 }
897 }897 }
898 898
899-func (d *Daemon) sessionsSnapshotLocked() []*session {899+// registerSessionLocked publishes an exact attachment-session identity. Caller
900- snapshot := make([]*session, 0, len(d.sessions))900+// holds d.mu.
901- for _, s := range d.sessions {901+func (d *Daemon) registerSessionLocked(entry attachmentSession) bool {
902- snapshot = append(snapshot, s)902+ if entry == nil {
903+ return false
904+ }
905+ core := entry.core()
906+ if core == nil || core.id == "" || d.sessions[core.id] != nil {
907+ return false
903 }908 }
904- return snapshot909+ d.sessions[core.id] = entry
910+ return true
911+}
912+
913+// unregisterSessionLocked removes only the exact registered identity. Caller
914+// holds d.mu.
915+func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool {
916+ if entry == nil {
917+ return false
918+ }
919+ core := entry.core()
920+ if core == nil || d.sessions[core.id] != entry {
921+ return false
922+ }
923+ delete(d.sessions, core.id)
924+ return true
925+}
926+
927+func (d *Daemon) sessionsSnapshotLocked() []*session {
928+ return localSessionsSnapshot(d.sessions)
905 }929 }
906 930
907 // handleConn reads the first frame off a fresh connection and routes it. A931 // handleConn reads the first frame off a fresh connection and routes it. A
@@ -941,7 +965,11 @@ func (d *Daemon) handleList(tr ports.Transport) {
941 d.mu.Lock()965 d.mu.Lock()
942 infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))966 infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped))
943 liveNames := make(map[string]struct{}, len(d.sessions))967 liveNames := make(map[string]struct{}, len(d.sessions))
944- for _, s := range d.sessions {968+ for _, entry := range d.sessions {
969+ s, ok := localSession(entry)
970+ if !ok {
971+ continue
972+ }
945 s.mu.Lock()973 s.mu.Lock()
946 info := ports.SessionInfo{974 info := ports.SessionInfo{
947 SessionID: string(s.id),975 SessionID: string(s.id),
M internal/usecase/daemon/daemon_test.go +5 -3
@@ -297,8 +297,10 @@ func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Fram
297297 func firstSession(d *Daemon) *session {
298298 d.mu.Lock()
299299 defer d.mu.Unlock()
300- for _, s := range d.sessions {
301- return s
300+ for _, entry := range d.sessions {
301+ if s, ok := localSession(entry); ok {
302+ return s
303+ }
302304 }
303305 return nil
304306 }
@@ -411,7 +413,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
411413 }
412414 tabs = append(tabs, tb)
413415 }
414- sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}
416+ sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: tabs}
415417 for _, tb := range tabs {
416418 publishTiledPaneOwners(sess, tb)
417419 }
@@ -297,8 +297,10 @@ func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Fram
297 func firstSession(d *Daemon) *session {297 func firstSession(d *Daemon) *session {
298 d.mu.Lock()298 d.mu.Lock()
299 defer d.mu.Unlock()299 defer d.mu.Unlock()
300- for _, s := range d.sessions {300+ for _, entry := range d.sessions {
301- return s301+ if s, ok := localSession(entry); ok {
302+ return s
303+ }
302 }304 }
303 return nil305 return nil
304 }306 }
@@ -411,7 +413,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis
411 }413 }
412 tabs = append(tabs, tb)414 tabs = append(tabs, tb)
413 }415 }
414- sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}416+ sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: tabs}
415 for _, tb := range tabs {417 for _, tb := range tabs {
416 publishTiledPaneOwners(sess, tb)418 publishTiledPaneOwners(sess, tb)
417 }419 }
M internal/usecase/daemon/durable_recovery_test.go +90 -1
@@ -73,6 +73,23 @@ func mustDurableRecords(t *testing.T, c *durableRecoveryCatalogue) []domain.Cata
7373 return records
7474 }
7575
76+func (c *durableRecoveryCatalogue) Create(record domain.CatalogueRecord) error {
77+ c.mu.Lock()
78+ defer c.mu.Unlock()
79+ if _, exists := c.records[record.Name]; exists {
80+ return errors.New("catalogue record already exists")
81+ }
82+ c.records[record.Name] = record
83+ return nil
84+}
85+
86+func (c *durableRecoveryCatalogue) Delete(name string) error {
87+ c.mu.Lock()
88+ defer c.mu.Unlock()
89+ delete(c.records, name)
90+ return nil
91+}
92+
7693 func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error {
7794 c.mu.Lock()
7895 defer c.mu.Unlock()
@@ -645,6 +662,78 @@ func TestFinishRecordRestoreDoesNotDegradeStaleCatalogueAuthority(t *testing.T)
645662 }
646663 }
647664
665+func TestPersistAndRegisterRestoredSessionRegistrationFailurePreservesStoppedEntry(t *testing.T) {
666+ record := durableRecoveryRecord(0)
667+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
668+ d.mu.Lock()
669+ before := d.stopped[record.Name]
670+ collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
671+ d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}}
672+ d.mu.Unlock()
673+ sess := newSnapshotTestSession(t, record.Name, false, record.Cwd)
674+ sess.incarnation = record.IncarnationID
675+
676+ registered, err := d.persistAndRegisterRestoredSession(t.Context(), sess)
677+
678+ require.ErrorContains(t, err, "registry rejected")
679+ require.False(t, registered)
680+ d.mu.Lock()
681+ after := d.stopped[record.Name]
682+ d.mu.Unlock()
683+ require.Equal(t, before, after, "failed runtime publication must preserve the exact stopped authority and barrier")
684+ require.Equal(t, before.restoreDone, after.restoreDone)
685+}
686+
687+func TestCreateNamedSessionRegistrationFailureRollsBackDurableState(t *testing.T) {
688+ for _, tc := range []struct {
689+ name string
690+ records []domain.CatalogueRecord
691+ sessName string
692+ cwd string
693+ }{
694+ {name: "fresh create", sessName: "fresh", cwd: "/fresh"},
695+ {name: "stopped metadata update", records: []domain.CatalogueRecord{durableRecoveryRecord(0)}, sessName: durableRecoveryRecord(0).Name, cwd: "/resumed"},
696+ } {
697+ t.Run(tc.name, func(t *testing.T) {
698+ pty, release := newBlockingPTY(t)
699+ defer release()
700+ catalogue := newDurableRecoveryCatalogue(tc.records)
701+ coordinator := recoveryusecase.NewCoordinator(catalogue, nil, bytes.NewReader(bytes.Repeat([]byte{0x6a}, 16)))
702+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
703+ WithCatalogue(catalogue, tc.records)(d)
704+ WithRecoveryCoordinator(coordinator)(d)
705+
706+ d.mu.Lock()
707+ for _, record := range tc.records {
708+ d.stopped[record.Name] = stoppedSession{
709+ name: record.Name, cwd: record.Cwd, createdAt: record.CreatedAt,
710+ incarnation: record.IncarnationID, lastUsedSeq: record.LastUsedSeq,
711+ tabNames: append([]string(nil), record.TabNames...), record: record,
712+ state: ports.SessionStopped, restoreDone: make(chan struct{}),
713+ }
714+ }
715+ beforeStopped, hadStopped := d.stopped[tc.sessName]
716+ collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
717+ d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}}
718+ d.mu.Unlock()
719+ beforeRecords := mustDurableRecords(t, catalogue)
720+
721+ _, err := createSessionForTest(d, tc.sessName, false, tc.cwd, defaultSize, terminalEnv{}, d.baseEnv)
722+
723+ require.ErrorContains(t, err, "registry rejected")
724+ require.ElementsMatch(t, beforeRecords, mustDurableRecords(t, catalogue), "failed runtime publication must leave no created record or metadata mutation")
725+ d.mu.Lock()
726+ afterStopped, stillStopped := d.stopped[tc.sessName]
727+ d.mu.Unlock()
728+ require.Equal(t, hadStopped, stillStopped)
729+ if hadStopped {
730+ require.Equal(t, beforeStopped, afterStopped, "resume publication failure must restore the exact stopped state")
731+ require.Equal(t, beforeStopped.restoreDone, afterStopped.restoreDone)
732+ }
733+ })
734+ }
735+}
736+
648737 func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testing.T) {
649738 record := durableRecoveryRecord(0)
650739 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
@@ -750,7 +839,7 @@ func TestAttachBarrierSurvivesLiveRegistryPublication(t *testing.T) {
750839 d.mu.Lock()
751840 done := d.stopped[record.Name].restoreDone
752841 d.mu.Unlock()
753- sess := &session{name: record.Name, incarnation: record.IncarnationID}
842+ sess := &session{sessionCore: sessionCore{name: record.Name, incarnation: record.IncarnationID}}
754843
755844 registered, err := d.persistAndRegisterRestoredSession(t.Context(), sess)
756845 require.NoError(t, err)
@@ -73,6 +73,23 @@ func mustDurableRecords(t *testing.T, c *durableRecoveryCatalogue) []domain.Cata
73 return records73 return records
74 }74 }
75 75
76+func (c *durableRecoveryCatalogue) Create(record domain.CatalogueRecord) error {
77+ c.mu.Lock()
78+ defer c.mu.Unlock()
79+ if _, exists := c.records[record.Name]; exists {
80+ return errors.New("catalogue record already exists")
81+ }
82+ c.records[record.Name] = record
83+ return nil
84+}
85+
86+func (c *durableRecoveryCatalogue) Delete(name string) error {
87+ c.mu.Lock()
88+ defer c.mu.Unlock()
89+ delete(c.records, name)
90+ return nil
91+}
92+
76 func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error {93 func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error {
77 c.mu.Lock()94 c.mu.Lock()
78 defer c.mu.Unlock()95 defer c.mu.Unlock()
@@ -645,6 +662,78 @@ func TestFinishRecordRestoreDoesNotDegradeStaleCatalogueAuthority(t *testing.T)
645 }662 }
646 }663 }
647 664
665+func TestPersistAndRegisterRestoredSessionRegistrationFailurePreservesStoppedEntry(t *testing.T) {
666+ record := durableRecoveryRecord(0)
667+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
668+ d.mu.Lock()
669+ before := d.stopped[record.Name]
670+ collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
671+ d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}}
672+ d.mu.Unlock()
673+ sess := newSnapshotTestSession(t, record.Name, false, record.Cwd)
674+ sess.incarnation = record.IncarnationID
675+
676+ registered, err := d.persistAndRegisterRestoredSession(t.Context(), sess)
677+
678+ require.ErrorContains(t, err, "registry rejected")
679+ require.False(t, registered)
680+ d.mu.Lock()
681+ after := d.stopped[record.Name]
682+ d.mu.Unlock()
683+ require.Equal(t, before, after, "failed runtime publication must preserve the exact stopped authority and barrier")
684+ require.Equal(t, before.restoreDone, after.restoreDone)
685+}
686+
687+func TestCreateNamedSessionRegistrationFailureRollsBackDurableState(t *testing.T) {
688+ for _, tc := range []struct {
689+ name string
690+ records []domain.CatalogueRecord
691+ sessName string
692+ cwd string
693+ }{
694+ {name: "fresh create", sessName: "fresh", cwd: "/fresh"},
695+ {name: "stopped metadata update", records: []domain.CatalogueRecord{durableRecoveryRecord(0)}, sessName: durableRecoveryRecord(0).Name, cwd: "/resumed"},
696+ } {
697+ t.Run(tc.name, func(t *testing.T) {
698+ pty, release := newBlockingPTY(t)
699+ defer release()
700+ catalogue := newDurableRecoveryCatalogue(tc.records)
701+ coordinator := recoveryusecase.NewCoordinator(catalogue, nil, bytes.NewReader(bytes.Repeat([]byte{0x6a}, 16)))
702+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
703+ WithCatalogue(catalogue, tc.records)(d)
704+ WithRecoveryCoordinator(coordinator)(d)
705+
706+ d.mu.Lock()
707+ for _, record := range tc.records {
708+ d.stopped[record.Name] = stoppedSession{
709+ name: record.Name, cwd: record.Cwd, createdAt: record.CreatedAt,
710+ incarnation: record.IncarnationID, lastUsedSeq: record.LastUsedSeq,
711+ tabNames: append([]string(nil), record.TabNames...), record: record,
712+ state: ports.SessionStopped, restoreDone: make(chan struct{}),
713+ }
714+ }
715+ beforeStopped, hadStopped := d.stopped[tc.sessName]
716+ collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
717+ d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}}
718+ d.mu.Unlock()
719+ beforeRecords := mustDurableRecords(t, catalogue)
720+
721+ _, err := createSessionForTest(d, tc.sessName, false, tc.cwd, defaultSize, terminalEnv{}, d.baseEnv)
722+
723+ require.ErrorContains(t, err, "registry rejected")
724+ require.ElementsMatch(t, beforeRecords, mustDurableRecords(t, catalogue), "failed runtime publication must leave no created record or metadata mutation")
725+ d.mu.Lock()
726+ afterStopped, stillStopped := d.stopped[tc.sessName]
727+ d.mu.Unlock()
728+ require.Equal(t, hadStopped, stillStopped)
729+ if hadStopped {
730+ require.Equal(t, beforeStopped, afterStopped, "resume publication failure must restore the exact stopped state")
731+ require.Equal(t, beforeStopped.restoreDone, afterStopped.restoreDone)
732+ }
733+ })
734+ }
735+}
736+
648 func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testing.T) {737 func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testing.T) {
649 record := durableRecoveryRecord(0)738 record := durableRecoveryRecord(0)
650 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})739 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
@@ -750,7 +839,7 @@ func TestAttachBarrierSurvivesLiveRegistryPublication(t *testing.T) {
750 d.mu.Lock()839 d.mu.Lock()
751 done := d.stopped[record.Name].restoreDone840 done := d.stopped[record.Name].restoreDone
752 d.mu.Unlock()841 d.mu.Unlock()
753- sess := &session{name: record.Name, incarnation: record.IncarnationID}842+ sess := &session{sessionCore: sessionCore{name: record.Name, incarnation: record.IncarnationID}}
754 843
755 registered, err := d.persistAndRegisterRestoredSession(t.Context(), sess)844 registered, err := d.persistAndRegisterRestoredSession(t.Context(), sess)
756 require.NoError(t, err)845 require.NoError(t, err)
M internal/usecase/daemon/floating_open_test.go +5 -5
@@ -107,7 +107,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
107107 tabCtx, cancelTab := context.WithCancel(t.Context())
108108 tb.ctx, tb.cancel = tabCtx, cancelTab
109109 sessCtx, cancelSession := context.WithCancel(t.Context())
110- sess := &session{id: "context-" + domain.SessionID(tt.name), name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}
110+ sess := &session{sessionCore: sessionCore{id: "context-" + domain.SessionID(tt.name), name: "work"}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}
111111 d.mu.Lock()
112112 d.sessions[sess.id] = sess
113113 d.mu.Unlock()
@@ -228,7 +228,7 @@ func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing.
228228 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
229229 ac.initOverlays()
230230 sessCtx, cancelSession := context.WithCancel(t.Context())
231- sess := &session{id: "floating-open-error", name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession, client: ac}
231+ sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", client: ac}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}
232232 ac.setSession(sess)
233233
234234 // Close must be safe to run before failure publication and without
@@ -350,7 +350,7 @@ func TestFloatingFailedAndStaleOpenCancelContext(t *testing.T) {
350350 factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)}
351351 d := newTestDaemon(t, factory, stubClock{})
352352 tb := newFloatingTestTab(t)
353- sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()}
353+ sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
354354 tb.mu.Lock()
355355 generation := tb.beginFloatingWarmLocked(true)
356356 tb.mu.Unlock()
@@ -400,7 +400,7 @@ func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testin
400400 d := newTestDaemon(t, factory, stubClock{})
401401 tb := newFloatingTestTab(t)
402402 ctx, cancel := context.WithCancel(t.Context())
403- sess := &session{id: "blocked-open", name: "work", tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
403+ sess := &session{sessionCore: sessionCore{id: "blocked-open", name: "work"}, tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
404404 d.mu.Lock()
405405 d.sessions[sess.id] = sess
406406 d.mu.Unlock()
@@ -446,7 +446,7 @@ func TestFloatingOpensFromUnrelatedSessionsAreConcurrent(t *testing.T) {
446446 for _, id := range []domain.SessionID{"one", "two"} {
447447 tb := newFloatingTestTab(t)
448448 ctx, cancel := context.WithCancel(t.Context())
449- sess := &session{id: id, name: string(id), tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
449+ sess := &session{sessionCore: sessionCore{id: id, name: string(id)}, tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
450450 tabs = append(tabs, tb)
451451 cancels = append(cancels, cancel)
452452 d.startFloating(sess, tb, false)
@@ -107,7 +107,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
107 tabCtx, cancelTab := context.WithCancel(t.Context())107 tabCtx, cancelTab := context.WithCancel(t.Context())
108 tb.ctx, tb.cancel = tabCtx, cancelTab108 tb.ctx, tb.cancel = tabCtx, cancelTab
109 sessCtx, cancelSession := context.WithCancel(t.Context())109 sessCtx, cancelSession := context.WithCancel(t.Context())
110- sess := &session{id: "context-" + domain.SessionID(tt.name), name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}110+ sess := &session{sessionCore: sessionCore{id: "context-" + domain.SessionID(tt.name), name: "work"}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}
111 d.mu.Lock()111 d.mu.Lock()
112 d.sessions[sess.id] = sess112 d.sessions[sess.id] = sess
113 d.mu.Unlock()113 d.mu.Unlock()
@@ -228,7 +228,7 @@ func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing.
228 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}228 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
229 ac.initOverlays()229 ac.initOverlays()
230 sessCtx, cancelSession := context.WithCancel(t.Context())230 sessCtx, cancelSession := context.WithCancel(t.Context())
231- sess := &session{id: "floating-open-error", name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession, client: ac}231+ sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", client: ac}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}
232 ac.setSession(sess)232 ac.setSession(sess)
233 233
234 // Close must be safe to run before failure publication and without234 // Close must be safe to run before failure publication and without
@@ -350,7 +350,7 @@ func TestFloatingFailedAndStaleOpenCancelContext(t *testing.T) {
350 factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)}350 factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)}
351 d := newTestDaemon(t, factory, stubClock{})351 d := newTestDaemon(t, factory, stubClock{})
352 tb := newFloatingTestTab(t)352 tb := newFloatingTestTab(t)
353- sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()}353+ sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
354 tb.mu.Lock()354 tb.mu.Lock()
355 generation := tb.beginFloatingWarmLocked(true)355 generation := tb.beginFloatingWarmLocked(true)
356 tb.mu.Unlock()356 tb.mu.Unlock()
@@ -400,7 +400,7 @@ func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testin
400 d := newTestDaemon(t, factory, stubClock{})400 d := newTestDaemon(t, factory, stubClock{})
401 tb := newFloatingTestTab(t)401 tb := newFloatingTestTab(t)
402 ctx, cancel := context.WithCancel(t.Context())402 ctx, cancel := context.WithCancel(t.Context())
403- sess := &session{id: "blocked-open", name: "work", tabs: []*tab{tb}, ctx: ctx, cancel: cancel}403+ sess := &session{sessionCore: sessionCore{id: "blocked-open", name: "work"}, tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
404 d.mu.Lock()404 d.mu.Lock()
405 d.sessions[sess.id] = sess405 d.sessions[sess.id] = sess
406 d.mu.Unlock()406 d.mu.Unlock()
@@ -446,7 +446,7 @@ func TestFloatingOpensFromUnrelatedSessionsAreConcurrent(t *testing.T) {
446 for _, id := range []domain.SessionID{"one", "two"} {446 for _, id := range []domain.SessionID{"one", "two"} {
447 tb := newFloatingTestTab(t)447 tb := newFloatingTestTab(t)
448 ctx, cancel := context.WithCancel(t.Context())448 ctx, cancel := context.WithCancel(t.Context())
449- sess := &session{id: id, name: string(id), tabs: []*tab{tb}, ctx: ctx, cancel: cancel}449+ sess := &session{sessionCore: sessionCore{id: id, name: string(id)}, tabs: []*tab{tb}, ctx: ctx, cancel: cancel}
450 tabs = append(tabs, tb)450 tabs = append(tabs, tb)
451 cancels = append(cancels, cancel)451 cancels = append(cancels, cancel)
452 d.startFloating(sess, tb, false)452 d.startFloating(sess, tb, false)
M internal/usecase/daemon/floating_render_test.go +2 -2
@@ -169,7 +169,7 @@ func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testi
169169 d := newTestDaemon(t, nil, stubClock{})
170170 d.ApplyConfig(domain.Config{Floating: cfg})
171171 tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
172- sess := &session{name: "test", tabs: []*tab{tb}, ctx: t.Context()}
172+ sess := &session{sessionCore: sessionCore{name: "test"}, tabs: []*tab{tb}, ctx: t.Context()}
173173 spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, true)
174174 require.NoError(t, err)
175175 require.Equal(t, geometry, spec.geometry, "the 1x1 PTY fallback must not replace drawer presentation")
@@ -595,7 +595,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
595595 installTestFloating(tb, p, true)
596596 ac := &attachedClient{}
597597 ac.initOverlays()
598- sess := &session{tabs: []*tab{tb}, client: ac}
598+ sess := &session{sessionCore: sessionCore{client: ac}, tabs: []*tab{tb}}
599599 base := barState{}
600600 cfg := domain.FloatingConfig{Width: 100, Height: 100}
601601
@@ -169,7 +169,7 @@ func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testi
169 d := newTestDaemon(t, nil, stubClock{})169 d := newTestDaemon(t, nil, stubClock{})
170 d.ApplyConfig(domain.Config{Floating: cfg})170 d.ApplyConfig(domain.Config{Floating: cfg})
171 tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})171 tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
172- sess := &session{name: "test", tabs: []*tab{tb}, ctx: t.Context()}172+ sess := &session{sessionCore: sessionCore{name: "test"}, tabs: []*tab{tb}, ctx: t.Context()}
173 spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, true)173 spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, true)
174 require.NoError(t, err)174 require.NoError(t, err)
175 require.Equal(t, geometry, spec.geometry, "the 1x1 PTY fallback must not replace drawer presentation")175 require.Equal(t, geometry, spec.geometry, "the 1x1 PTY fallback must not replace drawer presentation")
@@ -595,7 +595,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
595 installTestFloating(tb, p, true)595 installTestFloating(tb, p, true)
596 ac := &attachedClient{}596 ac := &attachedClient{}
597 ac.initOverlays()597 ac.initOverlays()
598- sess := &session{tabs: []*tab{tb}, client: ac}598+ sess := &session{sessionCore: sessionCore{client: ac}, tabs: []*tab{tb}}
599 base := barState{}599 base := barState{}
600 cfg := domain.FloatingConfig{Width: 100, Height: 100}600 cfg := domain.FloatingConfig{Width: 100, Height: 100}
601 601
M internal/usecase/daemon/floating_test.go +10 -10
@@ -172,7 +172,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
172172 cwd := t.TempDir()
173173 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
174174 tb.ctx, tb.cancel = context.WithCancel(t.Context())
175- sess := &session{name: "work", cwd: cwd, terminal: terminalEnv{TrueColor: true}, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}
175+ sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, terminal: terminalEnv{TrueColor: true}, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}
176176 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
177177 d.ensureFloatingWarm(sess, tb)
178178 // Open has started while this goroutine owns tab.mu: an external factory
@@ -239,7 +239,7 @@ func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) {
239239 t.Cleanup(cancelTab)
240240 tb := newTab(&transactionalResizePTY{}, tabSize(initial))
241241 tb.ctx, tb.cancel = tabCtx, cancelTab
242- sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()}
242+ sess := &session{sessionCore: sessionCore{id: "responsive-open", name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
243243
244244 d.startFloating(sess, tb, true)
245245 openCall := <-opened
@@ -294,7 +294,7 @@ func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {
294294 d := newTestDaemon(t, factory, stubClock{})
295295 tb := newFloatingTestTab(t)
296296 sessCtx, cancel := context.WithCancel(t.Context())
297- sess := &session{id: "floating-ownership", name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel}
297+ sess := &session{sessionCore: sessionCore{id: "floating-ownership", name: "work"}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel}
298298 d.mu.Lock()
299299 d.sessions[sess.id] = sess
300300 d.mu.Unlock()
@@ -345,7 +345,7 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test
345345 factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
346346 d := newTestDaemon(t, factory, stubClock{})
347347 tb := newFloatingTestTab(t)
348- sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()}
348+ sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
349349 d.ensureFloatingWarm(sess, tb)
350350 <-opened
351351 d.teardownFloating(tb, nil) // invalidate before the delayed Open returns
@@ -378,8 +378,8 @@ func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
378378 factory := &lifetimePTYFactory{}
379379 d := newTestDaemon(t, factory, stubClock{})
380380 tb := newFloatingTestTab(t)
381- source := &session{id: "source", name: "source", tabs: []*tab{tb}, ctx: t.Context()}
382- destination := &session{id: "destination", name: "destination", tabs: []*tab{tb}, ctx: t.Context()}
381+ source := &session{sessionCore: sessionCore{id: "source", name: "source"}, tabs: []*tab{tb}, ctx: t.Context()}
382+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{tb}, ctx: t.Context()}
383383
384384 tb.mu.Lock()
385385 generation := tb.beginFloatingWarmLocked(false)
@@ -630,7 +630,7 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
630630 d.procCwd = func(int) (string, error) { return tt.liveCwd, nil }
631631 }
632632 tb := newFloatingTestTab(t)
633- sess := &session{name: "work", cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()}
633+ sess := &session{sessionCore: sessionCore{name: "work"}, cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()}
634634 d.startFloating(sess, tb, false)
635635 select {
636636 case got := <-opened:
@@ -668,7 +668,7 @@ func TestFloatingLaunchFailureCapturesSessionNameBeforeConcurrentRename(t *testi
668668 d := newTestDaemon(t, factory, stubClock{})
669669 d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
670670 tb := newFloatingTestTab(t)
671- sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()}
671+ sess := &session{sessionCore: sessionCore{name: "captured"}, tabs: []*tab{tb}, ctx: t.Context()}
672672 d.startFloating(sess, tb, false)
673673 <-opened
674674 stopRename := make(chan struct{})
@@ -706,7 +706,7 @@ func TestFloatingInstallLogsSessionNameSafelyDuringRename(t *testing.T) {
706706 d := newTestDaemon(t, factory, stubClock{})
707707 d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
708708 tb := newFloatingTestTab(t)
709- sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()}
709+ sess := &session{sessionCore: sessionCore{name: "captured"}, tabs: []*tab{tb}, ctx: t.Context()}
710710
711711 d.startFloating(sess, tb, false)
712712 <-opened
@@ -770,7 +770,7 @@ func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) {
770770 tr, _ := newCapturingTransport(t)
771771 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
772772 ac.initOverlays()
773- sess := &session{id: "floating-spawn", name: "work", tabs: []*tab{tb}, ctx: t.Context(), client: ac}
773+ sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", client: ac}, tabs: []*tab{tb}, ctx: t.Context()}
774774 ac.setSession(sess)
775775
776776 d.startFloating(sess, tb, tc.userOpen)
@@ -172,7 +172,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
172 cwd := t.TempDir()172 cwd := t.TempDir()
173 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})173 tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
174 tb.ctx, tb.cancel = context.WithCancel(t.Context())174 tb.ctx, tb.cancel = context.WithCancel(t.Context())
175- sess := &session{name: "work", cwd: cwd, terminal: terminalEnv{TrueColor: true}, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}175+ sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, terminal: terminalEnv{TrueColor: true}, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()}
176 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})176 d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
177 d.ensureFloatingWarm(sess, tb)177 d.ensureFloatingWarm(sess, tb)
178 // Open has started while this goroutine owns tab.mu: an external factory178 // Open has started while this goroutine owns tab.mu: an external factory
@@ -239,7 +239,7 @@ func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) {
239 t.Cleanup(cancelTab)239 t.Cleanup(cancelTab)
240 tb := newTab(&transactionalResizePTY{}, tabSize(initial))240 tb := newTab(&transactionalResizePTY{}, tabSize(initial))
241 tb.ctx, tb.cancel = tabCtx, cancelTab241 tb.ctx, tb.cancel = tabCtx, cancelTab
242- sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()}242+ sess := &session{sessionCore: sessionCore{id: "responsive-open", name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
243 243
244 d.startFloating(sess, tb, true)244 d.startFloating(sess, tb, true)
245 openCall := <-opened245 openCall := <-opened
@@ -294,7 +294,7 @@ func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {
294 d := newTestDaemon(t, factory, stubClock{})294 d := newTestDaemon(t, factory, stubClock{})
295 tb := newFloatingTestTab(t)295 tb := newFloatingTestTab(t)
296 sessCtx, cancel := context.WithCancel(t.Context())296 sessCtx, cancel := context.WithCancel(t.Context())
297- sess := &session{id: "floating-ownership", name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel}297+ sess := &session{sessionCore: sessionCore{id: "floating-ownership", name: "work"}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel}
298 d.mu.Lock()298 d.mu.Lock()
299 d.sessions[sess.id] = sess299 d.sessions[sess.id] = sess
300 d.mu.Unlock()300 d.mu.Unlock()
@@ -345,7 +345,7 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test
345 factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)345 factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil)
346 d := newTestDaemon(t, factory, stubClock{})346 d := newTestDaemon(t, factory, stubClock{})
347 tb := newFloatingTestTab(t)347 tb := newFloatingTestTab(t)
348- sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()}348+ sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()}
349 d.ensureFloatingWarm(sess, tb)349 d.ensureFloatingWarm(sess, tb)
350 <-opened350 <-opened
351 d.teardownFloating(tb, nil) // invalidate before the delayed Open returns351 d.teardownFloating(tb, nil) // invalidate before the delayed Open returns
@@ -378,8 +378,8 @@ func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) {
378 factory := &lifetimePTYFactory{}378 factory := &lifetimePTYFactory{}
379 d := newTestDaemon(t, factory, stubClock{})379 d := newTestDaemon(t, factory, stubClock{})
380 tb := newFloatingTestTab(t)380 tb := newFloatingTestTab(t)
381- source := &session{id: "source", name: "source", tabs: []*tab{tb}, ctx: t.Context()}381+ source := &session{sessionCore: sessionCore{id: "source", name: "source"}, tabs: []*tab{tb}, ctx: t.Context()}
382- destination := &session{id: "destination", name: "destination", tabs: []*tab{tb}, ctx: t.Context()}382+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{tb}, ctx: t.Context()}
383 383
384 tb.mu.Lock()384 tb.mu.Lock()
385 generation := tb.beginFloatingWarmLocked(false)385 generation := tb.beginFloatingWarmLocked(false)
@@ -630,7 +630,7 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) {
630 d.procCwd = func(int) (string, error) { return tt.liveCwd, nil }630 d.procCwd = func(int) (string, error) { return tt.liveCwd, nil }
631 }631 }
632 tb := newFloatingTestTab(t)632 tb := newFloatingTestTab(t)
633- sess := &session{name: "work", cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()}633+ sess := &session{sessionCore: sessionCore{name: "work"}, cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()}
634 d.startFloating(sess, tb, false)634 d.startFloating(sess, tb, false)
635 select {635 select {
636 case got := <-opened:636 case got := <-opened:
@@ -668,7 +668,7 @@ func TestFloatingLaunchFailureCapturesSessionNameBeforeConcurrentRename(t *testi
668 d := newTestDaemon(t, factory, stubClock{})668 d := newTestDaemon(t, factory, stubClock{})
669 d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})669 d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
670 tb := newFloatingTestTab(t)670 tb := newFloatingTestTab(t)
671- sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()}671+ sess := &session{sessionCore: sessionCore{name: "captured"}, tabs: []*tab{tb}, ctx: t.Context()}
672 d.startFloating(sess, tb, false)672 d.startFloating(sess, tb, false)
673 <-opened673 <-opened
674 stopRename := make(chan struct{})674 stopRename := make(chan struct{})
@@ -706,7 +706,7 @@ func TestFloatingInstallLogsSessionNameSafelyDuringRename(t *testing.T) {
706 d := newTestDaemon(t, factory, stubClock{})706 d := newTestDaemon(t, factory, stubClock{})
707 d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})707 d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled})
708 tb := newFloatingTestTab(t)708 tb := newFloatingTestTab(t)
709- sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()}709+ sess := &session{sessionCore: sessionCore{name: "captured"}, tabs: []*tab{tb}, ctx: t.Context()}
710 710
711 d.startFloating(sess, tb, false)711 d.startFloating(sess, tb, false)
712 <-opened712 <-opened
@@ -770,7 +770,7 @@ func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) {
770 tr, _ := newCapturingTransport(t)770 tr, _ := newCapturingTransport(t)
771 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}771 ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
772 ac.initOverlays()772 ac.initOverlays()
773- sess := &session{id: "floating-spawn", name: "work", tabs: []*tab{tb}, ctx: t.Context(), client: ac}773+ sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", client: ac}, tabs: []*tab{tb}, ctx: t.Context()}
774 ac.setSession(sess)774 ac.setSession(sess)
775 775
776 d.startFloating(sess, tb, tc.userOpen)776 d.startFloating(sess, tb, tc.userOpen)
M internal/usecase/daemon/input.go +8 -2
@@ -310,7 +310,8 @@ type daemonKeyHandler struct {
310310 func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) {
311311 if h.roleToken.ac != nil {
312312 if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {
313- return h.roleToken.sess, effect, false
313+ sess, _ := localSession(h.roleToken.sess)
314+ return sess, effect, false
314315 }
315316 effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)
316317 if h.d.afterDelayedKeyEffectAttempt != nil {
@@ -324,7 +325,12 @@ func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool
324325 token.effect = effect
325326 h.d.afterRoleEffectAdmitted(token)
326327 }
327- return h.roleToken.sess, effect, true
328+ sess, ok := localSession(h.roleToken.sess)
329+ if !ok {
330+ effect.End()
331+ return nil, nil, false
332+ }
333+ return sess, effect, true
328334 }
329335 sess := h.ac.currentSession()
330336 if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched {
@@ -310,7 +310,8 @@ type daemonKeyHandler struct {
310 func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) {310 func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) {
311 if h.roleToken.ac != nil {311 if h.roleToken.ac != nil {
312 if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {312 if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {
313- return h.roleToken.sess, effect, false313+ sess, _ := localSession(h.roleToken.sess)
314+ return sess, effect, false
314 }315 }
315 effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)316 effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)
316 if h.d.afterDelayedKeyEffectAttempt != nil {317 if h.d.afterDelayedKeyEffectAttempt != nil {
@@ -324,7 +325,12 @@ func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool
324 token.effect = effect325 token.effect = effect
325 h.d.afterRoleEffectAdmitted(token)326 h.d.afterRoleEffectAdmitted(token)
326 }327 }
327- return h.roleToken.sess, effect, true328+ sess, ok := localSession(h.roleToken.sess)
329+ if !ok {
330+ effect.End()
331+ return nil, nil, false
332+ }
333+ return sess, effect, true
328 }334 }
329 sess := h.ac.currentSession()335 sess := h.ac.currentSession()
330 if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched {336 if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched {
M internal/usecase/daemon/input_test.go +13 -11
@@ -354,8 +354,10 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
354354 d.mu.Lock()
355355 var sess *session
356356 for _, candidate := range d.sessions {
357- sess = candidate
358- break
357+ if local, ok := localSession(candidate); ok {
358+ sess = local
359+ break
360+ }
359361 }
360362 d.mu.Unlock()
361363 require.NotNil(t, sess)
@@ -971,7 +973,7 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
971973 func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) {
972974 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
973975 defer releaseAll(releases)
974- recent := d.sessions[domain.SessionID("recent")]
976+ recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
975977
976978 // Picker-style successful transition records its origin.
977979 require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1}))
@@ -989,7 +991,7 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
989991 prepare func()
990992 }{
991993 {name: "no target", prepare: func() {}},
992- {name: "stale target", prepare: func() { ac.previousSession.Set(&session{id: "gone"}) }},
994+ {name: "stale target", prepare: func() { ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},
993995 {name: "same session", prepare: func() { ac.previousSession.Set(current) }},
994996 } {
995997 t.Run(tc.name, func(t *testing.T) {
@@ -1027,8 +1029,8 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
10271029
10281030 func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
10291031 ac := &attachedClient{}
1030- stale := &session{id: "stale"}
1031- updated := &session{id: "updated"}
1032+ stale := &session{sessionCore: sessionCore{id: "stale"}}
1033+ updated := &session{sessionCore: sessionCore{id: "updated"}}
10321034 ac.previousSession.Set(stale)
10331035
10341036 // Model a completed hand-off between observing a stale target and clearing
@@ -1051,7 +1053,7 @@ func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
10511053 }{
10521054 {name: "no previous target", prepare: func(*session, *attachedClient) {}},
10531055 {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {
1054- ac.previousSession.Set(&session{id: "gone"})
1056+ ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}})
10551057 }},
10561058 {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
10571059 ac.previousSession.Set(current)
@@ -1121,7 +1123,7 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
11211123 t.Run(tc.name, func(t *testing.T) {
11221124 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
11231125 defer releaseAll(releases)
1124- recent := d.sessions[domain.SessionID("recent")]
1126+ recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
11251127
11261128 if tc.name == "missing target does not record" {
11271129 require.Error(t, d.switchToTarget(current, ac, tc.target(current, recent)))
@@ -1133,7 +1135,7 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
11331135 require.Nil(t, ac.previousSession.Get())
11341136 return
11351137 }
1136- require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get().id)
1138+ require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get().core().id)
11371139 })
11381140 }
11391141 }
@@ -1170,8 +1172,8 @@ func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attached
11701172 current.id = "current"
11711173 delete(d.sessions, domain.SessionID("manual"))
11721174 d.sessions[current.id] = current
1173- recent := &session{id: "recent", name: "recent", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
1174- older := &session{id: "older", name: "older", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}
1175+ recent := &session{sessionCore: sessionCore{id: "recent", name: "recent"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
1176+ older := &session{sessionCore: sessionCore{id: "older", name: "older"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}
11751177 d.sessions[recent.id] = recent
11761178 d.sessions[older.id] = older
11771179 current.mruAt.Store(30)
@@ -354,8 +354,10 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
354 d.mu.Lock()354 d.mu.Lock()
355 var sess *session355 var sess *session
356 for _, candidate := range d.sessions {356 for _, candidate := range d.sessions {
357- sess = candidate357+ if local, ok := localSession(candidate); ok {
358- break358+ sess = local
359+ break
360+ }
359 }361 }
360 d.mu.Unlock()362 d.mu.Unlock()
361 require.NotNil(t, sess)363 require.NotNil(t, sess)
@@ -971,7 +973,7 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
971 func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) {973 func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) {
972 d, current, ac, _, releases := newRecentNavigationTestSessions(t)974 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
973 defer releaseAll(releases)975 defer releaseAll(releases)
974- recent := d.sessions[domain.SessionID("recent")]976+ recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
975 977
976 // Picker-style successful transition records its origin.978 // Picker-style successful transition records its origin.
977 require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1}))979 require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1}))
@@ -989,7 +991,7 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) {
989 prepare func()991 prepare func()
990 }{992 }{
991 {name: "no target", prepare: func() {}},993 {name: "no target", prepare: func() {}},
992- {name: "stale target", prepare: func() { ac.previousSession.Set(&session{id: "gone"}) }},994+ {name: "stale target", prepare: func() { ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}}) }},
993 {name: "same session", prepare: func() { ac.previousSession.Set(current) }},995 {name: "same session", prepare: func() { ac.previousSession.Set(current) }},
994 } {996 } {
995 t.Run(tc.name, func(t *testing.T) {997 t.Run(tc.name, func(t *testing.T) {
@@ -1027,8 +1029,8 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) {
1027 1029
1028 func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {1030 func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) {
1029 ac := &attachedClient{}1031 ac := &attachedClient{}
1030- stale := &session{id: "stale"}1032+ stale := &session{sessionCore: sessionCore{id: "stale"}}
1031- updated := &session{id: "updated"}1033+ updated := &session{sessionCore: sessionCore{id: "updated"}}
1032 ac.previousSession.Set(stale)1034 ac.previousSession.Set(stale)
1033 1035
1034 // Model a completed hand-off between observing a stale target and clearing1036 // Model a completed hand-off between observing a stale target and clearing
@@ -1051,7 +1053,7 @@ func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) {
1051 }{1053 }{
1052 {name: "no previous target", prepare: func(*session, *attachedClient) {}},1054 {name: "no previous target", prepare: func(*session, *attachedClient) {}},
1053 {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {1055 {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) {
1054- ac.previousSession.Set(&session{id: "gone"})1056+ ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}})
1055 }},1057 }},
1056 {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {1058 {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) {
1057 ac.previousSession.Set(current)1059 ac.previousSession.Set(current)
@@ -1121,7 +1123,7 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
1121 t.Run(tc.name, func(t *testing.T) {1123 t.Run(tc.name, func(t *testing.T) {
1122 d, current, ac, _, releases := newRecentNavigationTestSessions(t)1124 d, current, ac, _, releases := newRecentNavigationTestSessions(t)
1123 defer releaseAll(releases)1125 defer releaseAll(releases)
1124- recent := d.sessions[domain.SessionID("recent")]1126+ recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
1125 1127
1126 if tc.name == "missing target does not record" {1128 if tc.name == "missing target does not record" {
1127 require.Error(t, d.switchToTarget(current, ac, tc.target(current, recent)))1129 require.Error(t, d.switchToTarget(current, ac, tc.target(current, recent)))
@@ -1133,7 +1135,7 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) {
1133 require.Nil(t, ac.previousSession.Get())1135 require.Nil(t, ac.previousSession.Get())
1134 return1136 return
1135 }1137 }
1136- require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get().id)1138+ require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get().core().id)
1137 })1139 })
1138 }1140 }
1139 }1141 }
@@ -1170,8 +1172,8 @@ func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attached
1170 current.id = "current"1172 current.id = "current"
1171 delete(d.sessions, domain.SessionID("manual"))1173 delete(d.sessions, domain.SessionID("manual"))
1172 d.sessions[current.id] = current1174 d.sessions[current.id] = current
1173- recent := &session{id: "recent", name: "recent", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}1175+ recent := &session{sessionCore: sessionCore{id: "recent", name: "recent"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
1174- older := &session{id: "older", name: "older", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}1176+ older := &session{sessionCore: sessionCore{id: "older", name: "older"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}}
1175 d.sessions[recent.id] = recent1177 d.sessions[recent.id] = recent
1176 d.sessions[older.id] = older1178 d.sessions[older.id] = older
1177 current.mruAt.Store(30)1179 current.mruAt.Store(30)
M internal/usecase/daemon/move.go +2 -2
@@ -238,8 +238,8 @@ func moveSessionForLocatorLocked(d *Daemon, locator moveSessionLocator) *session
238238 if d == nil {
239239 return nil
240240 }
241- sess := d.sessions[locator.ID]
242- if sess == nil || sess.incarnation != locator.Incarnation {
241+ sess, ok := localSession(d.sessions[locator.ID])
242+ if !ok || sess.incarnation != locator.Incarnation {
243243 return nil
244244 }
245245 return sess
@@ -238,8 +238,8 @@ func moveSessionForLocatorLocked(d *Daemon, locator moveSessionLocator) *session
238 if d == nil {238 if d == nil {
239 return nil239 return nil
240 }240 }
241- sess := d.sessions[locator.ID]241+ sess, ok := localSession(d.sessions[locator.ID])
242- if sess == nil || sess.incarnation != locator.Incarnation {242+ if !ok || sess.incarnation != locator.Incarnation {
243 return nil243 return nil
244 }244 }
245 return sess245 return sess
M internal/usecase/daemon/move_acceptance_test.go +3 -12
@@ -89,10 +89,7 @@ func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T
8989
9090 destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23})
9191 destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})
92- destination := &session{
93- id: "destination", name: "destination", ephemeral: true,
94- tabs: []*tab{destinationActive, destinationTab}, active: 0,
95- }
92+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{destinationActive, destinationTab}, active: 0}
9693 publishTiledPaneOwners(destination, destinationActive)
9794 publishTiledPaneOwners(destination, destinationTab)
9895 d.mu.Lock()
@@ -185,10 +182,7 @@ func TestMovePaneRejectsStaleIncarnationWithoutMutation(t *testing.T) {
185182 sourceTab := source.tabs[0]
186183 sourceTab.stableID = "source-tab"
187184 moved := sourceTab.focusedPane()
188- destination := &session{
189- id: "destination", name: "destination", ephemeral: true,
190- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
191- }
185+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0}
192186 destinationTab := destination.tabs[0]
193187 publishTiledPaneOwners(destination, destinationTab)
194188 d.mu.Lock()
@@ -237,10 +231,7 @@ func TestMovePaneRejectsStaleLayoutGenerationBeforeMutation(t *testing.T) {
237231 sourceTab := source.tabs[0]
238232 sourceTab.stableID = "source-tab"
239233 moved := sourceTab.focusedPane()
240- destination := &session{
241- id: "destination", name: "destination", ephemeral: true,
242- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
243- }
234+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0}
244235 destinationTab := destination.tabs[0]
245236 publishTiledPaneOwners(destination, destinationTab)
246237 d.mu.Lock()
@@ -89,10 +89,7 @@ func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T
89 89
90 destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23})90 destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23})
91 destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})91 destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})
92- destination := &session{92+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{destinationActive, destinationTab}, active: 0}
93- id: "destination", name: "destination", ephemeral: true,
94- tabs: []*tab{destinationActive, destinationTab}, active: 0,
95- }
96 publishTiledPaneOwners(destination, destinationActive)93 publishTiledPaneOwners(destination, destinationActive)
97 publishTiledPaneOwners(destination, destinationTab)94 publishTiledPaneOwners(destination, destinationTab)
98 d.mu.Lock()95 d.mu.Lock()
@@ -185,10 +182,7 @@ func TestMovePaneRejectsStaleIncarnationWithoutMutation(t *testing.T) {
185 sourceTab := source.tabs[0]182 sourceTab := source.tabs[0]
186 sourceTab.stableID = "source-tab"183 sourceTab.stableID = "source-tab"
187 moved := sourceTab.focusedPane()184 moved := sourceTab.focusedPane()
188- destination := &session{185+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0}
189- id: "destination", name: "destination", ephemeral: true,
190- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
191- }
192 destinationTab := destination.tabs[0]186 destinationTab := destination.tabs[0]
193 publishTiledPaneOwners(destination, destinationTab)187 publishTiledPaneOwners(destination, destinationTab)
194 d.mu.Lock()188 d.mu.Lock()
@@ -237,10 +231,7 @@ func TestMovePaneRejectsStaleLayoutGenerationBeforeMutation(t *testing.T) {
237 sourceTab := source.tabs[0]231 sourceTab := source.tabs[0]
238 sourceTab.stableID = "source-tab"232 sourceTab.stableID = "source-tab"
239 moved := sourceTab.focusedPane()233 moved := sourceTab.focusedPane()
240- destination := &session{234+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0}
241- id: "destination", name: "destination", ephemeral: true,
242- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
243- }
244 destinationTab := destination.tabs[0]235 destinationTab := destination.tabs[0]
245 publishTiledPaneOwners(destination, destinationTab)236 publishTiledPaneOwners(destination, destinationTab)
246 d.mu.Lock()237 d.mu.Lock()
M internal/usecase/daemon/move_atomicity_test.go +3 -5
@@ -84,12 +84,10 @@ func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) {
8484 sourceTab.stableID = "source-tab"
8585 movedPane := sourceTab.focusedPane()
8686
87- destination := &session{
88- id: "destination",
87+ destination := &session{sessionCore: sessionCore{id: "destination",
8988 name: "destination",
90- ephemeral: true,
91- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
92- active: 0,
89+ ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
90+ active: 0,
9391 }
9492 destinationTab := destination.tabs[0]
9593 publishTiledPaneOwners(destination, destinationTab)
@@ -84,12 +84,10 @@ func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) {
84 sourceTab.stableID = "source-tab"84 sourceTab.stableID = "source-tab"
85 movedPane := sourceTab.focusedPane()85 movedPane := sourceTab.focusedPane()
86 86
87- destination := &session{87+ destination := &session{sessionCore: sessionCore{id: "destination",
88- id: "destination",
89 name: "destination",88 name: "destination",
90- ephemeral: true,89+ ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
91- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},90+ active: 0,
92- active: 0,
93 }91 }
94 destinationTab := destination.tabs[0]92 destinationTab := destination.tabs[0]
95 publishTiledPaneOwners(destination, destinationTab)93 publishTiledPaneOwners(destination, destinationTab)
M internal/usecase/daemon/move_cleanup_test.go +2 -6
@@ -26,9 +26,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
2626 client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
2727 client.sendMu.Unlock()
2828
29- destination := &session{
30- id: "destination", name: "destination", ephemeral: true,
31- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
29+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
3230 active: 0,
3331 }
3432 destinationTab := destination.tabs[0]
@@ -106,9 +104,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
106104 moved := sourceTab.focusedPane()
107105 require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))
108106
109- destination := &session{
110- id: "destination", name: "destination", ephemeral: true,
111- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
107+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
112108 active: 0,
113109 }
114110 destinationTab := destination.tabs[0]
@@ -26,9 +26,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
26 client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}26 client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}}
27 client.sendMu.Unlock()27 client.sendMu.Unlock()
28 28
29- destination := &session{29+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
30- id: "destination", name: "destination", ephemeral: true,
31- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
32 active: 0,30 active: 0,
33 }31 }
34 destinationTab := destination.tabs[0]32 destinationTab := destination.tabs[0]
@@ -106,9 +104,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
106 moved := sourceTab.focusedPane()104 moved := sourceTab.focusedPane()
107 require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))105 require.NotNil(t, d.attachCoordinator(source, nil, displaced, true))
108 106
109- destination := &session{107+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
110- id: "destination", name: "destination", ephemeral: true,
111- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
112 active: 0,108 active: 0,
113 }109 }
114 destinationTab := destination.tabs[0]110 destinationTab := destination.tabs[0]
M internal/usecase/daemon/move_gate_race_test.go +1 -3
@@ -21,9 +21,7 @@ func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *sess
2121 moved := sourceTab.focusedPane()
2222 require.NotNil(t, d.attachCoordinator(source, nil, client, true))
2323
24- destination := &session{
25- id: "destination", name: "destination", ephemeral: true,
26- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
24+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
2725 active: 0,
2826 }
2927 destinationTab := destination.tabs[0]
@@ -21,9 +21,7 @@ func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *sess
21 moved := sourceTab.focusedPane()21 moved := sourceTab.focusedPane()
22 require.NotNil(t, d.attachCoordinator(source, nil, client, true))22 require.NotNil(t, d.attachCoordinator(source, nil, client, true))
23 23
24- destination := &session{24+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
25- id: "destination", name: "destination", ephemeral: true,
26- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
27 active: 0,25 active: 0,
28 }26 }
29 destinationTab := destination.tabs[0]27 destinationTab := destination.tabs[0]
M internal/usecase/daemon/move_handoff_failure_test.go +7 -9
@@ -70,7 +70,7 @@ type movePaneState struct {
7070
7171 type moveState struct {
7272 closing bool
73- registry map[domain.SessionID]*session
73+ registry map[domain.SessionID]attachmentSession
7474 source moveSessionState
7575 destination moveSessionState
7676 sourceTab moveTabState
@@ -87,7 +87,7 @@ func captureMoveState(d *Daemon, source, destination *session, sourceTab, destin
8787 unlockTabs := lockMoveTabs(sourceTab, destinationTab)
8888 movedPane.mu.Lock()
8989
90- registry := make(map[domain.SessionID]*session, len(d.sessions))
90+ registry := make(map[domain.SessionID]attachmentSession, len(d.sessions))
9191 maps.Copy(registry, d.sessions)
9292 state := moveState{
9393 closing: d.closing,
@@ -207,12 +207,10 @@ func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
207207 sourceTab.stableID = "source-tab"
208208 movedPane := sourceTab.focusedPane()
209209
210- destination := &session{
211- id: "destination",
210+ destination := &session{sessionCore: sessionCore{id: "destination",
212211 name: "destination",
213- ephemeral: true,
214- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
215- active: 0,
212+ ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
213+ active: 0,
216214 }
217215 destinationTab := destination.tabs[0]
218216 publishTiledPaneOwners(destination, destinationTab)
@@ -261,7 +259,7 @@ func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
261259
262260 type moveTabHandoffState struct {
263261 closing bool
264- registry map[domain.SessionID]*session
262+ registry map[domain.SessionID]attachmentSession
265263 source moveSessionState
266264 destination moveSessionState
267265 movedTab moveTabState
@@ -275,7 +273,7 @@ func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *
275273 unlockSessions := lockAttachmentSessions(source, destination)
276274 moved.mu.Lock()
277275
278- registry := make(map[domain.SessionID]*session, len(d.sessions))
276+ registry := make(map[domain.SessionID]attachmentSession, len(d.sessions))
279277 maps.Copy(registry, d.sessions)
280278 state := moveTabHandoffState{
281279 closing: d.closing,
@@ -70,7 +70,7 @@ type movePaneState struct {
70 70
71 type moveState struct {71 type moveState struct {
72 closing bool72 closing bool
73- registry map[domain.SessionID]*session73+ registry map[domain.SessionID]attachmentSession
74 source moveSessionState74 source moveSessionState
75 destination moveSessionState75 destination moveSessionState
76 sourceTab moveTabState76 sourceTab moveTabState
@@ -87,7 +87,7 @@ func captureMoveState(d *Daemon, source, destination *session, sourceTab, destin
87 unlockTabs := lockMoveTabs(sourceTab, destinationTab)87 unlockTabs := lockMoveTabs(sourceTab, destinationTab)
88 movedPane.mu.Lock()88 movedPane.mu.Lock()
89 89
90- registry := make(map[domain.SessionID]*session, len(d.sessions))90+ registry := make(map[domain.SessionID]attachmentSession, len(d.sessions))
91 maps.Copy(registry, d.sessions)91 maps.Copy(registry, d.sessions)
92 state := moveState{92 state := moveState{
93 closing: d.closing,93 closing: d.closing,
@@ -207,12 +207,10 @@ func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
207 sourceTab.stableID = "source-tab"207 sourceTab.stableID = "source-tab"
208 movedPane := sourceTab.focusedPane()208 movedPane := sourceTab.focusedPane()
209 209
210- destination := &session{210+ destination := &session{sessionCore: sessionCore{id: "destination",
211- id: "destination",
212 name: "destination",211 name: "destination",
213- ephemeral: true,212+ ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},
214- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})},213+ active: 0,
215- active: 0,
216 }214 }
217 destinationTab := destination.tabs[0]215 destinationTab := destination.tabs[0]
218 publishTiledPaneOwners(destination, destinationTab)216 publishTiledPaneOwners(destination, destinationTab)
@@ -261,7 +259,7 @@ func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) {
261 259
262 type moveTabHandoffState struct {260 type moveTabHandoffState struct {
263 closing bool261 closing bool
264- registry map[domain.SessionID]*session262+ registry map[domain.SessionID]attachmentSession
265 source moveSessionState263 source moveSessionState
266 destination moveSessionState264 destination moveSessionState
267 movedTab moveTabState265 movedTab moveTabState
@@ -275,7 +273,7 @@ func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved *
275 unlockSessions := lockAttachmentSessions(source, destination)273 unlockSessions := lockAttachmentSessions(source, destination)
276 moved.mu.Lock()274 moved.mu.Lock()
277 275
278- registry := make(map[domain.SessionID]*session, len(d.sessions))276+ registry := make(map[domain.SessionID]attachmentSession, len(d.sessions))
279 maps.Copy(registry, d.sessions)277 maps.Copy(registry, d.sessions)
280 state := moveTabHandoffState{278 state := moveTabHandoffState{
281 closing: d.closing,279 closing: d.closing,
M internal/usecase/daemon/move_lifecycle_test.go +12 -10
@@ -66,7 +66,7 @@ func awaitDaemonMoveClosing(t *testing.T, d *Daemon) {
6666
6767 func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) {
6868 d := newTestDaemon(t, nil, stubClock{})
69- sess := &session{id: "source"}
69+ sess := &session{sessionCore: sessionCore{id: "source"}}
7070
7171 reservation, err := d.reserveMoveLifecycles(sess, sess)
7272 require.NoError(t, err)
@@ -88,11 +88,13 @@ func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) {
8888
8989 func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) {
9090 d := newTestDaemon(t, nil, stubClock{})
91- first := &session{id: "a"}
92- second := &session{id: "b"}
91+ first := &session{sessionCore: sessionCore{id: "a"}}
92+ second := &session{sessionCore:
9393
9494 // Both opposite-direction reservations must block on a before either takes
9595 // b. This proves order from immutable IDs rather than argument direction.
96+ sessionCore{id: "b"}}
97+
9698 first.teardownMu.Lock()
9799 beforeTeardownLocks := make(chan struct{}, 2)
98100 d.afterMoveLifecycleGateBeforeTeardownLocks = func() { beforeTeardownLocks <- struct{}{} }
@@ -127,8 +129,8 @@ func TestMoveLifecycleReservationRejectsTeardownWithoutLeakingCounts(t *testing.
127129 for _, teardownSession := range []string{"source", "destination"} {
128130 t.Run(teardownSession, func(t *testing.T) {
129131 d := newTestDaemon(t, nil, stubClock{})
130- source := &session{id: "source"}
131- destination := &session{id: "destination"}
132+ source := &session{sessionCore: sessionCore{id: "source"}}
133+ destination := &session{sessionCore: sessionCore{id: "destination"}}
132134 tearingDown := source
133135 if teardownSession == "destination" {
134136 tearingDown = destination
@@ -148,7 +150,7 @@ func TestMoveLifecycleReservationRejectsTeardownWithoutLeakingCounts(t *testing.
148150
149151 func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T) {
150152 d := newTestDaemon(t, nil, stubClock{})
151- sess := &session{id: "same"}
153+ sess := &session{sessionCore: sessionCore{id: "same"}}
152154 reservation, err := d.reserveMoveLifecycles(sess, sess)
153155 require.NoError(t, err)
154156 awaitMoveTeardownState(t, sess, 1, 0)
@@ -170,7 +172,7 @@ func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T
170172
171173 func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *testing.T) {
172174 d := newTestDaemon(t, nil, stubClock{})
173- sess := &session{id: "source"}
175+ sess := &session{sessionCore: sessionCore{id: "source"}}
174176 reservation, err := d.reserveMoveLifecycles(sess, sess)
175177 require.NoError(t, err)
176178 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}
@@ -190,7 +192,7 @@ func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *test
190192
191193 func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) {
192194 d := newTestDaemon(t, nil, stubClock{})
193- sess := &session{id: "source"}
195+ sess := &session{sessionCore: sessionCore{id: "source"}}
194196 reservation, err := d.reserveMoveLifecycles(sess, sess)
195197 require.NoError(t, err)
196198
@@ -221,8 +223,8 @@ func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) {
221223
222224 func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) {
223225 d := newTestDaemon(t, nil, stubClock{})
224- source := &session{id: "source"}
225- destination := &session{id: "destination"}
226+ source := &session{sessionCore: sessionCore{id: "source"}}
227+ destination := &session{sessionCore: sessionCore{id: "destination"}}
226228 reservation, err := d.reserveMoveLifecycles(source, destination)
227229 require.NoError(t, err)
228230 paneLifetime := d.paneProcessCtx
@@ -66,7 +66,7 @@ func awaitDaemonMoveClosing(t *testing.T, d *Daemon) {
66 66
67 func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) {67 func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) {
68 d := newTestDaemon(t, nil, stubClock{})68 d := newTestDaemon(t, nil, stubClock{})
69- sess := &session{id: "source"}69+ sess := &session{sessionCore: sessionCore{id: "source"}}
70 70
71 reservation, err := d.reserveMoveLifecycles(sess, sess)71 reservation, err := d.reserveMoveLifecycles(sess, sess)
72 require.NoError(t, err)72 require.NoError(t, err)
@@ -88,11 +88,13 @@ func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) {
88 88
89 func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) {89 func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) {
90 d := newTestDaemon(t, nil, stubClock{})90 d := newTestDaemon(t, nil, stubClock{})
91- first := &session{id: "a"}91+ first := &session{sessionCore: sessionCore{id: "a"}}
92- second := &session{id: "b"}92+ second := &session{sessionCore:
93 93
94 // Both opposite-direction reservations must block on a before either takes94 // Both opposite-direction reservations must block on a before either takes
95 // b. This proves order from immutable IDs rather than argument direction.95 // b. This proves order from immutable IDs rather than argument direction.
96+ sessionCore{id: "b"}}
97+
96 first.teardownMu.Lock()98 first.teardownMu.Lock()
97 beforeTeardownLocks := make(chan struct{}, 2)99 beforeTeardownLocks := make(chan struct{}, 2)
98 d.afterMoveLifecycleGateBeforeTeardownLocks = func() { beforeTeardownLocks <- struct{}{} }100 d.afterMoveLifecycleGateBeforeTeardownLocks = func() { beforeTeardownLocks <- struct{}{} }
@@ -127,8 +129,8 @@ func TestMoveLifecycleReservationRejectsTeardownWithoutLeakingCounts(t *testing.
127 for _, teardownSession := range []string{"source", "destination"} {129 for _, teardownSession := range []string{"source", "destination"} {
128 t.Run(teardownSession, func(t *testing.T) {130 t.Run(teardownSession, func(t *testing.T) {
129 d := newTestDaemon(t, nil, stubClock{})131 d := newTestDaemon(t, nil, stubClock{})
130- source := &session{id: "source"}132+ source := &session{sessionCore: sessionCore{id: "source"}}
131- destination := &session{id: "destination"}133+ destination := &session{sessionCore: sessionCore{id: "destination"}}
132 tearingDown := source134 tearingDown := source
133 if teardownSession == "destination" {135 if teardownSession == "destination" {
134 tearingDown = destination136 tearingDown = destination
@@ -148,7 +150,7 @@ func TestMoveLifecycleReservationRejectsTeardownWithoutLeakingCounts(t *testing.
148 150
149 func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T) {151 func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T) {
150 d := newTestDaemon(t, nil, stubClock{})152 d := newTestDaemon(t, nil, stubClock{})
151- sess := &session{id: "same"}153+ sess := &session{sessionCore: sessionCore{id: "same"}}
152 reservation, err := d.reserveMoveLifecycles(sess, sess)154 reservation, err := d.reserveMoveLifecycles(sess, sess)
153 require.NoError(t, err)155 require.NoError(t, err)
154 awaitMoveTeardownState(t, sess, 1, 0)156 awaitMoveTeardownState(t, sess, 1, 0)
@@ -170,7 +172,7 @@ func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T
170 172
171 func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *testing.T) {173 func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *testing.T) {
172 d := newTestDaemon(t, nil, stubClock{})174 d := newTestDaemon(t, nil, stubClock{})
173- sess := &session{id: "source"}175+ sess := &session{sessionCore: sessionCore{id: "source"}}
174 reservation, err := d.reserveMoveLifecycles(sess, sess)176 reservation, err := d.reserveMoveLifecycles(sess, sess)
175 require.NoError(t, err)177 require.NoError(t, err)
176 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}178 deadline := &snapshotShutdownDeadline{done: make(chan struct{})}
@@ -190,7 +192,7 @@ func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *test
190 192
191 func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) {193 func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) {
192 d := newTestDaemon(t, nil, stubClock{})194 d := newTestDaemon(t, nil, stubClock{})
193- sess := &session{id: "source"}195+ sess := &session{sessionCore: sessionCore{id: "source"}}
194 reservation, err := d.reserveMoveLifecycles(sess, sess)196 reservation, err := d.reserveMoveLifecycles(sess, sess)
195 require.NoError(t, err)197 require.NoError(t, err)
196 198
@@ -221,8 +223,8 @@ func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) {
221 223
222 func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) {224 func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) {
223 d := newTestDaemon(t, nil, stubClock{})225 d := newTestDaemon(t, nil, stubClock{})
224- source := &session{id: "source"}226+ source := &session{sessionCore: sessionCore{id: "source"}}
225- destination := &session{id: "destination"}227+ destination := &session{sessionCore: sessionCore{id: "destination"}}
226 reservation, err := d.reserveMoveLifecycles(source, destination)228 reservation, err := d.reserveMoveLifecycles(source, destination)
227 require.NoError(t, err)229 require.NoError(t, err)
228 paneLifetime := d.paneProcessCtx230 paneLifetime := d.paneProcessCtx
M internal/usecase/daemon/move_pane_commit.go +1 -1
@@ -156,7 +156,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
156156 }
157157
158158 if candidate.removeSourceTab && len(c.source.tabs) == 0 {
159- delete(d.sessions, c.source.id)
159+ d.unregisterSessionLocked(c.source)
160160 c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
161161 d.purgeParkingForSessionLocked(c.source)
162162 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
@@ -156,7 +156,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
156 }156 }
157 157
158 if candidate.removeSourceTab && len(c.source.tabs) == 0 {158 if candidate.removeSourceTab && len(c.source.tabs) == 0 {
159- delete(d.sessions, c.source.id)159+ d.unregisterSessionLocked(c.source)
160 c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)160 c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
161 d.purgeParkingForSessionLocked(c.source)161 d.purgeParkingForSessionLocked(c.source)
162 c.retiredParked = d.purgeParkedForSessionLocked(c.source)162 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
M internal/usecase/daemon/move_picker.go +4 -4
@@ -10,8 +10,8 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
1010 if d == nil || source.Session.ID == "" || source.TabID == "" {
1111 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
1212 }
13- sess := d.sessionByID(source.Session.ID)
14- if sess == nil || sess.incarnation != source.Session.Incarnation {
13+ sess, ok := localSession(d.sessionByID(source.Session.ID))
14+ if !ok || sess.incarnation != source.Session.Incarnation {
1515 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
1616 }
1717 sess.mu.Lock()
@@ -109,8 +109,8 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
109109
110110 func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
111111 d.mu.Lock()
112- sess := d.sessions[target.Session]
113- if sess == nil {
112+ sess, ok := localSession(d.sessions[target.Session])
113+ if !ok {
114114 d.mu.Unlock()
115115 return nil, nil
116116 }
@@ -10,8 +10,8 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
10 if d == nil || source.Session.ID == "" || source.TabID == "" {10 if d == nil || source.Session.ID == "" || source.TabID == "" {
11 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)11 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
12 }12 }
13- sess := d.sessionByID(source.Session.ID)13+ sess, ok := localSession(d.sessionByID(source.Session.ID))
14- if sess == nil || sess.incarnation != source.Session.Incarnation {14+ if !ok || sess.incarnation != source.Session.Incarnation {
15 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)15 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid)
16 }16 }
17 sess.mu.Lock()17 sess.mu.Lock()
@@ -109,8 +109,8 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
109 109
110 func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {110 func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) {
111 d.mu.Lock()111 d.mu.Lock()
112- sess := d.sessions[target.Session]112+ sess, ok := localSession(d.sessions[target.Session])
113- if sess == nil {113+ if !ok {
114 d.mu.Unlock()114 d.mu.Unlock()
115 return nil, nil115 return nil, nil
116 }116 }
M internal/usecase/daemon/move_picker_test.go +1 -4
@@ -35,10 +35,7 @@ func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDest
3535 releases = append(releases, releaseDest)
3636 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23})
3737 publishTiledPaneOwners(source, sourceTab)
38- destination := &session{
39- id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
40- ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0,
41- }
38+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0}
4239 publishTiledPaneOwners(destination, destinationTab)
4340 for range extraDestinationTabs {
4441 extraPTY, releaseExtra := newBlockingPTY(t)
@@ -35,10 +35,7 @@ func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDest
35 releases = append(releases, releaseDest)35 releases = append(releases, releaseDest)
36 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23})36 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23})
37 publishTiledPaneOwners(source, sourceTab)37 publishTiledPaneOwners(source, sourceTab)
38- destination := &session{38+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0}
39- id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
40- ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0,
41- }
42 publishTiledPaneOwners(destination, destinationTab)39 publishTiledPaneOwners(destination, destinationTab)
43 for range extraDestinationTabs {40 for range extraDestinationTabs {
44 extraPTY, releaseExtra := newBlockingPTY(t)41 extraPTY, releaseExtra := newBlockingPTY(t)
M internal/usecase/daemon/move_resize_fences_test.go +17 -17
@@ -22,8 +22,8 @@ func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) {
2222 }
2323
2424 func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) {
25- first := &session{id: "a-first"}
26- second := &session{id: "z-second"}
25+ first := &session{sessionCore: sessionCore{id: "a-first"}}
26+ second := &session{sessionCore: sessionCore{id: "z-second"}}
2727 fences := newMoveResizeFences([]*session{second, first}, nil, nil)
2828
2929 first.layoutApplyMu.Lock()
@@ -45,7 +45,7 @@ func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) {
4545 }
4646
4747 func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) {
48- sess := &session{id: "session"}
48+ sess := &session{sessionCore: sessionCore{id: "session"}}
4949 first := &tab{stableID: "a-first"}
5050 second := &tab{stableID: "z-second"}
5151 fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil)
@@ -71,7 +71,7 @@ func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) {
7171 }
7272
7373 func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) {
74- sess := &session{id: "session"}
74+ sess := &session{sessionCore: sessionCore{id: "session"}}
7575 tb := &tab{stableID: "tab"}
7676 first := &pane{stableID: "a-first"}
7777 second := &pane{stableID: "z-second"}
@@ -99,7 +99,7 @@ func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) {
9999 }
100100
101101 func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) {
102- sess := &session{id: "session"}
102+ sess := &session{sessionCore: sessionCore{id: "session"}}
103103 tb := &tab{stableID: "tab"}
104104 p := &pane{stableID: "pane"}
105105
@@ -113,8 +113,8 @@ func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) {
113113 }
114114
115115 func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) {
116- resolved := &session{id: "same"}
117- collision := &session{id: "same"}
116+ resolved := &session{sessionCore: sessionCore{id: "same"}}
117+ collision := &session{sessionCore: sessionCore{id: "same"}}
118118
119119 fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil)
120120
@@ -143,7 +143,7 @@ func TestMoveResizeFencesDeduplicatePaneIDWithoutReplacingResolvedOwner(t *testi
143143 }
144144
145145 func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) {
146- sess := &session{id: "session"}
146+ sess := &session{sessionCore: sessionCore{id: "session"}}
147147 tb := &tab{stableID: "tab"}
148148 p := &pane{stableID: "pane"}
149149 fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
@@ -171,7 +171,7 @@ func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) {
171171 }
172172
173173 func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) {
174- sess := &session{id: "session"}
174+ sess := &session{sessionCore: sessionCore{id: "session"}}
175175 tb := &tab{stableID: "tab"}
176176 p := &pane{stableID: "pane"}
177177 fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
@@ -187,7 +187,7 @@ func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) {
187187
188188 func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *testing.T) {
189189 d := &Daemon{}
190- sess := &session{id: "session"}
190+ sess := &session{sessionCore: sessionCore{id: "session"}}
191191 tb := &tab{stableID: "tab"}
192192 p := &pane{stableID: "pane"}
193193 members := []resizeMember{{session: sess, tab: tb, pane: p}}
@@ -216,7 +216,7 @@ func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *test
216216
217217 func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) {
218218 d := &Daemon{}
219- sess := &session{id: "session"}
219+ sess := &session{sessionCore: sessionCore{id: "session"}}
220220 tb := &tab{stableID: "tab"}
221221 p := &pane{stableID: "pane"}
222222 fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
@@ -243,8 +243,8 @@ func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) {
243243 }
244244
245245 func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) {
246- source := &session{id: "source"}
247- destination := &session{id: "destination"}
246+ source := &session{sessionCore: sessionCore{id: "source"}}
247+ destination := &session{sessionCore: sessionCore{id: "destination"}}
248248 sourceTab := &tab{stableID: "a-source-tab"}
249249 destinationTab := &tab{stableID: "z-destination-tab"}
250250 p := &pane{stableID: "pane"}
@@ -280,8 +280,8 @@ func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T)
280280 }
281281
282282 func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) {
283- source := &session{id: "source"}
284- destination := &session{id: "destination"}
283+ source := &session{sessionCore: sessionCore{id: "source"}}
284+ destination := &session{sessionCore: sessionCore{id: "destination"}}
285285 sourceTab := &tab{stableID: "source-tab"}
286286 destinationTab := &tab{stableID: "destination-tab"}
287287 moved := &pane{stableID: "moved-pane"}
@@ -299,8 +299,8 @@ func TestMoveTabResizeFencesCoverEveryContainedPane(t *testing.T) {
299299 state floatingState
300300 }{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} {
301301 t.Run(tc.name, func(t *testing.T) {
302- source := &session{id: "source"}
303- destination := &session{id: "destination"}
302+ source := &session{sessionCore: sessionCore{id: "source"}}
303+ destination := &session{sessionCore: sessionCore{id: "destination"}}
304304 first := &pane{stableID: "first"}
305305 second := &pane{stableID: "second"}
306306 floating := &pane{stableID: "floating"}
@@ -22,8 +22,8 @@ func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) {
22 }22 }
23 23
24 func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) {24 func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) {
25- first := &session{id: "a-first"}25+ first := &session{sessionCore: sessionCore{id: "a-first"}}
26- second := &session{id: "z-second"}26+ second := &session{sessionCore: sessionCore{id: "z-second"}}
27 fences := newMoveResizeFences([]*session{second, first}, nil, nil)27 fences := newMoveResizeFences([]*session{second, first}, nil, nil)
28 28
29 first.layoutApplyMu.Lock()29 first.layoutApplyMu.Lock()
@@ -45,7 +45,7 @@ func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) {
45 }45 }
46 46
47 func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) {47 func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) {
48- sess := &session{id: "session"}48+ sess := &session{sessionCore: sessionCore{id: "session"}}
49 first := &tab{stableID: "a-first"}49 first := &tab{stableID: "a-first"}
50 second := &tab{stableID: "z-second"}50 second := &tab{stableID: "z-second"}
51 fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil)51 fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil)
@@ -71,7 +71,7 @@ func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) {
71 }71 }
72 72
73 func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) {73 func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) {
74- sess := &session{id: "session"}74+ sess := &session{sessionCore: sessionCore{id: "session"}}
75 tb := &tab{stableID: "tab"}75 tb := &tab{stableID: "tab"}
76 first := &pane{stableID: "a-first"}76 first := &pane{stableID: "a-first"}
77 second := &pane{stableID: "z-second"}77 second := &pane{stableID: "z-second"}
@@ -99,7 +99,7 @@ func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) {
99 }99 }
100 100
101 func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) {101 func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) {
102- sess := &session{id: "session"}102+ sess := &session{sessionCore: sessionCore{id: "session"}}
103 tb := &tab{stableID: "tab"}103 tb := &tab{stableID: "tab"}
104 p := &pane{stableID: "pane"}104 p := &pane{stableID: "pane"}
105 105
@@ -113,8 +113,8 @@ func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) {
113 }113 }
114 114
115 func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) {115 func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) {
116- resolved := &session{id: "same"}116+ resolved := &session{sessionCore: sessionCore{id: "same"}}
117- collision := &session{id: "same"}117+ collision := &session{sessionCore: sessionCore{id: "same"}}
118 118
119 fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil)119 fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil)
120 120
@@ -143,7 +143,7 @@ func TestMoveResizeFencesDeduplicatePaneIDWithoutReplacingResolvedOwner(t *testi
143 }143 }
144 144
145 func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) {145 func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) {
146- sess := &session{id: "session"}146+ sess := &session{sessionCore: sessionCore{id: "session"}}
147 tb := &tab{stableID: "tab"}147 tb := &tab{stableID: "tab"}
148 p := &pane{stableID: "pane"}148 p := &pane{stableID: "pane"}
149 fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})149 fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
@@ -171,7 +171,7 @@ func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) {
171 }171 }
172 172
173 func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) {173 func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) {
174- sess := &session{id: "session"}174+ sess := &session{sessionCore: sessionCore{id: "session"}}
175 tb := &tab{stableID: "tab"}175 tb := &tab{stableID: "tab"}
176 p := &pane{stableID: "pane"}176 p := &pane{stableID: "pane"}
177 fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})177 fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
@@ -187,7 +187,7 @@ func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) {
187 187
188 func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *testing.T) {188 func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *testing.T) {
189 d := &Daemon{}189 d := &Daemon{}
190- sess := &session{id: "session"}190+ sess := &session{sessionCore: sessionCore{id: "session"}}
191 tb := &tab{stableID: "tab"}191 tb := &tab{stableID: "tab"}
192 p := &pane{stableID: "pane"}192 p := &pane{stableID: "pane"}
193 members := []resizeMember{{session: sess, tab: tb, pane: p}}193 members := []resizeMember{{session: sess, tab: tb, pane: p}}
@@ -216,7 +216,7 @@ func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *test
216 216
217 func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) {217 func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) {
218 d := &Daemon{}218 d := &Daemon{}
219- sess := &session{id: "session"}219+ sess := &session{sessionCore: sessionCore{id: "session"}}
220 tb := &tab{stableID: "tab"}220 tb := &tab{stableID: "tab"}
221 p := &pane{stableID: "pane"}221 p := &pane{stableID: "pane"}
222 fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})222 fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p})
@@ -243,8 +243,8 @@ func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) {
243 }243 }
244 244
245 func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) {245 func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) {
246- source := &session{id: "source"}246+ source := &session{sessionCore: sessionCore{id: "source"}}
247- destination := &session{id: "destination"}247+ destination := &session{sessionCore: sessionCore{id: "destination"}}
248 sourceTab := &tab{stableID: "a-source-tab"}248 sourceTab := &tab{stableID: "a-source-tab"}
249 destinationTab := &tab{stableID: "z-destination-tab"}249 destinationTab := &tab{stableID: "z-destination-tab"}
250 p := &pane{stableID: "pane"}250 p := &pane{stableID: "pane"}
@@ -280,8 +280,8 @@ func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T)
280 }280 }
281 281
282 func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) {282 func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) {
283- source := &session{id: "source"}283+ source := &session{sessionCore: sessionCore{id: "source"}}
284- destination := &session{id: "destination"}284+ destination := &session{sessionCore: sessionCore{id: "destination"}}
285 sourceTab := &tab{stableID: "source-tab"}285 sourceTab := &tab{stableID: "source-tab"}
286 destinationTab := &tab{stableID: "destination-tab"}286 destinationTab := &tab{stableID: "destination-tab"}
287 moved := &pane{stableID: "moved-pane"}287 moved := &pane{stableID: "moved-pane"}
@@ -299,8 +299,8 @@ func TestMoveTabResizeFencesCoverEveryContainedPane(t *testing.T) {
299 state floatingState299 state floatingState
300 }{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} {300 }{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} {
301 t.Run(tc.name, func(t *testing.T) {301 t.Run(tc.name, func(t *testing.T) {
302- source := &session{id: "source"}302+ source := &session{sessionCore: sessionCore{id: "source"}}
303- destination := &session{id: "destination"}303+ destination := &session{sessionCore: sessionCore{id: "destination"}}
304 first := &pane{stableID: "first"}304 first := &pane{stableID: "first"}
305 second := &pane{stableID: "second"}305 second := &pane{stableID: "second"}
306 floating := &pane{stableID: "floating"}306 floating := &pane{stableID: "floating"}
M internal/usecase/daemon/move_stale_destination_test.go +1 -4
@@ -18,10 +18,7 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T)
1818 sourceTab := source.tabs[0]
1919 sourceTab.stableID = "source-tab"
2020 moved := sourceTab.focusedPane()
21- destination := &session{
22- id: "destination", name: "destination", ephemeral: true,
23- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
24- }
21+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0}
2522 destinationTab := destination.tabs[0]
2623 publishTiledPaneOwners(destination, destinationTab)
2724 d.mu.Lock()
@@ -18,10 +18,7 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T)
18 sourceTab := source.tabs[0]18 sourceTab := source.tabs[0]
19 sourceTab.stableID = "source-tab"19 sourceTab.stableID = "source-tab"
20 moved := sourceTab.focusedPane()20 moved := sourceTab.focusedPane()
21- destination := &session{21+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0}
22- id: "destination", name: "destination", ephemeral: true,
23- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0,
24- }
25 destinationTab := destination.tabs[0]22 destinationTab := destination.tabs[0]
26 publishTiledPaneOwners(destination, destinationTab)23 publishTiledPaneOwners(destination, destinationTab)
27 d.mu.Lock()24 d.mu.Lock()
M internal/usecase/daemon/move_tab.go +1 -1
@@ -384,7 +384,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
384384 c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)
385385 }
386386 if len(c.source.tabs) == 0 {
387- delete(d.sessions, c.source.id)
387+ d.unregisterSessionLocked(c.source)
388388 c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
389389 d.purgeParkingForSessionLocked(c.source)
390390 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
@@ -384,7 +384,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
384 c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)384 c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client)
385 }385 }
386 if len(c.source.tabs) == 0 {386 if len(c.source.tabs) == 0 {
387- delete(d.sessions, c.source.id)387+ d.unregisterSessionLocked(c.source)
388 c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)388 c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement)
389 d.purgeParkingForSessionLocked(c.source)389 d.purgeParkingForSessionLocked(c.source)
390 c.retiredParked = d.purgeParkedForSessionLocked(c.source)390 c.retiredParked = d.purgeParkedForSessionLocked(c.source)
M internal/usecase/daemon/move_tab_test.go +2 -5
@@ -33,10 +33,7 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
3333 t.Cleanup(destinationCancel)
3434 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30})
3535 destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)
36- destination := &session{
37- id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral,
38- ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0,
39- }
36+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral}, ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0}
4037 publishTiledPaneOwners(destination, destinationTab)
4138
4239 floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})
@@ -575,7 +572,7 @@ func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *sessio
575572 ctx, cancel := context.WithCancel(d.serveCtx)
576573 tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
577574 tb.ctx, tb.cancel = context.WithCancel(ctx)
578- sess := &session{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0}
575+ sess := &session{sessionCore: sessionCore{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0}
579576 publishTiledPaneOwners(sess, tb)
580577 d.mu.Lock()
581578 d.sessions[sess.id] = sess
@@ -33,10 +33,7 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
33 t.Cleanup(destinationCancel)33 t.Cleanup(destinationCancel)
34 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30})34 destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30})
35 destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)35 destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx)
36- destination := &session{36+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral}, ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0}
37- id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral,
38- ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0,
39- }
40 publishTiledPaneOwners(destination, destinationTab)37 publishTiledPaneOwners(destination, destinationTab)
41 38
42 floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})39 floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8})
@@ -575,7 +572,7 @@ func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *sessio
575 ctx, cancel := context.WithCancel(d.serveCtx)572 ctx, cancel := context.WithCancel(d.serveCtx)
576 tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})573 tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
577 tb.ctx, tb.cancel = context.WithCancel(ctx)574 tb.ctx, tb.cancel = context.WithCancel(ctx)
578- sess := &session{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0}575+ sess := &session{sessionCore: sessionCore{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0}
579 publishTiledPaneOwners(sess, tb)576 publishTiledPaneOwners(sess, tb)
580 d.mu.Lock()577 d.mu.Lock()
581 d.sessions[sess.id] = sess578 d.sessions[sess.id] = sess
M internal/usecase/daemon/move_test.go +4 -8
@@ -57,7 +57,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) {
5757 movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
5858 moved.ctx, moved.cancel = movedCtx, movedCancel
5959
60- destination := &session{id: "destination", name: "destination", ephemeral: true, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
60+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
6161 destinationTab := destination.tabs[0]
6262 destinationTab.stableID = "destination-tab"
6363 publishTiledPaneOwners(destination, destinationTab)
@@ -97,7 +97,7 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) {
9797 require.NotNil(t, rc)
9898 var rebased atomic.Bool
9999 client.renderStages.handoffRebase = func() { rebased.Store(true) }
100- destination := &session{id: "destination", name: "destination", tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
100+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
101101 destinationTab := destination.tabs[0]
102102 destinationTab.stableID = "destination-tab"
103103 publishTiledPaneOwners(destination, destinationTab)
@@ -140,9 +140,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing
140140 movedTab.stableID = "source-tab"
141141
142142 destinationPTY := newQuietPTY()
143- destination := &session{
144- id: "destination", name: "destination", ephemeral: true,
145- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
143+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
146144 active: 0,
147145 }
148146 publishTiledPaneOwners(destination, destination.tabs[0])
@@ -192,9 +190,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
192190 parked.initOverlays()
193191 require.True(t, d.parkAttachment(source, parked))
194192 destinationPTY := newQuietPTY()
195- destination := &session{
196- id: "destination", name: "destination", ephemeral: true,
197- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
193+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
198194 active: 0,
199195 }
200196 publishTiledPaneOwners(destination, destination.tabs[0])
@@ -57,7 +57,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) {
57 movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)57 movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx)
58 moved.ctx, moved.cancel = movedCtx, movedCancel58 moved.ctx, moved.cancel = movedCtx, movedCancel
59 59
60- destination := &session{id: "destination", name: "destination", ephemeral: true, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}60+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
61 destinationTab := destination.tabs[0]61 destinationTab := destination.tabs[0]
62 destinationTab.stableID = "destination-tab"62 destinationTab.stableID = "destination-tab"
63 publishTiledPaneOwners(destination, destinationTab)63 publishTiledPaneOwners(destination, destinationTab)
@@ -97,7 +97,7 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) {
97 require.NotNil(t, rc)97 require.NotNil(t, rc)
98 var rebased atomic.Bool98 var rebased atomic.Bool
99 client.renderStages.handoffRebase = func() { rebased.Store(true) }99 client.renderStages.handoffRebase = func() { rebased.Store(true) }
100- destination := &session{id: "destination", name: "destination", tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}100+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0}
101 destinationTab := destination.tabs[0]101 destinationTab := destination.tabs[0]
102 destinationTab.stableID = "destination-tab"102 destinationTab.stableID = "destination-tab"
103 publishTiledPaneOwners(destination, destinationTab)103 publishTiledPaneOwners(destination, destinationTab)
@@ -140,9 +140,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing
140 movedTab.stableID = "source-tab"140 movedTab.stableID = "source-tab"
141 141
142 destinationPTY := newQuietPTY()142 destinationPTY := newQuietPTY()
143- destination := &session{143+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
144- id: "destination", name: "destination", ephemeral: true,
145- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
146 active: 0,144 active: 0,
147 }145 }
148 publishTiledPaneOwners(destination, destination.tabs[0])146 publishTiledPaneOwners(destination, destination.tabs[0])
@@ -192,9 +190,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) {
192 parked.initOverlays()190 parked.initOverlays()
193 require.True(t, d.parkAttachment(source, parked))191 require.True(t, d.parkAttachment(source, parked))
194 destinationPTY := newQuietPTY()192 destinationPTY := newQuietPTY()
195- destination := &session{193+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
196- id: "destination", name: "destination", ephemeral: true,
197- tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})},
198 active: 0,194 active: 0,
199 }195 }
200 publishTiledPaneOwners(destination, destination.tabs[0])196 publishTiledPaneOwners(destination, destination.tabs[0])
M internal/usecase/daemon/navigation_overflow.go +1 -4
@@ -85,10 +85,7 @@ func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction,
8585 current.mu.Unlock()
8686
8787 d.mu.Lock()
88- live := make([]*session, 0, len(d.sessions))
89- for _, sess := range d.sessions {
90- live = append(live, sess)
91- }
88+ live := localSessionsSnapshot(d.sessions)
9289 d.mu.Unlock()
9390
9491 snapshots := make([]sessionOverflowSnapshot, 0, len(live))
@@ -85,10 +85,7 @@ func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction,
85 current.mu.Unlock()85 current.mu.Unlock()
86 86
87 d.mu.Lock()87 d.mu.Lock()
88- live := make([]*session, 0, len(d.sessions))88+ live := localSessionsSnapshot(d.sessions)
89- for _, sess := range d.sessions {
90- live = append(live, sess)
91- }
92 d.mu.Unlock()89 d.mu.Unlock()
93 90
94 snapshots := make([]sessionOverflowSnapshot, 0, len(live))91 snapshots := make([]sessionOverflowSnapshot, 0, len(live))
M internal/usecase/daemon/navigation_overflow_test.go +18 -10
@@ -93,7 +93,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
9393 target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus}
9494 target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
9595 target.mu.Unlock()
96- return &session{id: domain.SessionID(id), name: name, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active}
96+ return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name}, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active}
9797 }
9898 charlie := newSession("live-charlie", "charlie", 1, "pane-2")
9999 echo := newSession("live-echo", "echo", 1, "pane-2")
@@ -138,7 +138,11 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
138138 d.mu.Lock()
139139 _, stopped := d.stopped["bravo"]
140140 var bravoLive bool
141- for _, candidate := range d.sessions {
141+ for _, entry := range d.sessions {
142+ candidate, ok := localSession(entry)
143+ if !ok {
144+ continue
145+ }
142146 candidate.mu.Lock()
143147 bravoLive = bravoLive || candidate.name == "bravo"
144148 candidate.mu.Unlock()
@@ -158,7 +162,7 @@ func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
158162 alpha.name = "alpha"
159163 alpha.mu.Unlock()
160164 installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
161- charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
165+ charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
162166 d.mu.Lock()
163167 d.sessions[charlie.id] = charlie
164168 d.mu.Unlock()
@@ -180,7 +184,7 @@ func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) {
180184 alpha.mu.Lock()
181185 alpha.name = "alpha"
182186 alpha.mu.Unlock()
183- charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
187+ charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
184188 d.mu.Lock()
185189 d.sessions[charlie.id] = charlie
186190 d.mu.Unlock()
@@ -206,7 +210,7 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
206210 expectedSource := alpha.tabs[0]
207211 alpha.active = 1
208212 alpha.mu.Unlock()
209- charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
213+ charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
210214 d.mu.Lock()
211215 d.sessions[charlie.id] = charlie
212216 d.mu.Unlock()
@@ -224,7 +228,7 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
224228 func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) {
225229 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
226230 installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
227- charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
231+ charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
228232 d.mu.Lock()
229233 d.sessions[charlie.id] = charlie
230234 d.mu.Unlock()
@@ -238,7 +242,7 @@ func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) {
238242 alpha.mu.Lock()
239243 alpha.name = "alpha"
240244 alpha.mu.Unlock()
241- charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
245+ charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
242246 d.mu.Lock()
243247 d.sessions[charlie.id] = charlie
244248 d.mu.Unlock()
@@ -265,8 +269,8 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
265269 alpha.mu.Lock()
266270 alpha.name = "alpha"
267271 alpha.mu.Unlock()
268- charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
269- echo := &session{id: "live-echo", name: "echo", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
272+ charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
273+ echo := &session{sessionCore: sessionCore{id: "live-echo", name: "echo"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
270274 d.mu.Lock()
271275 d.sessions[echo.id] = echo
272276 d.sessions[charlie.id] = charlie
@@ -304,7 +308,11 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
304308 current := ac.currentSession()
305309 owners := make([]*session, 0, 1)
306310 d.mu.Lock()
307- for _, candidate := range d.sessions {
311+ for _, entry := range d.sessions {
312+ candidate, ok := localSession(entry)
313+ if !ok {
314+ continue
315+ }
308316 candidate.mu.Lock()
309317 if candidate.client == ac {
310318 owners = append(owners, candidate)
@@ -93,7 +93,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
93 target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus}93 target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus}
94 target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})94 target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10})
95 target.mu.Unlock()95 target.mu.Unlock()
96- return &session{id: domain.SessionID(id), name: name, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active}96+ return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name}, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active}
97 }97 }
98 charlie := newSession("live-charlie", "charlie", 1, "pane-2")98 charlie := newSession("live-charlie", "charlie", 1, "pane-2")
99 echo := newSession("live-echo", "echo", 1, "pane-2")99 echo := newSession("live-echo", "echo", 1, "pane-2")
@@ -138,7 +138,11 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
138 d.mu.Lock()138 d.mu.Lock()
139 _, stopped := d.stopped["bravo"]139 _, stopped := d.stopped["bravo"]
140 var bravoLive bool140 var bravoLive bool
141- for _, candidate := range d.sessions {141+ for _, entry := range d.sessions {
142+ candidate, ok := localSession(entry)
143+ if !ok {
144+ continue
145+ }
142 candidate.mu.Lock()146 candidate.mu.Lock()
143 bravoLive = bravoLive || candidate.name == "bravo"147 bravoLive = bravoLive || candidate.name == "bravo"
144 candidate.mu.Unlock()148 candidate.mu.Unlock()
@@ -158,7 +162,7 @@ func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) {
158 alpha.name = "alpha"162 alpha.name = "alpha"
159 alpha.mu.Unlock()163 alpha.mu.Unlock()
160 installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)164 installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
161- charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}165+ charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
162 d.mu.Lock()166 d.mu.Lock()
163 d.sessions[charlie.id] = charlie167 d.sessions[charlie.id] = charlie
164 d.mu.Unlock()168 d.mu.Unlock()
@@ -180,7 +184,7 @@ func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) {
180 alpha.mu.Lock()184 alpha.mu.Lock()
181 alpha.name = "alpha"185 alpha.name = "alpha"
182 alpha.mu.Unlock()186 alpha.mu.Unlock()
183- charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}187+ charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
184 d.mu.Lock()188 d.mu.Lock()
185 d.sessions[charlie.id] = charlie189 d.sessions[charlie.id] = charlie
186 d.mu.Unlock()190 d.mu.Unlock()
@@ -206,7 +210,7 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
206 expectedSource := alpha.tabs[0]210 expectedSource := alpha.tabs[0]
207 alpha.active = 1211 alpha.active = 1
208 alpha.mu.Unlock()212 alpha.mu.Unlock()
209- charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}213+ charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
210 d.mu.Lock()214 d.mu.Lock()
211 d.sessions[charlie.id] = charlie215 d.sessions[charlie.id] = charlie
212 d.mu.Unlock()216 d.mu.Unlock()
@@ -224,7 +228,7 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) {
224 func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) {228 func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) {
225 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)229 d, alpha, ac, _ := newManualSessionWithPTYs(t, nil)
226 installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)230 installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true)
227- charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}231+ charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
228 d.mu.Lock()232 d.mu.Lock()
229 d.sessions[charlie.id] = charlie233 d.sessions[charlie.id] = charlie
230 d.mu.Unlock()234 d.mu.Unlock()
@@ -238,7 +242,7 @@ func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) {
238 alpha.mu.Lock()242 alpha.mu.Lock()
239 alpha.name = "alpha"243 alpha.name = "alpha"
240 alpha.mu.Unlock()244 alpha.mu.Unlock()
241- charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}245+ charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
242 d.mu.Lock()246 d.mu.Lock()
243 d.sessions[charlie.id] = charlie247 d.sessions[charlie.id] = charlie
244 d.mu.Unlock()248 d.mu.Unlock()
@@ -265,8 +269,8 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
265 alpha.mu.Lock()269 alpha.mu.Lock()
266 alpha.name = "alpha"270 alpha.name = "alpha"
267 alpha.mu.Unlock()271 alpha.mu.Unlock()
268- charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}272+ charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
269- echo := &session{id: "live-echo", name: "echo", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}273+ echo := &session{sessionCore: sessionCore{id: "live-echo", name: "echo"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}}
270 d.mu.Lock()274 d.mu.Lock()
271 d.sessions[echo.id] = echo275 d.sessions[echo.id] = echo
272 d.sessions[charlie.id] = charlie276 d.sessions[charlie.id] = charlie
@@ -304,7 +308,11 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) {
304 current := ac.currentSession()308 current := ac.currentSession()
305 owners := make([]*session, 0, 1)309 owners := make([]*session, 0, 1)
306 d.mu.Lock()310 d.mu.Lock()
307- for _, candidate := range d.sessions {311+ for _, entry := range d.sessions {
312+ candidate, ok := localSession(entry)
313+ if !ok {
314+ continue
315+ }
308 candidate.mu.Lock()316 candidate.mu.Lock()
309 if candidate.client == ac {317 if candidate.client == ac {
310 owners = append(owners, candidate)318 owners = append(owners, candidate)
M internal/usecase/daemon/navigation_role_handoff_test.go +1 -1
@@ -89,7 +89,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
8989 d, source, old, _, releases := newManualTabSession(t, 2)
9090 defer releaseAll(releases)
9191 d.ptys = newFactorySeq(t)
92- target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{
92+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
9393 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
9494 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
9595 }}
@@ -89,7 +89,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
89 d, source, old, _, releases := newManualTabSession(t, 2)89 d, source, old, _, releases := newManualTabSession(t, 2)
90 defer releaseAll(releases)90 defer releaseAll(releases)
91 d.ptys = newFactorySeq(t)91 d.ptys = newFactorySeq(t)
92- target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{92+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
93 newTab(nil, domain.Size{Cols: 80, Rows: 23}),93 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
94 newTab(nil, domain.Size{Cols: 80, Rows: 23}),94 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
95 }}95 }}
M internal/usecase/daemon/navigation_role_incarnation_test.go +1 -1
@@ -39,7 +39,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
3939 for _, tt := range tests {
4040 t.Run(tt.name, func(t *testing.T) {
4141 d, source, ac, _ := newManualSessionWithPTYs(t, nil)
42- target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{
42+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
4343 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
4444 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
4545 }}
@@ -39,7 +39,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
39 for _, tt := range tests {39 for _, tt := range tests {
40 t.Run(tt.name, func(t *testing.T) {40 t.Run(tt.name, func(t *testing.T) {
41 d, source, ac, _ := newManualSessionWithPTYs(t, nil)41 d, source, ac, _ := newManualSessionWithPTYs(t, nil)
42- target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{42+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
43 newTab(nil, domain.Size{Cols: 80, Rows: 23}),43 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
44 newTab(nil, domain.Size{Cols: 80, Rows: 23}),44 newTab(nil, domain.Size{Cols: 80, Rows: 23}),
45 }}45 }}
M internal/usecase/daemon/nil_pty_test.go +3 -3
@@ -17,7 +17,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
1717 d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
1818 ctx, cancel := context.WithCancel(context.Background())
1919 defer cancel()
20- sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel}
20+ sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, ctx: ctx, cancel: cancel}
2121 d.sessions[sess.id] = sess
2222 first := newTab(nil, domain.Size{Cols: 41, Rows: 10})
2323 second := newTab(nil, domain.Size{Cols: 41, Rows: 10})
@@ -47,7 +47,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
4747 func TestKillSessionIgnoresNilPanePTYs(t *testing.T) {
4848 d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
4949 ctx, cancel := context.WithCancel(context.Background())
50- sess := &session{id: "s", name: "s", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 10, Rows: 3})}}
50+ sess := &session{sessionCore: sessionCore{id: "s", name: "s", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 10, Rows: 3})}}
5151 d.sessions[sess.id] = sess
5252
5353 require.NotPanics(t, func() {
@@ -61,7 +61,7 @@ func TestNilPTYInputPathsDoNotPanic(t *testing.T) {
6161 ctx, cancel := context.WithCancel(context.Background())
6262 defer cancel()
6363 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
64- sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel, tabs: []*tab{tb}}
64+ sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}}
6565 ac := &attachedClient{}
6666 ac.initOverlays()
6767 ac.setSession(sess)
@@ -17,7 +17,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
17 d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))17 d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
18 ctx, cancel := context.WithCancel(context.Background())18 ctx, cancel := context.WithCancel(context.Background())
19 defer cancel()19 defer cancel()
20- sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel}20+ sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, ctx: ctx, cancel: cancel}
21 d.sessions[sess.id] = sess21 d.sessions[sess.id] = sess
22 first := newTab(nil, domain.Size{Cols: 41, Rows: 10})22 first := newTab(nil, domain.Size{Cols: 41, Rows: 10})
23 second := newTab(nil, domain.Size{Cols: 41, Rows: 10})23 second := newTab(nil, domain.Size{Cols: 41, Rows: 10})
@@ -47,7 +47,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
47 func TestKillSessionIgnoresNilPanePTYs(t *testing.T) {47 func TestKillSessionIgnoresNilPanePTYs(t *testing.T) {
48 d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))48 d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
49 ctx, cancel := context.WithCancel(context.Background())49 ctx, cancel := context.WithCancel(context.Background())
50- sess := &session{id: "s", name: "s", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 10, Rows: 3})}}50+ sess := &session{sessionCore: sessionCore{id: "s", name: "s", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 10, Rows: 3})}}
51 d.sessions[sess.id] = sess51 d.sessions[sess.id] = sess
52 52
53 require.NotPanics(t, func() {53 require.NotPanics(t, func() {
@@ -61,7 +61,7 @@ func TestNilPTYInputPathsDoNotPanic(t *testing.T) {
61 ctx, cancel := context.WithCancel(context.Background())61 ctx, cancel := context.WithCancel(context.Background())
62 defer cancel()62 defer cancel()
63 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})63 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
64- sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel, tabs: []*tab{tb}}64+ sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}}
65 ac := &attachedClient{}65 ac := &attachedClient{}
66 ac.initOverlays()66 ac.initOverlays()
67 ac.setSession(sess)67 ac.setSession(sess)
M internal/usecase/daemon/notify.go +1 -4
@@ -293,10 +293,7 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
293293 func (d *Daemon) deliverGlobal(n domain.Notification) {
294294 d.mu.Lock()
295295 d.notices.routingMu.Lock()
296- sessions := make([]*session, 0, len(d.sessions))
297- for _, s := range d.sessions {
298- sessions = append(sessions, s)
299- }
296+ sessions := localSessionsSnapshot(d.sessions)
300297 d.mu.Unlock()
301298
302299 if d.notices.beforeGlobalDelivery != nil {
@@ -293,10 +293,7 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No
293 func (d *Daemon) deliverGlobal(n domain.Notification) {293 func (d *Daemon) deliverGlobal(n domain.Notification) {
294 d.mu.Lock()294 d.mu.Lock()
295 d.notices.routingMu.Lock()295 d.notices.routingMu.Lock()
296- sessions := make([]*session, 0, len(d.sessions))296+ sessions := localSessionsSnapshot(d.sessions)
297- for _, s := range d.sessions {
298- sessions = append(sessions, s)
299- }
300 d.mu.Unlock()297 d.mu.Unlock()
301 298
302 if d.notices.beforeGlobalDelivery != nil {299 if d.notices.beforeGlobalDelivery != nil {
M internal/usecase/daemon/notify_test.go +4 -4
@@ -364,7 +364,7 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach
364364 for _, pane := range tb.panes {
365365 pane.ctx, pane.cancel = wctx, wcancel
366366 }
367- sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: []*tab{tb}, client: ac}
367+ sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}}
368368 ac.setSession(sess)
369369 d.sessions[sess.id] = sess
370370 t.Cleanup(cancel)
@@ -506,7 +506,7 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
506506
507507 other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
508508 other.initOverlays()
509- sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: other}
509+ sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: other}, ctx: sess.ctx, cancel: func() {}}
510510 other.setSession(sess2)
511511 d.mu.Lock()
512512 d.sessions[sess2.id] = sess2
@@ -596,7 +596,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
596596
597597 clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
598598 clientB.initOverlays()
599- sessB := &session{id: "manual-2", name: "other", ctx: sessA.ctx, cancel: func() {}}
599+ sessB := &session{sessionCore: sessionCore{id: "manual-2", name: "other"}, ctx: sessA.ctx, cancel: func() {}}
600600 clientB.setSession(sessB)
601601 d.mu.Lock()
602602 d.sessions[sessB.id] = sessB
@@ -649,7 +649,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
649649
650650 second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
651651 second.initOverlays()
652- sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: second}
652+ sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: second}, ctx: sess.ctx, cancel: func() {}}
653653 second.setSession(sess2)
654654 d.mu.Lock()
655655 d.sessions[sess2.id] = sess2
@@ -364,7 +364,7 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach
364 for _, pane := range tb.panes {364 for _, pane := range tb.panes {
365 pane.ctx, pane.cancel = wctx, wcancel365 pane.ctx, pane.cancel = wctx, wcancel
366 }366 }
367- sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: []*tab{tb}, client: ac}367+ sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}}
368 ac.setSession(sess)368 ac.setSession(sess)
369 d.sessions[sess.id] = sess369 d.sessions[sess.id] = sess
370 t.Cleanup(cancel)370 t.Cleanup(cancel)
@@ -506,7 +506,7 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
506 506
507 other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}507 other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
508 other.initOverlays()508 other.initOverlays()
509- sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: other}509+ sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: other}, ctx: sess.ctx, cancel: func() {}}
510 other.setSession(sess2)510 other.setSession(sess2)
511 d.mu.Lock()511 d.mu.Lock()
512 d.sessions[sess2.id] = sess2512 d.sessions[sess2.id] = sess2
@@ -596,7 +596,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) {
596 596
597 clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}597 clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
598 clientB.initOverlays()598 clientB.initOverlays()
599- sessB := &session{id: "manual-2", name: "other", ctx: sessA.ctx, cancel: func() {}}599+ sessB := &session{sessionCore: sessionCore{id: "manual-2", name: "other"}, ctx: sessA.ctx, cancel: func() {}}
600 clientB.setSession(sessB)600 clientB.setSession(sessB)
601 d.mu.Lock()601 d.mu.Lock()
602 d.sessions[sessB.id] = sessB602 d.sessions[sessB.id] = sessB
@@ -649,7 +649,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
649 649
650 second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}650 second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
651 second.initOverlays()651 second.initOverlays()
652- sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: second}652+ sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: second}, ctx: sess.ctx, cancel: func() {}}
653 second.setSession(sess2)653 second.setSession(sess2)
654 d.mu.Lock()654 d.mu.Lock()
655 d.sessions[sess2.id] = sess2655 d.sessions[sess2.id] = sess2
M internal/usecase/daemon/palette_test.go +13 -13
@@ -199,8 +199,8 @@ func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
199199 p, release := newBlockingPTY(t)
200200 defer release()
201201 d, current, ac, _ := newManualSessionWithPTYs(t, p)
202- d.sessions["active"] = &session{id: "active", name: "active", createdAt: 10}
203- d.sessions["ephemeral"] = &session{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11}
202+ d.sessions["active"] = &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}}
203+ d.sessions["ephemeral"] = &session{sessionCore: sessionCore{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11}}
204204 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 12}
205205 d.stopped["purging"] = stoppedSession{name: "purging", createdAt: 13, purging: true}
206206
@@ -224,9 +224,9 @@ func TestPaletteRecentSessionsExcludeEphemeralSessions(t *testing.T) {
224224 p, release := newBlockingPTY(t)
225225 defer release()
226226 d, current, ac, _ := newManualSessionWithPTYs(t, p)
227- named := &session{id: "named", name: "named", tabs: []*tab{{}}}
227+ named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
228228 named.mruAt.Store(1)
229- ephemeral := &session{id: "ephemeral", name: "1", ephemeral: true, tabs: []*tab{{}}}
229+ ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
230230 ephemeral.mruAt.Store(2)
231231 d.sessions[named.id] = named
232232 d.sessions[ephemeral.id] = ephemeral
@@ -243,7 +243,7 @@ func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {
243243 p, release := newBlockingPTY(t)
244244 defer release()
245245 d, current, ac, _ := newManualSessionWithPTYs(t, p)
246- target := &session{id: "active", name: "active", createdAt: 10}
246+ target := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}}
247247 d.sessions[target.id] = target
248248
249249 d.enterPalette(current, ac)
@@ -255,7 +255,7 @@ func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {
255255 require.True(t, ok)
256256 require.True(t, isSession)
257257 d.mu.Lock()
258- delete(d.sessions, target.id)
258+ delete(d.sessions, target.core().id)
259259 d.mu.Unlock()
260260
261261 d.handlePaletteInput(ac, []byte("\r"))
@@ -271,7 +271,7 @@ func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {
271271 func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testing.T) {
272272 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
273273 defer releaseAll(releases)
274- target := d.sessions[domain.SessionID("recent")]
274+ target := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
275275 target.createdAt = 42
276276
277277 d.handleInput(current, ac, []byte("\x1b "))
@@ -287,7 +287,7 @@ func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testin
287287 func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *testing.T) {
288288 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
289289 defer releaseAll(releases)
290- target := d.sessions[domain.SessionID("recent")]
290+ target := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
291291 target.createdAt = 42
292292
293293 d.handleInput(current, ac, []byte("\x1b "))
@@ -425,11 +425,11 @@ func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
425425 func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) {
426426 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
427427 defer releaseAll(releases)
428- captured := d.sessions[domain.SessionID("recent")]
428+ captured := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
429429 d.handleInput(current, ac, []byte("\x1b "))
430430 awaitFrame(t, sends, ports.MsgOutput)
431431 // Reordering live MRU after opening must not shift rank 1 from its capture.
432- d.sessions[domain.SessionID("older")].mruAt.Store(100)
432+ d.sessions[domain.SessionID("older")].core().mruAt.Store(100)
433433 d.handleInput(current, ac, []byte("JRS 1\r"))
434434
435435 require.Same(t, captured, ac.currentSession())
@@ -467,7 +467,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
467467 d.afterActionRoleEffectEnded = func(action string) {
468468 if action == "back-session" {
469469 d.mu.Lock()
470- delete(d.sessions, target.id)
470+ delete(d.sessions, target.core().id)
471471 d.mu.Unlock()
472472 }
473473 }
@@ -509,7 +509,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
509509 p, release := newBlockingPTY(t)
510510 defer release()
511511 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
512- target := &session{id: "captured", name: "captured"}
512+ target := &session{sessionCore: sessionCore{id: "captured", name: "captured"}}
513513 d.sessions[target.id] = target
514514
515515 validated := make(chan struct{})
@@ -527,7 +527,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
527527 }()
528528 awaitTestCompletion(t, validated, "JRS did not validate its captured target")
529529 d.mu.Lock()
530- delete(d.sessions, target.id)
530+ delete(d.sessions, target.core().id)
531531 d.mu.Unlock()
532532 close(releaseHandoff)
533533
@@ -199,8 +199,8 @@ func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) {
199 p, release := newBlockingPTY(t)199 p, release := newBlockingPTY(t)
200 defer release()200 defer release()
201 d, current, ac, _ := newManualSessionWithPTYs(t, p)201 d, current, ac, _ := newManualSessionWithPTYs(t, p)
202- d.sessions["active"] = &session{id: "active", name: "active", createdAt: 10}202+ d.sessions["active"] = &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}}
203- d.sessions["ephemeral"] = &session{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11}203+ d.sessions["ephemeral"] = &session{sessionCore: sessionCore{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11}}
204 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 12}204 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 12}
205 d.stopped["purging"] = stoppedSession{name: "purging", createdAt: 13, purging: true}205 d.stopped["purging"] = stoppedSession{name: "purging", createdAt: 13, purging: true}
206 206
@@ -224,9 +224,9 @@ func TestPaletteRecentSessionsExcludeEphemeralSessions(t *testing.T) {
224 p, release := newBlockingPTY(t)224 p, release := newBlockingPTY(t)
225 defer release()225 defer release()
226 d, current, ac, _ := newManualSessionWithPTYs(t, p)226 d, current, ac, _ := newManualSessionWithPTYs(t, p)
227- named := &session{id: "named", name: "named", tabs: []*tab{{}}}227+ named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
228 named.mruAt.Store(1)228 named.mruAt.Store(1)
229- ephemeral := &session{id: "ephemeral", name: "1", ephemeral: true, tabs: []*tab{{}}}229+ ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
230 ephemeral.mruAt.Store(2)230 ephemeral.mruAt.Store(2)
231 d.sessions[named.id] = named231 d.sessions[named.id] = named
232 d.sessions[ephemeral.id] = ephemeral232 d.sessions[ephemeral.id] = ephemeral
@@ -243,7 +243,7 @@ func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {
243 p, release := newBlockingPTY(t)243 p, release := newBlockingPTY(t)
244 defer release()244 defer release()
245 d, current, ac, _ := newManualSessionWithPTYs(t, p)245 d, current, ac, _ := newManualSessionWithPTYs(t, p)
246- target := &session{id: "active", name: "active", createdAt: 10}246+ target := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}}
247 d.sessions[target.id] = target247 d.sessions[target.id] = target
248 248
249 d.enterPalette(current, ac)249 d.enterPalette(current, ac)
@@ -255,7 +255,7 @@ func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {
255 require.True(t, ok)255 require.True(t, ok)
256 require.True(t, isSession)256 require.True(t, isSession)
257 d.mu.Lock()257 d.mu.Lock()
258- delete(d.sessions, target.id)258+ delete(d.sessions, target.core().id)
259 d.mu.Unlock()259 d.mu.Unlock()
260 260
261 d.handlePaletteInput(ac, []byte("\r"))261 d.handlePaletteInput(ac, []byte("\r"))
@@ -271,7 +271,7 @@ func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) {
271 func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testing.T) {271 func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testing.T) {
272 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)272 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
273 defer releaseAll(releases)273 defer releaseAll(releases)
274- target := d.sessions[domain.SessionID("recent")]274+ target := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
275 target.createdAt = 42275 target.createdAt = 42
276 276
277 d.handleInput(current, ac, []byte("\x1b "))277 d.handleInput(current, ac, []byte("\x1b "))
@@ -287,7 +287,7 @@ func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testin
287 func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *testing.T) {287 func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *testing.T) {
288 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)288 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
289 defer releaseAll(releases)289 defer releaseAll(releases)
290- target := d.sessions[domain.SessionID("recent")]290+ target := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
291 target.createdAt = 42291 target.createdAt = 42
292 292
293 d.handleInput(current, ac, []byte("\x1b "))293 d.handleInput(current, ac, []byte("\x1b "))
@@ -425,11 +425,11 @@ func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) {
425 func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) {425 func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) {
426 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)426 d, current, ac, sends, releases := newRecentNavigationTestSessions(t)
427 defer releaseAll(releases)427 defer releaseAll(releases)
428- captured := d.sessions[domain.SessionID("recent")]428+ captured := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
429 d.handleInput(current, ac, []byte("\x1b "))429 d.handleInput(current, ac, []byte("\x1b "))
430 awaitFrame(t, sends, ports.MsgOutput)430 awaitFrame(t, sends, ports.MsgOutput)
431 // Reordering live MRU after opening must not shift rank 1 from its capture.431 // Reordering live MRU after opening must not shift rank 1 from its capture.
432- d.sessions[domain.SessionID("older")].mruAt.Store(100)432+ d.sessions[domain.SessionID("older")].core().mruAt.Store(100)
433 d.handleInput(current, ac, []byte("JRS 1\r"))433 d.handleInput(current, ac, []byte("JRS 1\r"))
434 434
435 require.Same(t, captured, ac.currentSession())435 require.Same(t, captured, ac.currentSession())
@@ -467,7 +467,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
467 d.afterActionRoleEffectEnded = func(action string) {467 d.afterActionRoleEffectEnded = func(action string) {
468 if action == "back-session" {468 if action == "back-session" {
469 d.mu.Lock()469 d.mu.Lock()
470- delete(d.sessions, target.id)470+ delete(d.sessions, target.core().id)
471 d.mu.Unlock()471 d.mu.Unlock()
472 }472 }
473 }473 }
@@ -509,7 +509,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
509 p, release := newBlockingPTY(t)509 p, release := newBlockingPTY(t)
510 defer release()510 defer release()
511 d, sess, ac, sends := newManualSessionWithPTYs(t, p)511 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
512- target := &session{id: "captured", name: "captured"}512+ target := &session{sessionCore: sessionCore{id: "captured", name: "captured"}}
513 d.sessions[target.id] = target513 d.sessions[target.id] = target
514 514
515 validated := make(chan struct{})515 validated := make(chan struct{})
@@ -527,7 +527,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
527 }()527 }()
528 awaitTestCompletion(t, validated, "JRS did not validate its captured target")528 awaitTestCompletion(t, validated, "JRS did not validate its captured target")
529 d.mu.Lock()529 d.mu.Lock()
530- delete(d.sessions, target.id)530+ delete(d.sessions, target.core().id)
531 d.mu.Unlock()531 d.mu.Unlock()
532 close(releaseHandoff)532 close(releaseHandoff)
533 533
M internal/usecase/daemon/pane_actions_test.go +1 -1
@@ -759,7 +759,7 @@ func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *ports
759759 for _, p := range tb.panes {
760760 p.ctx, p.cancel = tctx, cancel
761761 }
762- sess := &session{id: "s", name: "s", cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel}
762+ sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel}
763763 return d, sess, oldPTY, factory
764764 }
765765
@@ -759,7 +759,7 @@ func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *ports
759 for _, p := range tb.panes {759 for _, p := range tb.panes {
760 p.ctx, p.cancel = tctx, cancel760 p.ctx, p.cancel = tctx, cancel
761 }761 }
762- sess := &session{id: "s", name: "s", cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel}762+ sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel}
763 return d, sess, oldPTY, factory763 return d, sess, oldPTY, factory
764 }764 }
765 765
M internal/usecase/daemon/pane_lifetime_test.go +5 -5
@@ -95,7 +95,7 @@ func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {
9595 sessCtx, cancelSession := context.WithCancel(t.Context())
9696 base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
9797 base.ctx, base.cancel = context.WithCancel(sessCtx)
98- sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
98+ sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
9999 d.mu.Lock()
100100 d.sessions[sess.id] = sess
101101 d.mu.Unlock()
@@ -113,7 +113,7 @@ func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {
113113 sessCtx, cancelSession := context.WithCancel(t.Context())
114114 tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10})
115115 tb.ctx, tb.cancel = context.WithCancel(sessCtx)
116- sess := &session{id: "work", name: "work", cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
116+ sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
117117 err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right)
118118 return []*tab{tb}, func() {
119119 cancelSession()
@@ -234,7 +234,7 @@ func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
234234 sessCtx, cancelSession := context.WithCancel(t.Context())
235235 base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
236236 base.ctx, base.cancel = context.WithCancel(sessCtx)
237- sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
237+ sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
238238 d.mu.Lock()
239239 d.sessions[sess.id] = sess
240240 d.mu.Unlock()
@@ -249,7 +249,7 @@ func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
249249 sessCtx, cancelSession := context.WithCancel(t.Context())
250250 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
251251 tb.ctx, tb.cancel = context.WithCancel(sessCtx)
252- sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
252+ sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
253253 target := tb.focusedPane()
254254 require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right))
255255 tb.mu.Lock()
@@ -265,7 +265,7 @@ func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
265265 sessCtx, cancelSession := context.WithCancel(t.Context())
266266 tb := newFloatingTestTab(t)
267267 tb.ctx, tb.cancel = context.WithCancel(sessCtx)
268- sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
268+ sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
269269 tb.mu.Lock()
270270 generation := tb.beginFloatingWarmLocked(true)
271271 tb.mu.Unlock()
@@ -95,7 +95,7 @@ func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {
95 sessCtx, cancelSession := context.WithCancel(t.Context())95 sessCtx, cancelSession := context.WithCancel(t.Context())
96 base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})96 base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
97 base.ctx, base.cancel = context.WithCancel(sessCtx)97 base.ctx, base.cancel = context.WithCancel(sessCtx)
98- sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}98+ sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
99 d.mu.Lock()99 d.mu.Lock()
100 d.sessions[sess.id] = sess100 d.sessions[sess.id] = sess
101 d.mu.Unlock()101 d.mu.Unlock()
@@ -113,7 +113,7 @@ func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {
113 sessCtx, cancelSession := context.WithCancel(t.Context())113 sessCtx, cancelSession := context.WithCancel(t.Context())
114 tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10})114 tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10})
115 tb.ctx, tb.cancel = context.WithCancel(sessCtx)115 tb.ctx, tb.cancel = context.WithCancel(sessCtx)
116- sess := &session{id: "work", name: "work", cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}116+ sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
117 err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right)117 err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right)
118 return []*tab{tb}, func() {118 return []*tab{tb}, func() {
119 cancelSession()119 cancelSession()
@@ -234,7 +234,7 @@ func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
234 sessCtx, cancelSession := context.WithCancel(t.Context())234 sessCtx, cancelSession := context.WithCancel(t.Context())
235 base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})235 base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
236 base.ctx, base.cancel = context.WithCancel(sessCtx)236 base.ctx, base.cancel = context.WithCancel(sessCtx)
237- sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}237+ sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
238 d.mu.Lock()238 d.mu.Lock()
239 d.sessions[sess.id] = sess239 d.sessions[sess.id] = sess
240 d.mu.Unlock()240 d.mu.Unlock()
@@ -249,7 +249,7 @@ func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
249 sessCtx, cancelSession := context.WithCancel(t.Context())249 sessCtx, cancelSession := context.WithCancel(t.Context())
250 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})250 tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
251 tb.ctx, tb.cancel = context.WithCancel(sessCtx)251 tb.ctx, tb.cancel = context.WithCancel(sessCtx)
252- sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}252+ sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
253 target := tb.focusedPane()253 target := tb.focusedPane()
254 require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right))254 require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right))
255 tb.mu.Lock()255 tb.mu.Lock()
@@ -265,7 +265,7 @@ func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) {
265 sessCtx, cancelSession := context.WithCancel(t.Context())265 sessCtx, cancelSession := context.WithCancel(t.Context())
266 tb := newFloatingTestTab(t)266 tb := newFloatingTestTab(t)
267 tb.ctx, tb.cancel = context.WithCancel(sessCtx)267 tb.ctx, tb.cancel = context.WithCancel(sessCtx)
268- sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}268+ sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
269 tb.mu.Lock()269 tb.mu.Lock()
270 generation := tb.beginFloatingWarmLocked(true)270 generation := tb.beginFloatingWarmLocked(true)
271 tb.mu.Unlock()271 tb.mu.Unlock()
M internal/usecase/daemon/pane_owner_reader_test.go +6 -6
@@ -79,11 +79,11 @@ func TestPTYReaderRoutesEffectsToOwnerPublishedForEachRead(t *testing.T) {
7979 source.snapEligible.Store(true)
8080
8181 destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})
82- destination := &session{
83- id: domain.SessionID("destination"),
84- name: "destination",
85- tabs: []*tab{destinationTab},
86- client: &attachedClient{},
82+ destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"),
83+ name: "destination",
84+
85+ client: &attachedClient{}}, tabs: []*tab{destinationTab},
86+
8787 snapEligible: atomic.Bool{},
8888 }
8989 destination.snapEligible.Store(true)
@@ -134,7 +134,7 @@ func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) {
134134 require.NoError(t, destinationTab.tree.Split(layout.PaneID("pane-1"), layout.Right, true, other.id, domain.Rect{Width: 80, Height: 23}))
135135 destinationTab.panes[other.id] = other
136136 destinationTab.mu.Unlock()
137- destination := &session{id: domain.SessionID("destination"), name: "destination", tabs: []*tab{destinationTab}}
137+ destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"), name: "destination"}, tabs: []*tab{destinationTab}}
138138 d.sessions[destination.id] = destination
139139
140140 // Model a completed owner commit while the one reader remains blocked in
@@ -79,11 +79,11 @@ func TestPTYReaderRoutesEffectsToOwnerPublishedForEachRead(t *testing.T) {
79 source.snapEligible.Store(true)79 source.snapEligible.Store(true)
80 80
81 destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})81 destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})
82- destination := &session{82+ destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"),
83- id: domain.SessionID("destination"),83+ name: "destination",
84- name: "destination",84+
85- tabs: []*tab{destinationTab},85+ client: &attachedClient{}}, tabs: []*tab{destinationTab},
86- client: &attachedClient{},86+
87 snapEligible: atomic.Bool{},87 snapEligible: atomic.Bool{},
88 }88 }
89 destination.snapEligible.Store(true)89 destination.snapEligible.Store(true)
@@ -134,7 +134,7 @@ func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) {
134 require.NoError(t, destinationTab.tree.Split(layout.PaneID("pane-1"), layout.Right, true, other.id, domain.Rect{Width: 80, Height: 23}))134 require.NoError(t, destinationTab.tree.Split(layout.PaneID("pane-1"), layout.Right, true, other.id, domain.Rect{Width: 80, Height: 23}))
135 destinationTab.panes[other.id] = other135 destinationTab.panes[other.id] = other
136 destinationTab.mu.Unlock()136 destinationTab.mu.Unlock()
137- destination := &session{id: domain.SessionID("destination"), name: "destination", tabs: []*tab{destinationTab}}137+ destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"), name: "destination"}, tabs: []*tab{destinationTab}}
138 d.sessions[destination.id] = destination138 d.sessions[destination.id] = destination
139 139
140 // Model a completed owner commit while the one reader remains blocked in140 // Model a completed owner commit while the one reader remains blocked in
M internal/usecase/daemon/pane_owner_test.go +4 -4
@@ -10,9 +10,9 @@ import (
1010
1111 func TestPaneOwnerPublicationAdvancesGenerationAndInvalidatesLease(t *testing.T) {
1212 p := newPaneWithStableID(layout.PaneID("pane-1"), "p_stable", nil, domain.Size{Cols: 80, Rows: 23})
13- firstSession := &session{id: "first"}
13+ firstSession := &session{sessionCore: sessionCore{id: "first"}}
1414 firstTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})
15- secondSession := &session{id: "second"}
15+ secondSession := &session{sessionCore: sessionCore{id: "second"}}
1616 secondTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})
1717
1818 p.mu.Lock()
@@ -51,7 +51,7 @@ func TestPaneOwnerPublicationAdvancesGenerationAndInvalidatesLease(t *testing.T)
5151
5252 func TestFloatingPaneOwnerLeaseRequiresCurrentSlotGeneration(t *testing.T) {
5353 p := newPaneWithStableID(layout.PaneID("floating"), "p_float", nil, domain.Size{Cols: 20, Rows: 8})
54- sess := &session{id: "owner"}
54+ sess := &session{sessionCore: sessionCore{id: "owner"}}
5555 tb := newTab(nil, domain.Size{Cols: 80, Rows: 23})
5656
5757 tb.mu.Lock()
@@ -72,7 +72,7 @@ func TestFloatingPaneOwnerLeaseRequiresCurrentSlotGeneration(t *testing.T) {
7272 }
7373
7474 func TestPublishTiledPaneOwnersInitializesAllPanesBeforePublication(t *testing.T) {
75- sess := &session{id: "owner"}
75+ sess := &session{sessionCore: sessionCore{id: "owner"}}
7676 tb := newTabWithStableID("t_stable", "p_one", nil, domain.Size{Cols: 80, Rows: 23})
7777 second := newPaneWithStableID("pane-2", "p_two", nil, domain.Size{Cols: 40, Rows: 23})
7878 tb.panes[second.id] = second
@@ -10,9 +10,9 @@ import (
10 10
11 func TestPaneOwnerPublicationAdvancesGenerationAndInvalidatesLease(t *testing.T) {11 func TestPaneOwnerPublicationAdvancesGenerationAndInvalidatesLease(t *testing.T) {
12 p := newPaneWithStableID(layout.PaneID("pane-1"), "p_stable", nil, domain.Size{Cols: 80, Rows: 23})12 p := newPaneWithStableID(layout.PaneID("pane-1"), "p_stable", nil, domain.Size{Cols: 80, Rows: 23})
13- firstSession := &session{id: "first"}13+ firstSession := &session{sessionCore: sessionCore{id: "first"}}
14 firstTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})14 firstTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})
15- secondSession := &session{id: "second"}15+ secondSession := &session{sessionCore: sessionCore{id: "second"}}
16 secondTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})16 secondTab := newTab(nil, domain.Size{Cols: 80, Rows: 23})
17 17
18 p.mu.Lock()18 p.mu.Lock()
@@ -51,7 +51,7 @@ func TestPaneOwnerPublicationAdvancesGenerationAndInvalidatesLease(t *testing.T)
51 51
52 func TestFloatingPaneOwnerLeaseRequiresCurrentSlotGeneration(t *testing.T) {52 func TestFloatingPaneOwnerLeaseRequiresCurrentSlotGeneration(t *testing.T) {
53 p := newPaneWithStableID(layout.PaneID("floating"), "p_float", nil, domain.Size{Cols: 20, Rows: 8})53 p := newPaneWithStableID(layout.PaneID("floating"), "p_float", nil, domain.Size{Cols: 20, Rows: 8})
54- sess := &session{id: "owner"}54+ sess := &session{sessionCore: sessionCore{id: "owner"}}
55 tb := newTab(nil, domain.Size{Cols: 80, Rows: 23})55 tb := newTab(nil, domain.Size{Cols: 80, Rows: 23})
56 56
57 tb.mu.Lock()57 tb.mu.Lock()
@@ -72,7 +72,7 @@ func TestFloatingPaneOwnerLeaseRequiresCurrentSlotGeneration(t *testing.T) {
72 }72 }
73 73
74 func TestPublishTiledPaneOwnersInitializesAllPanesBeforePublication(t *testing.T) {74 func TestPublishTiledPaneOwnersInitializesAllPanesBeforePublication(t *testing.T) {
75- sess := &session{id: "owner"}75+ sess := &session{sessionCore: sessionCore{id: "owner"}}
76 tb := newTabWithStableID("t_stable", "p_one", nil, domain.Size{Cols: 80, Rows: 23})76 tb := newTabWithStableID("t_stable", "p_one", nil, domain.Size{Cols: 80, Rows: 23})
77 second := newPaneWithStableID("pane-2", "p_two", nil, domain.Size{Cols: 40, Rows: 23})77 second := newPaneWithStableID("pane-2", "p_two", nil, domain.Size{Cols: 40, Rows: 23})
78 tb.panes[second.id] = second78 tb.panes[second.id] = second
M internal/usecase/daemon/pane_rearrange_test.go +2 -2
@@ -84,7 +84,7 @@ func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) {
8484 name: "session",
8585 stale: func(h *paneRearrangeHarness) daemonActionTarget {
8686 target := h.target("pane-1")
87- h.daemon.sessions[h.session.id] = &session{id: h.session.id}
87+ h.daemon.sessions[h.session.id] = &session{sessionCore: sessionCore{id: h.session.id}}
8888 return target
8989 },
9090 },
@@ -481,7 +481,7 @@ func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree)
481481 for _, p := range tb.panes {
482482 p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx)
483483 }
484- sess := &session{id: "s", name: "work", cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}}
484+ sess := &session{sessionCore: sessionCore{id: "s", name: "work"}, cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}}
485485 sess.snapEligible.Store(true)
486486 d.sessions[sess.id] = sess
487487 return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory}
@@ -84,7 +84,7 @@ func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) {
84 name: "session",84 name: "session",
85 stale: func(h *paneRearrangeHarness) daemonActionTarget {85 stale: func(h *paneRearrangeHarness) daemonActionTarget {
86 target := h.target("pane-1")86 target := h.target("pane-1")
87- h.daemon.sessions[h.session.id] = &session{id: h.session.id}87+ h.daemon.sessions[h.session.id] = &session{sessionCore: sessionCore{id: h.session.id}}
88 return target88 return target
89 },89 },
90 },90 },
@@ -481,7 +481,7 @@ func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree)
481 for _, p := range tb.panes {481 for _, p := range tb.panes {
482 p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx)482 p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx)
483 }483 }
484- sess := &session{id: "s", name: "work", cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}}484+ sess := &session{sessionCore: sessionCore{id: "s", name: "work"}, cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}}
485 sess.snapEligible.Store(true)485 sess.snapEligible.Store(true)
486 d.sessions[sess.id] = sess486 d.sessions[sess.id] = sess
487 return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory}487 return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory}
M internal/usecase/daemon/pane_sync_owner_test.go +3 -3
@@ -27,9 +27,9 @@ func newPaneSyncOwnerFixture(t *testing.T) paneSyncOwnerFixture {
2727 size := domain.Size{Cols: 80, Rows: 23}
2828 sourceTab := newTab(newQuietPTY(), size)
2929 targetTab := newTab(newQuietPTY(), size)
30- source := &session{id: "sync-source", tabs: []*tab{sourceTab}, active: 0, client: &attachedClient{}}
31- destination := &session{id: "sync-destination", tabs: []*tab{targetTab}, active: 0, client: &attachedClient{}}
32- d := &Daemon{sessions: map[domain.SessionID]*session{source.id: source, destination.id: destination}}
30+ source := &session{sessionCore: sessionCore{id: "sync-source", client: &attachedClient{}}, tabs: []*tab{sourceTab}, active: 0}
31+ destination := &session{sessionCore: sessionCore{id: "sync-destination", client: &attachedClient{}}, tabs: []*tab{targetTab}, active: 0}
32+ d := &Daemon{sessions: map[domain.SessionID]attachmentSession{source.id: source, destination.id: destination}}
3333 p := sourceTab.focusedPane()
3434 publishPaneOwner(p, source, sourceTab, 0)
3535
@@ -27,9 +27,9 @@ func newPaneSyncOwnerFixture(t *testing.T) paneSyncOwnerFixture {
27 size := domain.Size{Cols: 80, Rows: 23}27 size := domain.Size{Cols: 80, Rows: 23}
28 sourceTab := newTab(newQuietPTY(), size)28 sourceTab := newTab(newQuietPTY(), size)
29 targetTab := newTab(newQuietPTY(), size)29 targetTab := newTab(newQuietPTY(), size)
30- source := &session{id: "sync-source", tabs: []*tab{sourceTab}, active: 0, client: &attachedClient{}}30+ source := &session{sessionCore: sessionCore{id: "sync-source", client: &attachedClient{}}, tabs: []*tab{sourceTab}, active: 0}
31- destination := &session{id: "sync-destination", tabs: []*tab{targetTab}, active: 0, client: &attachedClient{}}31+ destination := &session{sessionCore: sessionCore{id: "sync-destination", client: &attachedClient{}}, tabs: []*tab{targetTab}, active: 0}
32- d := &Daemon{sessions: map[domain.SessionID]*session{source.id: source, destination.id: destination}}32+ d := &Daemon{sessions: map[domain.SessionID]attachmentSession{source.id: source, destination.id: destination}}
33 p := sourceTab.focusedPane()33 p := sourceTab.focusedPane()
34 publishPaneOwner(p, source, sourceTab, 0)34 publishPaneOwner(p, source, sourceTab, 0)
35 35
M internal/usecase/daemon/picker.go +22 -10
@@ -422,7 +422,11 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
422422 }
423423 d.mu.Lock()
424424 var clients []*attachedClient
425- for _, sess := range d.sessions {
425+ for _, entry := range d.sessions {
426+ sess, ok := localSession(entry)
427+ if !ok {
428+ continue
429+ }
426430 sess.mu.Lock()
427431 if sess.client != nil {
428432 clients = append(clients, sess.client)
@@ -470,7 +474,7 @@ func pickerTargetsEqual(left, right picker.Target) bool {
470474 return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt
471475 }
472476
473-func (d *Daemon) sessionByID(id domain.SessionID) *session {
477+func (d *Daemon) sessionByID(id domain.SessionID) attachmentSession {
474478 d.mu.Lock()
475479 defer d.mu.Unlock()
476480 return d.sessions[id]
@@ -513,8 +517,12 @@ func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target pic
513517 }
514518 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
515519 }
516- d.touchMRU(targetSess)
517- token.ac.recordPreviousSession(token.sess)
520+ if target, ok := localSession(targetSess); ok {
521+ d.touchMRU(target)
522+ }
523+ if source, ok := localSession(token.sess); ok {
524+ token.ac.recordPreviousSession(source)
525+ }
518526 d.deferAttachmentTransitionCleanups(transition)
519527 d.firstPaintForTransition(transition.published)
520528 return nil
@@ -537,7 +545,11 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.
537545 }
538546 token.effect.bindActionEnd(d, action)
539547 token.effect.End()
540- return d.switchToTargetGuardedForRole(token.sess, token.ac, target, guard, &token, action)
548+ sess, ok := localSession(token.sess)
549+ if !ok {
550+ return errAttachmentTransition
551+ }
552+ return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action)
541553 }
542554
543555 // switchToTargetGuarded is retained for daemon-internal and headless callers.
@@ -580,7 +592,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
580592 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)
581593 }
582594 }
583- } else if active := d.sessions[target.Session]; active != nil {
595+ } else if active, ok := localSession(d.sessions[target.Session]); ok {
584596 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)
585597 }
586598 d.mu.Unlock()
@@ -849,9 +861,9 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR
849861 return d.killPickerTarget(target)
850862 }
851863 d.mu.Lock()
852- targetSess := d.sessions[target.Session]
864+ targetSess, ok := localSession(d.sessions[target.Session])
853865 d.mu.Unlock()
854- if targetSess == nil {
866+ if !ok {
855867 return nil
856868 }
857869 return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
@@ -866,9 +878,9 @@ func (d *Daemon) killPickerTarget(target picker.Target) error {
866878 return nil
867879 }
868880 d.mu.Lock()
869- targetSess := d.sessions[target.Session]
881+ targetSess, ok := localSession(d.sessions[target.Session])
870882 d.mu.Unlock()
871- if targetSess != nil {
883+ if ok {
872884 return d.killSession(targetSess, ports.ReasonSessionKilled, true)
873885 }
874886 return nil
@@ -422,7 +422,11 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
422 }422 }
423 d.mu.Lock()423 d.mu.Lock()
424 var clients []*attachedClient424 var clients []*attachedClient
425- for _, sess := range d.sessions {425+ for _, entry := range d.sessions {
426+ sess, ok := localSession(entry)
427+ if !ok {
428+ continue
429+ }
426 sess.mu.Lock()430 sess.mu.Lock()
427 if sess.client != nil {431 if sess.client != nil {
428 clients = append(clients, sess.client)432 clients = append(clients, sess.client)
@@ -470,7 +474,7 @@ func pickerTargetsEqual(left, right picker.Target) bool {
470 return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt474 return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt
471 }475 }
472 476
473-func (d *Daemon) sessionByID(id domain.SessionID) *session {477+func (d *Daemon) sessionByID(id domain.SessionID) attachmentSession {
474 d.mu.Lock()478 d.mu.Lock()
475 defer d.mu.Unlock()479 defer d.mu.Unlock()
476 return d.sessions[id]480 return d.sessions[id]
@@ -513,8 +517,12 @@ func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target pic
513 }517 }
514 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)518 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
515 }519 }
516- d.touchMRU(targetSess)520+ if target, ok := localSession(targetSess); ok {
517- token.ac.recordPreviousSession(token.sess)521+ d.touchMRU(target)
522+ }
523+ if source, ok := localSession(token.sess); ok {
524+ token.ac.recordPreviousSession(source)
525+ }
518 d.deferAttachmentTransitionCleanups(transition)526 d.deferAttachmentTransitionCleanups(transition)
519 d.firstPaintForTransition(transition.published)527 d.firstPaintForTransition(transition.published)
520 return nil528 return nil
@@ -537,7 +545,11 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.
537 }545 }
538 token.effect.bindActionEnd(d, action)546 token.effect.bindActionEnd(d, action)
539 token.effect.End()547 token.effect.End()
540- return d.switchToTargetGuardedForRole(token.sess, token.ac, target, guard, &token, action)548+ sess, ok := localSession(token.sess)
549+ if !ok {
550+ return errAttachmentTransition
551+ }
552+ return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action)
541 }553 }
542 554
543 // switchToTargetGuarded is retained for daemon-internal and headless callers.555 // switchToTargetGuarded is retained for daemon-internal and headless callers.
@@ -580,7 +592,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
580 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)592 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)
581 }593 }
582 }594 }
583- } else if active := d.sessions[target.Session]; active != nil {595+ } else if active, ok := localSession(d.sessions[target.Session]); ok {
584 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)596 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)
585 }597 }
586 d.mu.Unlock()598 d.mu.Unlock()
@@ -849,9 +861,9 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR
849 return d.killPickerTarget(target)861 return d.killPickerTarget(target)
850 }862 }
851 d.mu.Lock()863 d.mu.Lock()
852- targetSess := d.sessions[target.Session]864+ targetSess, ok := localSession(d.sessions[target.Session])
853 d.mu.Unlock()865 d.mu.Unlock()
854- if targetSess == nil {866+ if !ok {
855 return nil867 return nil
856 }868 }
857 return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")869 return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
@@ -866,9 +878,9 @@ func (d *Daemon) killPickerTarget(target picker.Target) error {
866 return nil878 return nil
867 }879 }
868 d.mu.Lock()880 d.mu.Lock()
869- targetSess := d.sessions[target.Session]881+ targetSess, ok := localSession(d.sessions[target.Session])
870 d.mu.Unlock()882 d.mu.Unlock()
871- if targetSess != nil {883+ if ok {
872 return d.killSession(targetSess, ports.ReasonSessionKilled, true)884 return d.killSession(targetSess, ports.ReasonSessionKilled, true)
873 }885 }
874 return nil886 return nil
M internal/usecase/daemon/picker_test.go +40 -55
@@ -70,8 +70,8 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
7070 d := newTestDaemon(t, nil, stubClock{})
7171 ctx, cancel := context.WithCancel(d.serveCtx)
7272 defer cancel()
73- current := &session{id: "s1", name: "alpha", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
74- ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}}
73+ current := &session{sessionCore: sessionCore{id: "s1", name: "alpha"}, ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
74+ ringing := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}}
7575 ringing.mu.Lock()
7676 ringing.tabs[1].attention = true
7777 ringing.tabs[1].attentionAt = time.Unix(10, 0)
@@ -99,8 +99,8 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
9999
100100 func TestPickerViewsIncludesEphemeralSessions(t *testing.T) {
101101 d := newTestDaemon(t, nil, stubClock{})
102- current := &session{id: "current", name: "current", tabs: []*tab{{}}}
103- ephemeral := &session{id: "ephemeral", name: "1", ephemeral: true, tabs: []*tab{{}}}
102+ current := &session{sessionCore: sessionCore{id: "current", name: "current"}, tabs: []*tab{{}}}
103+ ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
104104 d.sessions[current.id] = current
105105 d.sessions[ephemeral.id] = ephemeral
106106
@@ -120,11 +120,11 @@ func TestPickerViewsIncludesEphemeralSessions(t *testing.T) {
120120
121121 func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) {
122122 d := newTestDaemon(t, nil, stubClock{})
123- old := &session{id: "old", name: "alpha", tabs: []*tab{{}}}
123+ old := &session{sessionCore: sessionCore{id: "old", name: "alpha"}, tabs: []*tab{{}}}
124124 old.mruAt.Store(1)
125- eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
125+ eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}}
126126 eph.mruAt.Store(3)
127- recent := &session{id: "recent", name: "zeta", tabs: []*tab{{}}}
127+ recent := &session{sessionCore: sessionCore{id: "recent", name: "zeta"}, tabs: []*tab{{}}}
128128 recent.mruAt.Store(2)
129129 d.sessions[old.id] = old
130130 d.sessions[eph.id] = eph
@@ -146,8 +146,8 @@ func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) {
146146
147147 func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) {
148148 d := newTestDaemon(t, nil, stubClock{})
149- b := &session{id: "b", name: "bravo", tabs: []*tab{{}}}
150- a := &session{id: "a", name: "alpha", tabs: []*tab{{}}}
149+ b := &session{sessionCore: sessionCore{id: "b", name: "bravo"}, tabs: []*tab{{}}}
150+ a := &session{sessionCore: sessionCore{id: "a", name: "alpha"}, tabs: []*tab{{}}}
151151 // Equal mruAt (both zero) must yield a stable alphabetical order.
152152 d.sessions[b.id] = b
153153 d.sessions[a.id] = a
@@ -161,11 +161,11 @@ func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) {
161161 func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
162162 d := newTestDaemon(t, nil, stubClock{})
163163 d.pickerSort.Store(uint32(pickerSortGrouped))
164- eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
164+ eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}}
165165 eph.mruAt.Store(5)
166- named := &session{id: "named", name: "work", tabs: []*tab{{}}}
166+ named := &session{sessionCore: sessionCore{id: "named", name: "work"}, tabs: []*tab{{}}}
167167 named.mruAt.Store(2)
168- named2 := &session{id: "named2", name: "notes", tabs: []*tab{{}}}
168+ named2 := &session{sessionCore: sessionCore{id: "named2", name: "notes"}, tabs: []*tab{{}}}
169169 named2.mruAt.Store(3)
170170 d.sessions[eph.id] = eph
171171 d.sessions[named.id] = named
@@ -206,7 +206,7 @@ func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
206206 sess.mruAt.Store(1)
207207 // A second live session, ephemeral and more recently used: recent mode
208208 // lists it first, grouped mode moves it below the named session.
209- eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}
209+ eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}}
210210 eph.mruAt.Store(5)
211211 d.mu.Lock()
212212 d.sessions[eph.id] = eph
@@ -279,7 +279,7 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
279279 d := newTestDaemon(t, nil, stubClock{})
280280 ctx, cancel := context.WithCancel(d.serveCtx)
281281 defer cancel()
282- active := &session{id: "active", name: "active", createdAt: 23, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
282+ active := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 23}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
283283 d.sessions[active.id] = active
284284 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24}
285285
@@ -294,17 +294,13 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
294294
295295 func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
296296 d := newTestDaemon(t, nil, stubClock{})
297- source := &session{
298- id: "source", name: "source", incarnation: domain.IncarnationID{1}, active: 0,
297+ source := &session{sessionCore: sessionCore{id: "source", name: "source", incarnation: domain.IncarnationID{1}}, active: 0,
299298 tabs: []*tab{
300299 {stableID: "source-tab", panes: map[layout.PaneID]*pane{}},
301300 {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}},
302301 },
303302 }
304- destination := &session{
305- id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
306- tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}},
307- }
303+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}}
308304 d.sessions[source.id] = source
309305 d.sessions[destination.id] = destination
310306 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}}
@@ -382,10 +378,7 @@ func TestPickerMovePaneStoresSourceAndCleansPreviewSubscription(t *testing.T) {
382378 d.sessions[sourceSession.id] = sourceSession
383379 targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel)
384380 targetTab.stableID = "destination-tab"
385- targetSession := &session{
386- id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
387- ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab},
388- }
381+ targetSession := &session{sessionCore: sessionCore{id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
389382 d.sessions[targetSession.id] = targetSession
390383 sourceTab := sourceSession.tabs[0]
391384 sourceTab.mu.Lock()
@@ -452,8 +445,7 @@ func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing
452445 sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
453446 delete(d.sessions, domain.SessionID("manual"))
454447 d.sessions[sourceSession.id] = sourceSession
455- target := &session{
456- id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, active: 1,
448+ target := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, active: 1,
457449 ctx: sourceSession.ctx, cancel: func() {},
458450 tabs: []*tab{
459451 newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel),
@@ -499,10 +491,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) {
499491 firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel)
500492 secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel)
501493 firstTab.stableID, secondTab.stableID = "first", "selected"
502- destination := &session{
503- id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
504- ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab},
505- }
494+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab}}
506495 d.sessions[destination.id] = destination
507496 source := moveSourceLocator{
508497 Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
@@ -550,10 +539,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) {
550539 releases = append(releases, release)
551540 tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel)
552541 tb.stableID = string(destination.id) + "-tab"
553- d.sessions[destination.id] = &session{
554- id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true,
555- ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb},
556- }
542+ d.sessions[destination.id] = &session{sessionCore: sessionCore{id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb}}
557543 }
558544 defer releaseAll(releases)
559545 source := moveSourceLocator{
@@ -586,7 +572,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
586572 d, current, ac, _ := newManualSessionWithPTYs(t, p)
587573 ctx, cancel := context.WithCancel(d.serveCtx)
588574 defer cancel()
589- original := &session{id: "ephemeral-1", name: "2", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
575+ original := &session{sessionCore: sessionCore{id: "ephemeral-1", name: "2", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
590576 d.sessions[original.id] = original
591577
592578 views, _ := d.pickerViews(current)
@@ -602,7 +588,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
602588 require.True(t, ok)
603589
604590 delete(d.sessions, original.id)
605- replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
591+ replacement := &session{sessionCore: sessionCore{id: "ephemeral-2", name: original.name, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
606592 d.sessions[replacement.id] = replacement
607593
608594 require.Error(t, d.switchToTarget(current, ac, target))
@@ -709,7 +695,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
709695
710696 ctx, cancel := context.WithCancel(d.serveCtx)
711697 defer cancel()
712- target := &session{id: "restored", name: record.Name, createdAt: record.CreatedAt, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}}
698+ target := &session{sessionCore: sessionCore{id: "restored", name: record.Name, createdAt: record.CreatedAt}, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}}
713699 d.mu.Lock()
714700 delete(d.stopped, record.Name)
715701 d.sessions[target.id] = target
@@ -929,8 +915,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
929915 source.id, source.name = "source", "source"
930916 delete(d.sessions, domain.SessionID("manual"))
931917 d.sessions[source.id] = source
932- target := &session{
933- id: "target", name: "target", ctx: source.ctx, cancel: func() {},
918+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {},
934919 tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})},
935920 }
936921 d.sessions[target.id] = target
@@ -977,8 +962,8 @@ func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) {
977962 sctx2, cancel2 := context.WithCancel(d.serveCtx)
978963 defer cancel1()
979964 defer cancel2()
980- sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
981- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
965+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
966+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
982967 ac1.setSession(sess1)
983968 ac2.setSession(sess2)
984969 ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil)
@@ -1031,8 +1016,8 @@ func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
10311016 sctx2, cancel2 := context.WithCancel(d.serveCtx)
10321017 defer cancel1()
10331018 defer cancel2()
1034- sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
1035- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
1019+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
1020+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
10361021 ac1.setSession(sess1)
10371022 ac2.setSession(sess2)
10381023 d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2
@@ -1066,8 +1051,8 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
10661051 sctx2, cancel2 := context.WithCancel(d.serveCtx)
10671052 defer cancel1()
10681053 defer cancel2()
1069- sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
1070- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
1054+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
1055+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
10711056 ac1.setSession(sess1)
10721057 ac2.setSession(sess2)
10731058 d.sessions[sess1.id] = sess1
@@ -1117,8 +1102,8 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
11171102 sctx2, cancel2 := context.WithCancel(d.serveCtx)
11181103 defer cancel1()
11191104 defer cancel2()
1120- sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac}
1121- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
1105+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
1106+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
11221107 ac.setSession(sess1)
11231108 d.sessions[sess1.id] = sess1
11241109 d.sessions[sess2.id] = sess2
@@ -1197,8 +1182,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
11971182 sctx2, cancel2 := context.WithCancel(d.serveCtx)
11981183 defer cancel1()
11991184 defer cancel2()
1200- sess1 := &session{id: "s1", name: "alpha", cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}}
1201- sess2 := &session{id: "s2", name: "beta", cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}}
1185+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}}
1186+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}}
12021187 ac1.setSession(sess1)
12031188 ac2.setSession(sess2)
12041189 d.sessions[sess1.id] = sess1
@@ -1554,7 +1539,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
15541539 sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
15551540 require.NoError(t, err)
15561541 d.resumeStoppedAndSwitch(sess, ac, picker.Target{Name: "old", Stopped: true})
1557- got := ac.sess.Get()
1542+ got := ac.currentSession()
15581543 require.NotNil(t, got)
15591544 got.mu.Lock()
15601545 require.True(t, got.terminal.TrueColor)
@@ -1664,14 +1649,14 @@ func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
16641649 defer releaseOther()
16651650 otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)
16661651 otherTab.stableID = "other-tab"
1667- other := &session{id: "other", name: tt.otherName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1652+ other := &session{sessionCore: sessionCore{id: "other", name: tt.otherName, ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
16681653 d.sessions[other.id] = other
16691654
16701655 targetPTY, releaseTarget := newBlockingPTY(t)
16711656 defer releaseTarget()
16721657 targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
16731658 targetTab.stableID = "target-tab"
1674- target := &session{id: "target", name: tt.targetName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1659+ target := &session{sessionCore: sessionCore{id: "target", name: tt.targetName, ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
16751660 d.sessions[target.id] = target
16761661
16771662 d.enterPicker(current, ac)
@@ -1712,7 +1697,7 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T)
17121697 targetPTY, releaseTarget := newBlockingPTY(t)
17131698 defer releaseTarget()
17141699 targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1715- target := &session{id: "target", name: "a-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1700+ target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
17161701 d.sessions[target.id] = target
17171702 current.mu.Lock()
17181703 current.client = ac
@@ -1746,7 +1731,7 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
17461731 defer releaseAll(releases)
17471732 cause := errors.New("snapshot delete refused")
17481733 WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause})(d)
1749- target := d.sessions[domain.SessionID("recent")]
1734+ target := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
17501735 store, _ := newMockStore(t)
17511736 WithStore(t, store)(d)
17521737 target.mu.Lock()
@@ -1792,7 +1777,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
17921777 defer releaseOther()
17931778 otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel)
17941779 otherTab.stableID = "other-tab"
1795- other := &session{id: "other", name: "a-other", ephemeral: true, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1780+ other := &session{sessionCore: sessionCore{id: "other", name: "a-other", ephemeral: true}, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
17961781 d.sessions[other.id] = other
17971782 cause := errors.New("delete failed")
17981783 store, state := newMockStore(t)
@@ -70,8 +70,8 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
70 d := newTestDaemon(t, nil, stubClock{})70 d := newTestDaemon(t, nil, stubClock{})
71 ctx, cancel := context.WithCancel(d.serveCtx)71 ctx, cancel := context.WithCancel(d.serveCtx)
72 defer cancel()72 defer cancel()
73- current := &session{id: "s1", name: "alpha", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}73+ current := &session{sessionCore: sessionCore{id: "s1", name: "alpha"}, ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
74- ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}}74+ ringing := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}}
75 ringing.mu.Lock()75 ringing.mu.Lock()
76 ringing.tabs[1].attention = true76 ringing.tabs[1].attention = true
77 ringing.tabs[1].attentionAt = time.Unix(10, 0)77 ringing.tabs[1].attentionAt = time.Unix(10, 0)
@@ -99,8 +99,8 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
99 99
100 func TestPickerViewsIncludesEphemeralSessions(t *testing.T) {100 func TestPickerViewsIncludesEphemeralSessions(t *testing.T) {
101 d := newTestDaemon(t, nil, stubClock{})101 d := newTestDaemon(t, nil, stubClock{})
102- current := &session{id: "current", name: "current", tabs: []*tab{{}}}102+ current := &session{sessionCore: sessionCore{id: "current", name: "current"}, tabs: []*tab{{}}}
103- ephemeral := &session{id: "ephemeral", name: "1", ephemeral: true, tabs: []*tab{{}}}103+ ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
104 d.sessions[current.id] = current104 d.sessions[current.id] = current
105 d.sessions[ephemeral.id] = ephemeral105 d.sessions[ephemeral.id] = ephemeral
106 106
@@ -120,11 +120,11 @@ func TestPickerViewsIncludesEphemeralSessions(t *testing.T) {
120 120
121 func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) {121 func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) {
122 d := newTestDaemon(t, nil, stubClock{})122 d := newTestDaemon(t, nil, stubClock{})
123- old := &session{id: "old", name: "alpha", tabs: []*tab{{}}}123+ old := &session{sessionCore: sessionCore{id: "old", name: "alpha"}, tabs: []*tab{{}}}
124 old.mruAt.Store(1)124 old.mruAt.Store(1)
125- eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}125+ eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}}
126 eph.mruAt.Store(3)126 eph.mruAt.Store(3)
127- recent := &session{id: "recent", name: "zeta", tabs: []*tab{{}}}127+ recent := &session{sessionCore: sessionCore{id: "recent", name: "zeta"}, tabs: []*tab{{}}}
128 recent.mruAt.Store(2)128 recent.mruAt.Store(2)
129 d.sessions[old.id] = old129 d.sessions[old.id] = old
130 d.sessions[eph.id] = eph130 d.sessions[eph.id] = eph
@@ -146,8 +146,8 @@ func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) {
146 146
147 func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) {147 func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) {
148 d := newTestDaemon(t, nil, stubClock{})148 d := newTestDaemon(t, nil, stubClock{})
149- b := &session{id: "b", name: "bravo", tabs: []*tab{{}}}149+ b := &session{sessionCore: sessionCore{id: "b", name: "bravo"}, tabs: []*tab{{}}}
150- a := &session{id: "a", name: "alpha", tabs: []*tab{{}}}150+ a := &session{sessionCore: sessionCore{id: "a", name: "alpha"}, tabs: []*tab{{}}}
151 // Equal mruAt (both zero) must yield a stable alphabetical order.151 // Equal mruAt (both zero) must yield a stable alphabetical order.
152 d.sessions[b.id] = b152 d.sessions[b.id] = b
153 d.sessions[a.id] = a153 d.sessions[a.id] = a
@@ -161,11 +161,11 @@ func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) {
161 func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {161 func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) {
162 d := newTestDaemon(t, nil, stubClock{})162 d := newTestDaemon(t, nil, stubClock{})
163 d.pickerSort.Store(uint32(pickerSortGrouped))163 d.pickerSort.Store(uint32(pickerSortGrouped))
164- eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}164+ eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}}
165 eph.mruAt.Store(5)165 eph.mruAt.Store(5)
166- named := &session{id: "named", name: "work", tabs: []*tab{{}}}166+ named := &session{sessionCore: sessionCore{id: "named", name: "work"}, tabs: []*tab{{}}}
167 named.mruAt.Store(2)167 named.mruAt.Store(2)
168- named2 := &session{id: "named2", name: "notes", tabs: []*tab{{}}}168+ named2 := &session{sessionCore: sessionCore{id: "named2", name: "notes"}, tabs: []*tab{{}}}
169 named2.mruAt.Store(3)169 named2.mruAt.Store(3)
170 d.sessions[eph.id] = eph170 d.sessions[eph.id] = eph
171 d.sessions[named.id] = named171 d.sessions[named.id] = named
@@ -206,7 +206,7 @@ func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) {
206 sess.mruAt.Store(1)206 sess.mruAt.Store(1)
207 // A second live session, ephemeral and more recently used: recent mode207 // A second live session, ephemeral and more recently used: recent mode
208 // lists it first, grouped mode moves it below the named session.208 // lists it first, grouped mode moves it below the named session.
209- eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}}209+ eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}}
210 eph.mruAt.Store(5)210 eph.mruAt.Store(5)
211 d.mu.Lock()211 d.mu.Lock()
212 d.sessions[eph.id] = eph212 d.sessions[eph.id] = eph
@@ -279,7 +279,7 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
279 d := newTestDaemon(t, nil, stubClock{})279 d := newTestDaemon(t, nil, stubClock{})
280 ctx, cancel := context.WithCancel(d.serveCtx)280 ctx, cancel := context.WithCancel(d.serveCtx)
281 defer cancel()281 defer cancel()
282- active := &session{id: "active", name: "active", createdAt: 23, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}282+ active := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 23}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
283 d.sessions[active.id] = active283 d.sessions[active.id] = active
284 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24}284 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24}
285 285
@@ -294,17 +294,13 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) {
294 294
295 func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {295 func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) {
296 d := newTestDaemon(t, nil, stubClock{})296 d := newTestDaemon(t, nil, stubClock{})
297- source := &session{297+ source := &session{sessionCore: sessionCore{id: "source", name: "source", incarnation: domain.IncarnationID{1}}, active: 0,
298- id: "source", name: "source", incarnation: domain.IncarnationID{1}, active: 0,
299 tabs: []*tab{298 tabs: []*tab{
300 {stableID: "source-tab", panes: map[layout.PaneID]*pane{}},299 {stableID: "source-tab", panes: map[layout.PaneID]*pane{}},
301 {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}},300 {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}},
302 },301 },
303 }302 }
304- destination := &session{303+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}}
305- id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
306- tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}},
307- }
308 d.sessions[source.id] = source304 d.sessions[source.id] = source
309 d.sessions[destination.id] = destination305 d.sessions[destination.id] = destination
310 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}}306 d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}}
@@ -382,10 +378,7 @@ func TestPickerMovePaneStoresSourceAndCleansPreviewSubscription(t *testing.T) {
382 d.sessions[sourceSession.id] = sourceSession378 d.sessions[sourceSession.id] = sourceSession
383 targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel)379 targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel)
384 targetTab.stableID = "destination-tab"380 targetTab.stableID = "destination-tab"
385- targetSession := &session{381+ targetSession := &session{sessionCore: sessionCore{id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
386- id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
387- ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab},
388- }
389 d.sessions[targetSession.id] = targetSession382 d.sessions[targetSession.id] = targetSession
390 sourceTab := sourceSession.tabs[0]383 sourceTab := sourceSession.tabs[0]
391 sourceTab.mu.Lock()384 sourceTab.mu.Lock()
@@ -452,8 +445,7 @@ func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing
452 sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}445 sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1}
453 delete(d.sessions, domain.SessionID("manual"))446 delete(d.sessions, domain.SessionID("manual"))
454 d.sessions[sourceSession.id] = sourceSession447 d.sessions[sourceSession.id] = sourceSession
455- target := &session{448+ target := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, active: 1,
456- id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, active: 1,
457 ctx: sourceSession.ctx, cancel: func() {},449 ctx: sourceSession.ctx, cancel: func() {},
458 tabs: []*tab{450 tabs: []*tab{
459 newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel),451 newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel),
@@ -499,10 +491,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) {
499 firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel)491 firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel)
500 secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel)492 secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel)
501 firstTab.stableID, secondTab.stableID = "first", "selected"493 firstTab.stableID, secondTab.stableID = "first", "selected"
502- destination := &session{494+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab}}
503- id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true,
504- ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab},
505- }
506 d.sessions[destination.id] = destination495 d.sessions[destination.id] = destination
507 source := moveSourceLocator{496 source := moveSourceLocator{
508 Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},497 Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name},
@@ -550,10 +539,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) {
550 releases = append(releases, release)539 releases = append(releases, release)
551 tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel)540 tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel)
552 tb.stableID = string(destination.id) + "-tab"541 tb.stableID = string(destination.id) + "-tab"
553- d.sessions[destination.id] = &session{542+ d.sessions[destination.id] = &session{sessionCore: sessionCore{id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb}}
554- id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true,
555- ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb},
556- }
557 }543 }
558 defer releaseAll(releases)544 defer releaseAll(releases)
559 source := moveSourceLocator{545 source := moveSourceLocator{
@@ -586,7 +572,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
586 d, current, ac, _ := newManualSessionWithPTYs(t, p)572 d, current, ac, _ := newManualSessionWithPTYs(t, p)
587 ctx, cancel := context.WithCancel(d.serveCtx)573 ctx, cancel := context.WithCancel(d.serveCtx)
588 defer cancel()574 defer cancel()
589- original := &session{id: "ephemeral-1", name: "2", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}575+ original := &session{sessionCore: sessionCore{id: "ephemeral-1", name: "2", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
590 d.sessions[original.id] = original576 d.sessions[original.id] = original
591 577
592 views, _ := d.pickerViews(current)578 views, _ := d.pickerViews(current)
@@ -602,7 +588,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) {
602 require.True(t, ok)588 require.True(t, ok)
603 589
604 delete(d.sessions, original.id)590 delete(d.sessions, original.id)
605- replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}591+ replacement := &session{sessionCore: sessionCore{id: "ephemeral-2", name: original.name, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}}
606 d.sessions[replacement.id] = replacement592 d.sessions[replacement.id] = replacement
607 593
608 require.Error(t, d.switchToTarget(current, ac, target))594 require.Error(t, d.switchToTarget(current, ac, target))
@@ -709,7 +695,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
709 695
710 ctx, cancel := context.WithCancel(d.serveCtx)696 ctx, cancel := context.WithCancel(d.serveCtx)
711 defer cancel()697 defer cancel()
712- target := &session{id: "restored", name: record.Name, createdAt: record.CreatedAt, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}}698+ target := &session{sessionCore: sessionCore{id: "restored", name: record.Name, createdAt: record.CreatedAt}, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}}
713 d.mu.Lock()699 d.mu.Lock()
714 delete(d.stopped, record.Name)700 delete(d.stopped, record.Name)
715 d.sessions[target.id] = target701 d.sessions[target.id] = target
@@ -929,8 +915,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
929 source.id, source.name = "source", "source"915 source.id, source.name = "source", "source"
930 delete(d.sessions, domain.SessionID("manual"))916 delete(d.sessions, domain.SessionID("manual"))
931 d.sessions[source.id] = source917 d.sessions[source.id] = source
932- target := &session{918+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {},
933- id: "target", name: "target", ctx: source.ctx, cancel: func() {},
934 tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})},919 tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})},
935 }920 }
936 d.sessions[target.id] = target921 d.sessions[target.id] = target
@@ -977,8 +962,8 @@ func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) {
977 sctx2, cancel2 := context.WithCancel(d.serveCtx)962 sctx2, cancel2 := context.WithCancel(d.serveCtx)
978 defer cancel1()963 defer cancel1()
979 defer cancel2()964 defer cancel2()
980- sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}965+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
981- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}966+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
982 ac1.setSession(sess1)967 ac1.setSession(sess1)
983 ac2.setSession(sess2)968 ac2.setSession(sess2)
984 ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil)969 ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil)
@@ -1031,8 +1016,8 @@ func TestPickerDisplacementCancelsSupersededResize(t *testing.T) {
1031 sctx2, cancel2 := context.WithCancel(d.serveCtx)1016 sctx2, cancel2 := context.WithCancel(d.serveCtx)
1032 defer cancel1()1017 defer cancel1()
1033 defer cancel2()1018 defer cancel2()
1034- sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}1019+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
1035- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}1020+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
1036 ac1.setSession(sess1)1021 ac1.setSession(sess1)
1037 ac2.setSession(sess2)1022 ac2.setSession(sess2)
1038 d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess21023 d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2
@@ -1066,8 +1051,8 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
1066 sctx2, cancel2 := context.WithCancel(d.serveCtx)1051 sctx2, cancel2 := context.WithCancel(d.serveCtx)
1067 defer cancel1()1052 defer cancel1()
1068 defer cancel2()1053 defer cancel2()
1069- sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}1054+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
1070- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}1055+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
1071 ac1.setSession(sess1)1056 ac1.setSession(sess1)
1072 ac2.setSession(sess2)1057 ac2.setSession(sess2)
1073 d.sessions[sess1.id] = sess11058 d.sessions[sess1.id] = sess1
@@ -1117,8 +1102,8 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
1117 sctx2, cancel2 := context.WithCancel(d.serveCtx)1102 sctx2, cancel2 := context.WithCancel(d.serveCtx)
1118 defer cancel1()1103 defer cancel1()
1119 defer cancel2()1104 defer cancel2()
1120- sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac}1105+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}}
1121- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}1106+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}}
1122 ac.setSession(sess1)1107 ac.setSession(sess1)
1123 d.sessions[sess1.id] = sess11108 d.sessions[sess1.id] = sess1
1124 d.sessions[sess2.id] = sess21109 d.sessions[sess2.id] = sess2
@@ -1197,8 +1182,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
1197 sctx2, cancel2 := context.WithCancel(d.serveCtx)1182 sctx2, cancel2 := context.WithCancel(d.serveCtx)
1198 defer cancel1()1183 defer cancel1()
1199 defer cancel2()1184 defer cancel2()
1200- sess1 := &session{id: "s1", name: "alpha", cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}}1185+ sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}}
1201- sess2 := &session{id: "s2", name: "beta", cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}}1186+ sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}}
1202 ac1.setSession(sess1)1187 ac1.setSession(sess1)
1203 ac2.setSession(sess2)1188 ac2.setSession(sess2)
1204 d.sessions[sess1.id] = sess11189 d.sessions[sess1.id] = sess1
@@ -1554,7 +1539,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
1554 sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)1539 sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr)
1555 require.NoError(t, err)1540 require.NoError(t, err)
1556 d.resumeStoppedAndSwitch(sess, ac, picker.Target{Name: "old", Stopped: true})1541 d.resumeStoppedAndSwitch(sess, ac, picker.Target{Name: "old", Stopped: true})
1557- got := ac.sess.Get()1542+ got := ac.currentSession()
1558 require.NotNil(t, got)1543 require.NotNil(t, got)
1559 got.mu.Lock()1544 got.mu.Lock()
1560 require.True(t, got.terminal.TrueColor)1545 require.True(t, got.terminal.TrueColor)
@@ -1664,14 +1649,14 @@ func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
1664 defer releaseOther()1649 defer releaseOther()
1665 otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)1650 otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel)
1666 otherTab.stableID = "other-tab"1651 otherTab.stableID = "other-tab"
1667- other := &session{id: "other", name: tt.otherName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}1652+ other := &session{sessionCore: sessionCore{id: "other", name: tt.otherName, ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1668 d.sessions[other.id] = other1653 d.sessions[other.id] = other
1669 1654
1670 targetPTY, releaseTarget := newBlockingPTY(t)1655 targetPTY, releaseTarget := newBlockingPTY(t)
1671 defer releaseTarget()1656 defer releaseTarget()
1672 targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)1657 targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1673 targetTab.stableID = "target-tab"1658 targetTab.stableID = "target-tab"
1674- target := &session{id: "target", name: tt.targetName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}1659+ target := &session{sessionCore: sessionCore{id: "target", name: tt.targetName, ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1675 d.sessions[target.id] = target1660 d.sessions[target.id] = target
1676 1661
1677 d.enterPicker(current, ac)1662 d.enterPicker(current, ac)
@@ -1712,7 +1697,7 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T)
1712 targetPTY, releaseTarget := newBlockingPTY(t)1697 targetPTY, releaseTarget := newBlockingPTY(t)
1713 defer releaseTarget()1698 defer releaseTarget()
1714 targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)1699 targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel)
1715- target := &session{id: "target", name: "a-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}1700+ target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}}
1716 d.sessions[target.id] = target1701 d.sessions[target.id] = target
1717 current.mu.Lock()1702 current.mu.Lock()
1718 current.client = ac1703 current.client = ac
@@ -1746,7 +1731,7 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
1746 defer releaseAll(releases)1731 defer releaseAll(releases)
1747 cause := errors.New("snapshot delete refused")1732 cause := errors.New("snapshot delete refused")
1748 WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause})(d)1733 WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause})(d)
1749- target := d.sessions[domain.SessionID("recent")]1734+ target := mustLocalSession(t, d.sessions[domain.SessionID("recent")])
1750 store, _ := newMockStore(t)1735 store, _ := newMockStore(t)
1751 WithStore(t, store)(d)1736 WithStore(t, store)(d)
1752 target.mu.Lock()1737 target.mu.Lock()
@@ -1792,7 +1777,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
1792 defer releaseOther()1777 defer releaseOther()
1793 otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel)1778 otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel)
1794 otherTab.stableID = "other-tab"1779 otherTab.stableID = "other-tab"
1795- other := &session{id: "other", name: "a-other", ephemeral: true, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}}1780+ other := &session{sessionCore: sessionCore{id: "other", name: "a-other", ephemeral: true}, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}}
1796 d.sessions[other.id] = other1781 d.sessions[other.id] = other
1797 cause := errors.New("delete failed")1782 cause := errors.New("delete failed")
1798 store, state := newMockStore(t)1783 store, state := newMockStore(t)
M internal/usecase/daemon/prompt_test.go +2 -2
@@ -66,7 +66,7 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
6666 defer release2()
6767 ctx, cancel := context.WithCancel(context.Background())
6868 defer cancel()
69- d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}}
69+ d.sessions["other"] = &session{sessionCore: sessionCore{id: "other", name: "taken"}, ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}}
7070
7171 d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
7272 awaitFrame(t, sends, ports.MsgOutput)
@@ -123,7 +123,7 @@ func TestPromptSubmitValidationErrorStaysInlineOnly(t *testing.T) {
123123 input: []byte("taken\r"),
124124 wantErr: errSessionNameInUse,
125125 setup: func(d *Daemon) {
126- d.sessions["taken"] = &session{id: "taken", name: "taken"}
126+ d.sessions["taken"] = &session{sessionCore: sessionCore{id: "taken", name: "taken"}}
127127 },
128128 },
129129 }
@@ -66,7 +66,7 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
66 defer release2()66 defer release2()
67 ctx, cancel := context.WithCancel(context.Background())67 ctx, cancel := context.WithCancel(context.Background())
68 defer cancel()68 defer cancel()
69- d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}}69+ d.sessions["other"] = &session{sessionCore: sessionCore{id: "other", name: "taken"}, ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}}
70 70
71 d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })71 d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
72 awaitFrame(t, sends, ports.MsgOutput)72 awaitFrame(t, sends, ports.MsgOutput)
@@ -123,7 +123,7 @@ func TestPromptSubmitValidationErrorStaysInlineOnly(t *testing.T) {
123 input: []byte("taken\r"),123 input: []byte("taken\r"),
124 wantErr: errSessionNameInUse,124 wantErr: errSessionNameInUse,
125 setup: func(d *Daemon) {125 setup: func(d *Daemon) {
126- d.sessions["taken"] = &session{id: "taken", name: "taken"}126+ d.sessions["taken"] = &session{sessionCore: sessionCore{id: "taken", name: "taken"}}
127 },127 },
128 },128 },
129 }129 }
M internal/usecase/daemon/render.go +13 -9
@@ -60,10 +60,7 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
6060 return false
6161 }
6262 d.mu.Lock()
63- sessions := make([]*session, 0, len(d.sessions))
64- for _, sess := range d.sessions {
65- sessions = append(sessions, sess)
66- }
63+ sessions := localSessionsSnapshot(d.sessions)
6764 d.mu.Unlock()
6865 for _, sess := range sessions {
6966 sess.mu.Lock()
@@ -294,10 +291,17 @@ func (b *runtimeMarkBatch) flush() {
294291 }
295292 }
296293
297-func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) {
294+func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {
295+ // Phase 1 has only the local implementation. Keep all local preparation
296+ // byte-identical while routing the attachment-facing capture through the
297+ // interface; proxy-specific preparation is added with proxy rendering.
298+ sess, ok := localSession(entry)
299+ if !ok {
300+ return
301+ }
298302 marks := d.newRuntimeMarkBatch()
299303 if lease != nil {
300- token := sess.attachmentToken(ac, ac.transport())
304+ token := attachmentToken(entry, ac, ac.transport())
301305 token.lease = lease
302306 ticket, admitted := ac.beginRoleEffect(token)
303307 if !admitted {
@@ -318,12 +322,12 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
318322 sess.mu.Lock()
319323 owned := sess.client == ac
320324 sess.mu.Unlock()
321- if !owned || ac.currentSession() != sess {
325+ if !owned || ac.currentAttachmentSession() != entry {
322326 ac.sendMu.Unlock()
323327 return
324328 }
325329 if lease != nil {
326- rc := sess.renderCoordinator()
330+ rc := attachmentRenderCoordinator(entry)
327331 if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {
328332 ac.sendMu.Unlock()
329333 return
@@ -397,7 +401,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
397401 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
398402 }
399403 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
400- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
404+ state, ok := entry.capturePrimary(ac, primaryCaptureRequest{
401405 bars: bars,
402406 overlays: capturedOverlays,
403407 preview: preview,
@@ -60,10 +60,7 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool {
60 return false60 return false
61 }61 }
62 d.mu.Lock()62 d.mu.Lock()
63- sessions := make([]*session, 0, len(d.sessions))63+ sessions := localSessionsSnapshot(d.sessions)
64- for _, sess := range d.sessions {
65- sessions = append(sessions, sess)
66- }
67 d.mu.Unlock()64 d.mu.Unlock()
68 for _, sess := range sessions {65 for _, sess := range sessions {
69 sess.mu.Lock()66 sess.mu.Lock()
@@ -294,10 +291,17 @@ func (b *runtimeMarkBatch) flush() {
294 }291 }
295 }292 }
296 293
297-func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) {294+func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) {
295+ // Phase 1 has only the local implementation. Keep all local preparation
296+ // byte-identical while routing the attachment-facing capture through the
297+ // interface; proxy-specific preparation is added with proxy rendering.
298+ sess, ok := localSession(entry)
299+ if !ok {
300+ return
301+ }
298 marks := d.newRuntimeMarkBatch()302 marks := d.newRuntimeMarkBatch()
299 if lease != nil {303 if lease != nil {
300- token := sess.attachmentToken(ac, ac.transport())304+ token := attachmentToken(entry, ac, ac.transport())
301 token.lease = lease305 token.lease = lease
302 ticket, admitted := ac.beginRoleEffect(token)306 ticket, admitted := ac.beginRoleEffect(token)
303 if !admitted {307 if !admitted {
@@ -318,12 +322,12 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
318 sess.mu.Lock()322 sess.mu.Lock()
319 owned := sess.client == ac323 owned := sess.client == ac
320 sess.mu.Unlock()324 sess.mu.Unlock()
321- if !owned || ac.currentSession() != sess {325+ if !owned || ac.currentAttachmentSession() != entry {
322 ac.sendMu.Unlock()326 ac.sendMu.Unlock()
323 return327 return
324 }328 }
325 if lease != nil {329 if lease != nil {
326- rc := sess.renderCoordinator()330+ rc := attachmentRenderCoordinator(entry)
327 if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {331 if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {
328 ac.sendMu.Unlock()332 ac.sendMu.Unlock()
329 return333 return
@@ -397,7 +401,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
397 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,401 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
398 }402 }
399 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)403 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
400- state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{404+ state, ok := entry.capturePrimary(ac, primaryCaptureRequest{
401 bars: bars,405 bars: bars,
402 overlays: capturedOverlays,406 overlays: capturedOverlays,
403 preview: preview,407 preview: preview,
M internal/usecase/daemon/render_capture.go +9 -3
@@ -190,11 +190,11 @@ type primaryCaptureRequest struct {
190190 lease *attachmentLease
191191 }
192192
193-// capturePrimaryRenderState is the ownership boundary for a primary render
194-// transaction. Callers hold attachment sendMu; this function then follows
193+// captureLocalPrimaryRenderState is the ownership boundary for a local primary
194+// render transaction. Callers hold attachment sendMu; this function then follows
195195 // session -> tab -> pane lock order. ACK-blocked capture returns before touching
196196 // VT damage, and every captured pane records a receipt for successful emission.
197-func capturePrimaryRenderState(
197+func captureLocalPrimaryRenderState(
198198 sess *session,
199199 ac *attachedClient,
200200 request primaryCaptureRequest,
@@ -321,6 +321,12 @@ func capturePrimaryRenderState(
321321 return state, true
322322 }
323323
324+// capturePrimaryRenderState preserves the focused local test seam. Production
325+// rendering delegates through attachmentSession.capturePrimary.
326+func capturePrimaryRenderState(sess *session, ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) {
327+ return captureLocalPrimaryRenderState(sess, ac, request)
328+}
329+
324330 // copyRankedRecentInto preserves the non-nil empty slice that selects
325331 // contextual recent-session mode while retaining scratch capacity for reuse.
326332 func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
@@ -190,11 +190,11 @@ type primaryCaptureRequest struct {
190 lease *attachmentLease190 lease *attachmentLease
191 }191 }
192 192
193-// capturePrimaryRenderState is the ownership boundary for a primary render193+// captureLocalPrimaryRenderState is the ownership boundary for a local primary
194-// transaction. Callers hold attachment sendMu; this function then follows194+// render transaction. Callers hold attachment sendMu; this function then follows
195 // session -> tab -> pane lock order. ACK-blocked capture returns before touching195 // session -> tab -> pane lock order. ACK-blocked capture returns before touching
196 // VT damage, and every captured pane records a receipt for successful emission.196 // VT damage, and every captured pane records a receipt for successful emission.
197-func capturePrimaryRenderState(197+func captureLocalPrimaryRenderState(
198 sess *session,198 sess *session,
199 ac *attachedClient,199 ac *attachedClient,
200 request primaryCaptureRequest,200 request primaryCaptureRequest,
@@ -321,6 +321,12 @@ func capturePrimaryRenderState(
321 return state, true321 return state, true
322 }322 }
323 323
324+// capturePrimaryRenderState preserves the focused local test seam. Production
325+// rendering delegates through attachmentSession.capturePrimary.
326+func capturePrimaryRenderState(sess *session, ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) {
327+ return captureLocalPrimaryRenderState(sess, ac, request)
328+}
329+
324 // copyRankedRecentInto preserves the non-nil empty slice that selects330 // copyRankedRecentInto preserves the non-nil empty slice that selects
325 // contextual recent-session mode while retaining scratch capacity for reuse.331 // contextual recent-session mode while retaining scratch capacity for reuse.
326 func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {332 func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
M internal/usecase/daemon/render_coordinator.go +11 -15
@@ -745,31 +745,27 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
745745 // invalidateRender is the sole producer fan-in. In tests and transitional
746746 // headless paths without an attached coordinator it retains the old private
747747 // compositor; attached sessions always schedule through their coordinator.
748-func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, producer string) {
749- if sess != nil {
750- if rc := sess.renderCoordinator(); rc != nil {
751- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
752- return
753- }
748+func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
749+ if rc := attachmentRenderCoordinator(entry); rc != nil {
750+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
751+ return
754752 }
755753 if ac != nil {
756- d.paint(sess, ac, reset, nil)
754+ d.paint(entry, ac, reset, nil)
757755 }
758756 }
759757
760758 // invalidateRenderNow publishes through the coordinator but immediately
761759 // flushes the wake when the client can accept state. Attach uses this path so
762760 // the required first full frame never depends on a debounce timer.
763-func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bool, producer string) {
764- if sess != nil {
765- if rc := sess.renderCoordinator(); rc != nil {
766- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
767- rc.fireCurrent(false)
768- return
769- }
761+func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
762+ if rc := attachmentRenderCoordinator(entry); rc != nil {
763+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
764+ rc.fireCurrent(false)
765+ return
770766 }
771767 if ac != nil {
772- d.paint(sess, ac, reset, nil)
768+ d.paint(entry, ac, reset, nil)
773769 }
774770 }
775771
@@ -745,31 +745,27 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() }
745 // invalidateRender is the sole producer fan-in. In tests and transitional745 // invalidateRender is the sole producer fan-in. In tests and transitional
746 // headless paths without an attached coordinator it retains the old private746 // headless paths without an attached coordinator it retains the old private
747 // compositor; attached sessions always schedule through their coordinator.747 // compositor; attached sessions always schedule through their coordinator.
748-func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, producer string) {748+func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
749- if sess != nil {749+ if rc := attachmentRenderCoordinator(entry); rc != nil {
750- if rc := sess.renderCoordinator(); rc != nil {750+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
751- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})751+ return
752- return
753- }
754 }752 }
755 if ac != nil {753 if ac != nil {
756- d.paint(sess, ac, reset, nil)754+ d.paint(entry, ac, reset, nil)
757 }755 }
758 }756 }
759 757
760 // invalidateRenderNow publishes through the coordinator but immediately758 // invalidateRenderNow publishes through the coordinator but immediately
761 // flushes the wake when the client can accept state. Attach uses this path so759 // flushes the wake when the client can accept state. Attach uses this path so
762 // the required first full frame never depends on a debounce timer.760 // the required first full frame never depends on a debounce timer.
763-func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bool, producer string) {761+func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) {
764- if sess != nil {762+ if rc := attachmentRenderCoordinator(entry); rc != nil {
765- if rc := sess.renderCoordinator(); rc != nil {763+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
766- rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})764+ rc.fireCurrent(false)
767- rc.fireCurrent(false)765+ return
768- return
769- }
770 }766 }
771 if ac != nil {767 if ac != nil {
772- d.paint(sess, ac, reset, nil)768+ d.paint(entry, ac, reset, nil)
773 }769 }
774 }770 }
775 771
M internal/usecase/daemon/render_coordinator_support.go +26 -4
@@ -13,11 +13,33 @@ func (c *renderCoordinator) burstMetricsSnapshot() renderCoordinatorBurstMetrics
1313 }
1414 }
1515
16-// renderCoordinator returns the coordinator installed for this session.
17-func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() }
16+// attachmentRenderCoordinator returns the coordinator installed for the exact
17+// attachment-session identity.
18+func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator {
19+ if entry == nil || entry.core() == nil {
20+ return nil
21+ }
22+ return entry.core().coordinator.Load()
23+}
24+
25+// installAttachmentRenderCoordinator publishes rc for the exact
26+// attachment-session identity.
27+func installAttachmentRenderCoordinator(entry attachmentSession, rc *renderCoordinator) {
28+ if entry == nil || entry.core() == nil {
29+ return
30+ }
31+ entry.core().coordinator.Store(rc)
32+}
33+
34+// renderCoordinator retains the local-only method used by PTY and tab owners.
35+func (s *session) renderCoordinator() *renderCoordinator {
36+ return attachmentRenderCoordinator(s)
37+}
1838
19-// installRenderCoordinator publishes rc as the session's coordinator.
20-func (s *session) installRenderCoordinator(rc *renderCoordinator) { s.coordinator.Store(rc) }
39+// installRenderCoordinator retains the local-only setup seam used by tests.
40+func (s *session) installRenderCoordinator(rc *renderCoordinator) {
41+ installAttachmentRenderCoordinator(s, rc)
42+}
2143
2244 func (c *renderCoordinator) fireCurrent(watchdog bool) {
2345 c.mu.Lock()
@@ -13,11 +13,33 @@ func (c *renderCoordinator) burstMetricsSnapshot() renderCoordinatorBurstMetrics
13 }13 }
14 }14 }
15 15
16-// renderCoordinator returns the coordinator installed for this session.16+// attachmentRenderCoordinator returns the coordinator installed for the exact
17-func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() }17+// attachment-session identity.
18+func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator {
19+ if entry == nil || entry.core() == nil {
20+ return nil
21+ }
22+ return entry.core().coordinator.Load()
23+}
24+
25+// installAttachmentRenderCoordinator publishes rc for the exact
26+// attachment-session identity.
27+func installAttachmentRenderCoordinator(entry attachmentSession, rc *renderCoordinator) {
28+ if entry == nil || entry.core() == nil {
29+ return
30+ }
31+ entry.core().coordinator.Store(rc)
32+}
33+
34+// renderCoordinator retains the local-only method used by PTY and tab owners.
35+func (s *session) renderCoordinator() *renderCoordinator {
36+ return attachmentRenderCoordinator(s)
37+}
18 38
19-// installRenderCoordinator publishes rc as the session's coordinator.39+// installRenderCoordinator retains the local-only setup seam used by tests.
20-func (s *session) installRenderCoordinator(rc *renderCoordinator) { s.coordinator.Store(rc) }40+func (s *session) installRenderCoordinator(rc *renderCoordinator) {
41+ installAttachmentRenderCoordinator(s, rc)
42+}
21 43
22 func (c *renderCoordinator) fireCurrent(watchdog bool) {44 func (c *renderCoordinator) fireCurrent(watchdog bool) {
23 c.mu.Lock()45 c.mu.Lock()
M internal/usecase/daemon/render_test.go +7 -7
@@ -491,7 +491,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
491491 viewer.overlays.pickerMu.Lock()
492492 viewer.overlays.pickerPreview = target.tabs[0]
493493 viewer.overlays.pickerMu.Unlock()
494- d.sessions["viewer"] = &session{id: "viewer", client: viewer}
494+ d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}}
495495 previews := make(chan renderWake, 2)
496496 rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w })
497497
@@ -673,7 +673,7 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
673673 viewer.overlays.pickerMu.Lock()
674674 viewer.overlays.pickerPreview = tb
675675 viewer.overlays.pickerMu.Unlock()
676- d.sessions["viewer"] = &session{id: "viewer", client: viewer}
676+ d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}}
677677 p.screen.Write([]byte("preview"))
678678 _ = d.paneRenderable(sess, tb, p)
679679 require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")
@@ -687,7 +687,7 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
687687 p := portsmocks.NewMockPTY(t)
688688 p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
689689 win := newTab(p, domain.Size{Cols: 80, Rows: 23})
690- sess := &session{id: "manual", name: "work", tabs: []*tab{win}}
690+ sess := &session{sessionCore: sessionCore{id: "manual", name: "work"}, tabs: []*tab{win}}
691691 ac := &attachedClient{}
692692 ac.initOverlays()
693693 ac.setSession(sess)
@@ -802,7 +802,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
802802 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
803803 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
804804 ac.initOverlays()
805- sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
805+ sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}}
806806 ac.setSession(sess)
807807
808808 // Client rows are one more than the equivalent case in a single-bar
@@ -1062,7 +1062,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
10621062 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
10631063 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
10641064 ac.initOverlays()
1065- sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
1065+ sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}}
10661066 ac.setSession(sess)
10671067
10681068 d.resize(sess, ac, newSize)
@@ -1091,7 +1091,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
10911091 sctx, cancel := context.WithCancel(context.Background())
10921092 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
10931093 ac.initOverlays()
1094- sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1094+ sess := &session{sessionCore: sessionCore{id: "e", name: "0", ephemeral: true, client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
10951095 ac.setSession(sess)
10961096 d.sessions[sess.id] = sess
10971097
@@ -1135,7 +1135,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
11351135 win := newTestTabWithContext(p, sctx, cancel)
11361136 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
11371137 ac.initOverlays()
1138- sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1138+ sess := &session{sessionCore: sessionCore{id: "query", name: "query", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
11391139 ac.setSession(sess)
11401140 d.sessions[sess.id] = sess
11411141 d.sessWg.Add(1)
@@ -491,7 +491,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
491 viewer.overlays.pickerMu.Lock()491 viewer.overlays.pickerMu.Lock()
492 viewer.overlays.pickerPreview = target.tabs[0]492 viewer.overlays.pickerPreview = target.tabs[0]
493 viewer.overlays.pickerMu.Unlock()493 viewer.overlays.pickerMu.Unlock()
494- d.sessions["viewer"] = &session{id: "viewer", client: viewer}494+ d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}}
495 previews := make(chan renderWake, 2)495 previews := make(chan renderWake, 2)
496 rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w })496 rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w })
497 497
@@ -673,7 +673,7 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
673 viewer.overlays.pickerMu.Lock()673 viewer.overlays.pickerMu.Lock()
674 viewer.overlays.pickerPreview = tb674 viewer.overlays.pickerPreview = tb
675 viewer.overlays.pickerMu.Unlock()675 viewer.overlays.pickerMu.Unlock()
676- d.sessions["viewer"] = &session{id: "viewer", client: viewer}676+ d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}}
677 p.screen.Write([]byte("preview"))677 p.screen.Write([]byte("preview"))
678 _ = d.paneRenderable(sess, tb, p)678 _ = d.paneRenderable(sess, tb, p)
679 require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")679 require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")
@@ -687,7 +687,7 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
687 p := portsmocks.NewMockPTY(t)687 p := portsmocks.NewMockPTY(t)
688 p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()688 p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
689 win := newTab(p, domain.Size{Cols: 80, Rows: 23})689 win := newTab(p, domain.Size{Cols: 80, Rows: 23})
690- sess := &session{id: "manual", name: "work", tabs: []*tab{win}}690+ sess := &session{sessionCore: sessionCore{id: "manual", name: "work"}, tabs: []*tab{win}}
691 ac := &attachedClient{}691 ac := &attachedClient{}
692 ac.initOverlays()692 ac.initOverlays()
693 ac.setSession(sess)693 ac.setSession(sess)
@@ -802,7 +802,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
802 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))802 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
803 ac := &attachedClient{tr: tr, output: newOutputStateStream()}803 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
804 ac.initOverlays()804 ac.initOverlays()
805- sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}805+ sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}}
806 ac.setSession(sess)806 ac.setSession(sess)
807 807
808 // Client rows are one more than the equivalent case in a single-bar808 // Client rows are one more than the equivalent case in a single-bar
@@ -1062,7 +1062,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
1062 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))1062 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
1063 ac := &attachedClient{tr: tr, output: newOutputStateStream()}1063 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1064 ac.initOverlays()1064 ac.initOverlays()
1065- sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}1065+ sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}}
1066 ac.setSession(sess)1066 ac.setSession(sess)
1067 1067
1068 d.resize(sess, ac, newSize)1068 d.resize(sess, ac, newSize)
@@ -1091,7 +1091,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
1091 sctx, cancel := context.WithCancel(context.Background())1091 sctx, cancel := context.WithCancel(context.Background())
1092 ac := &attachedClient{tr: tr, output: newOutputStateStream()}1092 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1093 ac.initOverlays()1093 ac.initOverlays()
1094- sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}1094+ sess := &session{sessionCore: sessionCore{id: "e", name: "0", ephemeral: true, client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
1095 ac.setSession(sess)1095 ac.setSession(sess)
1096 d.sessions[sess.id] = sess1096 d.sessions[sess.id] = sess
1097 1097
@@ -1135,7 +1135,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1135 win := newTestTabWithContext(p, sctx, cancel)1135 win := newTestTabWithContext(p, sctx, cancel)
1136 ac := &attachedClient{tr: tr, output: newOutputStateStream()}1136 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
1137 ac.initOverlays()1137 ac.initOverlays()
1138- sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}1138+ sess := &session{sessionCore: sessionCore{id: "query", name: "query", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel}
1139 ac.setSession(sess)1139 ac.setSession(sess)
1140 d.sessions[sess.id] = sess1140 d.sessions[sess.id] = sess
1141 d.sessWg.Add(1)1141 d.sessWg.Add(1)
M internal/usecase/daemon/resume_test.go +2 -2
@@ -1648,7 +1648,7 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
16481648 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
16491649 require.NoError(t, err)
16501650
1651- previous := &session{id: "previous"}
1651+ previous := &session{sessionCore: sessionCore{id: "previous"}}
16521652 ac.previousSession.Set(previous)
16531653 d.clientGone(sess, ac, ac.transport(), false)
16541654
@@ -1694,7 +1694,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
16941694 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
16951695 require.NoError(t, err)
16961696
1697- ac.previousSession.Set(&session{id: "previous"})
1697+ ac.previousSession.Set(&session{sessionCore: sessionCore{id: "previous"}})
16981698 d.clientGone(sess, ac, ac.transport(), false)
16991699
17001700 d.mu.Lock()
@@ -1648,7 +1648,7 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) {
1648 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)1648 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
1649 require.NoError(t, err)1649 require.NoError(t, err)
1650 1650
1651- previous := &session{id: "previous"}1651+ previous := &session{sessionCore: sessionCore{id: "previous"}}
1652 ac.previousSession.Set(previous)1652 ac.previousSession.Set(previous)
1653 d.clientGone(sess, ac, ac.transport(), false)1653 d.clientGone(sess, ac, ac.transport(), false)
1654 1654
@@ -1694,7 +1694,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) {
1694 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)1694 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
1695 require.NoError(t, err)1695 require.NoError(t, err)
1696 1696
1697- ac.previousSession.Set(&session{id: "previous"})1697+ ac.previousSession.Set(&session{sessionCore: sessionCore{id: "previous"}})
1698 d.clientGone(sess, ac, ac.transport(), false)1698 d.clientGone(sess, ac, ac.transport(), false)
1699 1699
1700 d.mu.Lock()1700 d.mu.Lock()
M internal/usecase/daemon/role_effect_gate.go +1 -1
@@ -18,7 +18,7 @@ const (
1818 // effect admission. It deliberately contains no independently mutable state:
1919 // a ticket is admitted only when every field matches the frame's token.
2020 type roleCapability struct {
21- sess *session
21+ sess attachmentSession
2222 role attachmentRole
2323 generation uint64
2424 transport transportSnapshot
@@ -18,7 +18,7 @@ const (
18 // effect admission. It deliberately contains no independently mutable state:18 // effect admission. It deliberately contains no independently mutable state:
19 // a ticket is admitted only when every field matches the frame's token.19 // a ticket is admitted only when every field matches the frame's token.
20 type roleCapability struct {20 type roleCapability struct {
21- sess *session21+ sess attachmentSession
22 role attachmentRole22 role attachmentRole
23 generation uint6423 generation uint64
24 transport transportSnapshot24 transport transportSnapshot
M internal/usecase/daemon/role_effect_gate_test.go +7 -8
@@ -287,7 +287,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
287287 unrelatedTransport := &closeTrackingTransport{}
288288 unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size}
289289 unrelatedClient.initOverlays()
290- unrelated := &session{id: "unrelated", name: "unrelated", ctx: sess.ctx, cancel: func() {}, client: unrelatedClient}
290+ unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", client: unrelatedClient}, ctx: sess.ctx, cancel: func() {}}
291291 unrelatedClient.setSession(unrelated)
292292 d.mu.Lock()
293293 d.sessions[unrelated.id] = unrelated
@@ -873,7 +873,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
873873 defer release2()
874874 defer release3()
875875 d, source, ac, _ := newManualSessionWithPTYs(t, p1)
876- target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{
876+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
877877 newTab(p2, domain.Size{Cols: 80, Rows: 23}),
878878 newTab(p3, domain.Size{Cols: 80, Rows: 23}),
879879 }}
@@ -900,7 +900,7 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T
900900 defer release2()
901901 defer release3()
902902 d, source, old, _ := newManualSessionWithPTYs(t, p1)
903- target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{
903+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
904904 newTab(p2, domain.Size{Cols: 80, Rows: 23}),
905905 newTab(p3, domain.Size{Cols: 80, Rows: 23}),
906906 }}
@@ -1003,7 +1003,7 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi
10031003 defer release2()
10041004 d, source, old, _ := newManualSessionWithPTYs(t, p1)
10051005 source.name = "source"
1006- target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
1006+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
10071007 d.sessions[target.id] = target
10081008 d.enterPicker(source, old)
10091009 old.overlays.pickerMu.Lock()
@@ -1269,7 +1269,7 @@ func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T)
12691269 bTransport := newDatagramTestTransport()
12701270 b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
12711271 b.initOverlays()
1272- second := &session{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})}
1272+ second := &session{sessionCore: sessionCore{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})}}
12731273 b.setSession(second)
12741274 d.mu.Lock()
12751275 d.sessions[second.id] = second
@@ -1326,9 +1326,8 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
13261326 bTransport := newDatagramTestTransport()
13271327 b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
13281328 b.initOverlays()
1329- target := &session{
1330- id: "picker-delete-target", name: "picker-delete-target", ctx: source.ctx, cancel: func() {},
1331- client: b, snatched: make(map[*attachedClient]struct{}),
1329+ target := &session{sessionCore: sessionCore{id: "picker-delete-target", name: "picker-delete-target",
1330+ client: b, snatched: make(map[*attachedClient]struct{})}, ctx: source.ctx, cancel: func() {},
13321331 }
13331332 b.setSession(target)
13341333 d.mu.Lock()
@@ -287,7 +287,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
287 unrelatedTransport := &closeTrackingTransport{}287 unrelatedTransport := &closeTrackingTransport{}
288 unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size}288 unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size}
289 unrelatedClient.initOverlays()289 unrelatedClient.initOverlays()
290- unrelated := &session{id: "unrelated", name: "unrelated", ctx: sess.ctx, cancel: func() {}, client: unrelatedClient}290+ unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", client: unrelatedClient}, ctx: sess.ctx, cancel: func() {}}
291 unrelatedClient.setSession(unrelated)291 unrelatedClient.setSession(unrelated)
292 d.mu.Lock()292 d.mu.Lock()
293 d.sessions[unrelated.id] = unrelated293 d.sessions[unrelated.id] = unrelated
@@ -873,7 +873,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
873 defer release2()873 defer release2()
874 defer release3()874 defer release3()
875 d, source, ac, _ := newManualSessionWithPTYs(t, p1)875 d, source, ac, _ := newManualSessionWithPTYs(t, p1)
876- target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{876+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
877 newTab(p2, domain.Size{Cols: 80, Rows: 23}),877 newTab(p2, domain.Size{Cols: 80, Rows: 23}),
878 newTab(p3, domain.Size{Cols: 80, Rows: 23}),878 newTab(p3, domain.Size{Cols: 80, Rows: 23}),
879 }}879 }}
@@ -900,7 +900,7 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T
900 defer release2()900 defer release2()
901 defer release3()901 defer release3()
902 d, source, old, _ := newManualSessionWithPTYs(t, p1)902 d, source, old, _ := newManualSessionWithPTYs(t, p1)
903- target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{903+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{
904 newTab(p2, domain.Size{Cols: 80, Rows: 23}),904 newTab(p2, domain.Size{Cols: 80, Rows: 23}),
905 newTab(p3, domain.Size{Cols: 80, Rows: 23}),905 newTab(p3, domain.Size{Cols: 80, Rows: 23}),
906 }}906 }}
@@ -1003,7 +1003,7 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi
1003 defer release2()1003 defer release2()
1004 d, source, old, _ := newManualSessionWithPTYs(t, p1)1004 d, source, old, _ := newManualSessionWithPTYs(t, p1)
1005 source.name = "source"1005 source.name = "source"
1006- target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}1006+ target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}}
1007 d.sessions[target.id] = target1007 d.sessions[target.id] = target
1008 d.enterPicker(source, old)1008 d.enterPicker(source, old)
1009 old.overlays.pickerMu.Lock()1009 old.overlays.pickerMu.Lock()
@@ -1269,7 +1269,7 @@ func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T)
1269 bTransport := newDatagramTestTransport()1269 bTransport := newDatagramTestTransport()
1270 b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}1270 b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
1271 b.initOverlays()1271 b.initOverlays()
1272- second := &session{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})}1272+ second := &session{sessionCore: sessionCore{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})}}
1273 b.setSession(second)1273 b.setSession(second)
1274 d.mu.Lock()1274 d.mu.Lock()
1275 d.sessions[second.id] = second1275 d.sessions[second.id] = second
@@ -1326,9 +1326,8 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
1326 bTransport := newDatagramTestTransport()1326 bTransport := newDatagramTestTransport()
1327 b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}1327 b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
1328 b.initOverlays()1328 b.initOverlays()
1329- target := &session{1329+ target := &session{sessionCore: sessionCore{id: "picker-delete-target", name: "picker-delete-target",
1330- id: "picker-delete-target", name: "picker-delete-target", ctx: source.ctx, cancel: func() {},1330+ client: b, snatched: make(map[*attachedClient]struct{})}, ctx: source.ctx, cancel: func() {},
1331- client: b, snatched: make(map[*attachedClient]struct{}),
1332 }1331 }
1333 b.setSession(target)1332 b.setSession(target)
1334 d.mu.Lock()1333 d.mu.Lock()
M internal/usecase/daemon/session.go +100 -58
@@ -29,11 +29,7 @@ type terminalEnv struct {
2929 }
3030
3131 type session struct {
32- id domain.SessionID
33- name string
34- ephemeral bool
35- // caps is immutable after construction; see sessionCapabilities.
36- caps sessionCapabilities
32+ sessionCore
3733
3834 ctx context.Context
3935 cancel context.CancelFunc
@@ -55,15 +51,14 @@ type session struct {
5551 teardownWaiters uint
5652 teardownChanged chan struct{}
5753 lifecycleStopOnce sync.Once
58- mu sync.Mutex // guards tabs, active, client, snatched, restoreDone, clipFiles, and clipboard queue state
54+ // sessionCore.mu guards tabs, active, attachment membership, restoreDone,
55+ // clipFiles, and clipboard queue state.
5956 // restoreDone remains attached to a restored session after it is published in
6057 // the live registry, so racing attaches cannot bypass restoration completion.
6158 restoreDone chan struct{}
6259 themeMu sync.Mutex
6360 tabs []*tab
6461 active int
65- client *attachedClient
66- snatched map[*attachedClient]struct{}
6762 clipboardQueue []clipboardForward
6863 clipboardWorkerRunning bool
6964 cwd string
@@ -71,9 +66,6 @@ type session struct {
7166 // env is the authoritative immutable environment snapshot for future PTY children.
7267 // It is guarded by mu and is always copied on ingress and egress.
7368 env []string
74- createdAt int64
75- incarnation domain.IncarnationID
76- mruAt atomic.Uint64
7769 snapDirty atomic.Bool
7870 snapEligible atomic.Bool
7971 // snapshotMu serializes mutation revisions and repository publication
@@ -110,8 +102,6 @@ type session struct {
110102 // syncGen makes synchronized-output watchdog generations unique across all
111103 // panes in this session.
112104 syncGen atomic.Uint64
113- // coordinator fans in this session's producer render invalidations.
114- coordinator atomic.Pointer[renderCoordinator]
115105 // layoutApplyMu serializes whole-session resize prepare/apply/admit/publish
116106 // transactions. It is deliberately not an architecture lock and may span
117107 // external PTY.Resize calls.
@@ -293,17 +283,19 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
293283 lastUsedSeq = d.mruSeq.Add(1)
294284 }
295285 sess := &session{
296- id: id,
297- name: name,
298- ephemeral: ephemeral,
286+ sessionCore: sessionCore{
287+ id: id,
288+ name: name,
289+ ephemeral: ephemeral,
290+ createdAt: createdAt,
291+ incarnation: incarnation,
292+ },
299293 ctx: sctx,
300294 cancel: cancel,
301295 tabs: tabs,
302296 cwd: cwd,
303297 terminal: term,
304298 env: env,
305- createdAt: createdAt,
306- incarnation: incarnation,
307299 snapshotWake: d.snapshotWake,
308300 }
309301 if !ephemeral && name != "" {
@@ -316,38 +308,68 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
316308 for _, tb := range tabs {
317309 publishTiledPaneOwners(sess, tb)
318310 }
319- if !ephemeral {
320- if d.persistEnabled {
321- record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}
322- if d.recovery == nil {
323- closeTabs(tabs)
324- cancel()
325- return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("durable session authority is not configured"))
311+ var rollbackDurable func() error
312+ if !ephemeral && d.persistEnabled {
313+ record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}
314+ if d.recovery == nil {
315+ closeTabs(tabs)
316+ cancel()
317+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("durable session authority is not configured"))
318+ }
319+ var err error
320+ if resuming && authoritativeExists {
321+ err = d.catalogue.UpdateMetadata(record.MetadataUpdate())
322+ if err == nil {
323+ rollbackDurable = func() error { return d.catalogue.Replace(name, authoritative) }
324+ err = d.catalogue.Sync()
326325 }
327- var err error
328- if resuming && authoritativeExists {
329- err = d.catalogue.UpdateMetadata(record.MetadataUpdate())
330- if err == nil {
331- err = d.catalogue.Sync()
326+ } else {
327+ record, err = d.recovery.Create(d.serveCtx, record)
328+ if err == nil {
329+ rollbackDurable = func() error {
330+ current, ok, recordErr := d.catalogue.Record(name)
331+ if recordErr != nil {
332+ return recordErr
333+ }
334+ if !ok {
335+ return nil
336+ }
337+ if current.IncarnationID != record.IncarnationID {
338+ return errors.New("daemon: created session authority changed before rollback")
339+ }
340+ return d.catalogue.Delete(name)
332341 }
333- } else {
334- record, err = d.recovery.Create(d.serveCtx, record)
335- }
336- if err != nil {
337- closeTabs(tabs)
338- cancel()
339- return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
340342 }
341- sess.incarnation = record.IncarnationID
342- if resuming && authoritativeExists && authoritative.Committed != nil && authoritative.IncarnationID == sess.incarnation {
343- checkpoint := *authoritative.Committed
344- sess.snapshotPublishedGeneration = checkpoint.Generation
345- sess.snapshotPublishedCheckpoint = &checkpoint
343+ }
344+ if err != nil {
345+ if rollbackDurable != nil {
346+ err = errors.Join(err, rollbackDurable())
346347 }
348+ closeTabs(tabs)
349+ cancel()
350+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
347351 }
352+ sess.incarnation = record.IncarnationID
353+ if resuming && authoritativeExists && authoritative.Committed != nil && authoritative.IncarnationID == sess.incarnation {
354+ checkpoint := *authoritative.Committed
355+ sess.snapshotPublishedGeneration = checkpoint.Generation
356+ sess.snapshotPublishedCheckpoint = &checkpoint
357+ }
358+ }
359+ if !d.registerSessionLocked(sess) {
360+ err := errors.New("daemon: session registry rejected local session")
361+ if rollbackDurable != nil {
362+ err = errors.Join(err, rollbackDurable())
363+ }
364+ closeTabs(tabs)
365+ cancel()
366+ return nil, err
367+ }
368+ if !ephemeral {
369+ // Keep stopped lifecycle authority intact until runtime publication and
370+ // its durable side effects have both succeeded.
348371 delete(d.stopped, name)
349372 }
350- d.sessions[id] = sess
351373 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
352374 for i, tb := range tabs {
353375 d.log.Info("tab created", "session", name, "tab", i)
@@ -442,15 +464,19 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
442464 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
443465 copySourceEnvironment: true, ready: true,
444466 createTargetLocked: func() (*session, error) {
467+ source, ok := localSession(token.sess)
468+ if !ok {
469+ return nil, errAttachmentTransition
470+ }
445471 if d.closing {
446472 return nil, errors.New("daemon is shutting down")
447473 }
448474 if d.nameLiveOrStoppedLocked(name) {
449475 return nil, errSessionNameInUse
450476 }
451- token.sess.mu.Lock()
452- cwd, term, env := token.sess.cwd, token.sess.terminal, copyEnvironment(token.sess.env)
453- token.sess.mu.Unlock()
477+ source.mu.Lock()
478+ cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
479+ source.mu.Unlock()
454480 var createErr error
455481 created, createErr = d.createSessionLocked(name, false, cwd, token.ac.size, term, env)
456482 return created, createErr
@@ -464,7 +490,9 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
464490 }
465491
466492 d.touchMRU(created)
467- token.ac.recordPreviousSession(token.sess)
493+ if source, ok := localSession(token.sess); ok {
494+ token.ac.recordPreviousSession(source)
495+ }
468496 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
469497 d.deferAttachmentTransitionCleanups(transition)
470498 d.firstPaintForTransition(transition.published)
@@ -729,13 +757,14 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
729757 d.afterDetachRoleEffectsFrozen()
730758 }
731759 d.mu.Lock()
732- if d.sessions[token.sess.id] != token.sess {
760+ core := token.sess.core()
761+ if core == nil || d.sessions[core.id] != token.sess {
733762 d.mu.Unlock()
734763 return false
735764 }
736765 d.notices.routingMu.Lock()
737- token.sess.mu.Lock()
738- coordinator := token.sess.renderCoordinator()
766+ core.mu.Lock()
767+ coordinator := core.coordinator.Load()
739768 if coordinator != nil {
740769 coordinator.mu.Lock()
741770 }
@@ -745,7 +774,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
745774 }
746775 current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
747776 if current {
748- token.sess.client = nil
777+ core.client = nil
749778 token.ac.roleGeneration.Add(1)
750779 token.ac.setSession(nil)
751780 token.ac.invalidateFrozenRoleCapability()
@@ -753,7 +782,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
753782 if coordinator != nil {
754783 coordinator.mu.Unlock()
755784 }
756- token.sess.mu.Unlock()
785+ core.mu.Unlock()
757786 d.notices.routingMu.Unlock()
758787 d.mu.Unlock()
759788 return current
@@ -1107,7 +1136,11 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
11071136 if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {
11081137 return sessionKillParticipants{}, false
11091138 }
1110- snapshot.source = token.sess
1139+ var ok bool
1140+ snapshot.source, ok = localSession(token.sess)
1141+ if !ok {
1142+ return sessionKillParticipants{}, false
1143+ }
11111144 snapshot.sourceToken = &admission.token
11121145 }
11131146
@@ -1481,7 +1514,13 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
14811514 // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held
14821515 // here. Registry removal and every role invalidation are one publication
14831516 // while the complete immutable gate set remains frozen.
1484- delete(d.sessions, sess.id)
1517+ if !d.unregisterSessionLocked(sess) {
1518+ unlockCoordinators()
1519+ unlockSessions()
1520+ d.notices.routingMu.Unlock()
1521+ d.mu.Unlock()
1522+ return errAttachmentTransition
1523+ }
14851524 d.clearBarScriptsForSession(sess.id)
14861525 d.purgeParkingForSessionLocked(sess)
14871526 parkedRetirements := d.purgeParkedForSessionLocked(sess)
@@ -1591,8 +1630,10 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
15911630 // d.mu.
15921631 func (d *Daemon) allocEphemeralNameLocked() string {
15931632 used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))
1594- for _, s := range d.sessions {
1595- used[s.name] = struct{}{}
1633+ for _, entry := range d.sessions {
1634+ if s, ok := localSession(entry); ok {
1635+ used[s.name] = struct{}{}
1636+ }
15961637 }
15971638 for name := range d.stopped {
15981639 used[name] = struct{}{}
@@ -1606,8 +1647,9 @@ func (d *Daemon) allocEphemeralNameLocked() string {
16061647 }
16071648
16081649 func (d *Daemon) findByNameLocked(name string) *session {
1609- for _, s := range d.sessions {
1610- if s.name == name {
1650+ for _, entry := range d.sessions {
1651+ s, ok := localSession(entry)
1652+ if ok && s.name == name {
16111653 return s
16121654 }
16131655 }
@@ -29,11 +29,7 @@ type terminalEnv struct {
29 }29 }
30 30
31 type session struct {31 type session struct {
32- id domain.SessionID32+ sessionCore
33- name string
34- ephemeral bool
35- // caps is immutable after construction; see sessionCapabilities.
36- caps sessionCapabilities
37 33
38 ctx context.Context34 ctx context.Context
39 cancel context.CancelFunc35 cancel context.CancelFunc
@@ -55,15 +51,14 @@ type session struct {
55 teardownWaiters uint51 teardownWaiters uint
56 teardownChanged chan struct{}52 teardownChanged chan struct{}
57 lifecycleStopOnce sync.Once53 lifecycleStopOnce sync.Once
58- mu sync.Mutex // guards tabs, active, client, snatched, restoreDone, clipFiles, and clipboard queue state54+ // sessionCore.mu guards tabs, active, attachment membership, restoreDone,
55+ // clipFiles, and clipboard queue state.
59 // restoreDone remains attached to a restored session after it is published in56 // restoreDone remains attached to a restored session after it is published in
60 // the live registry, so racing attaches cannot bypass restoration completion.57 // the live registry, so racing attaches cannot bypass restoration completion.
61 restoreDone chan struct{}58 restoreDone chan struct{}
62 themeMu sync.Mutex59 themeMu sync.Mutex
63 tabs []*tab60 tabs []*tab
64 active int61 active int
65- client *attachedClient
66- snatched map[*attachedClient]struct{}
67 clipboardQueue []clipboardForward62 clipboardQueue []clipboardForward
68 clipboardWorkerRunning bool63 clipboardWorkerRunning bool
69 cwd string64 cwd string
@@ -71,9 +66,6 @@ type session struct {
71 // env is the authoritative immutable environment snapshot for future PTY children.66 // env is the authoritative immutable environment snapshot for future PTY children.
72 // It is guarded by mu and is always copied on ingress and egress.67 // It is guarded by mu and is always copied on ingress and egress.
73 env []string68 env []string
74- createdAt int64
75- incarnation domain.IncarnationID
76- mruAt atomic.Uint64
77 snapDirty atomic.Bool69 snapDirty atomic.Bool
78 snapEligible atomic.Bool70 snapEligible atomic.Bool
79 // snapshotMu serializes mutation revisions and repository publication71 // snapshotMu serializes mutation revisions and repository publication
@@ -110,8 +102,6 @@ type session struct {
110 // syncGen makes synchronized-output watchdog generations unique across all102 // syncGen makes synchronized-output watchdog generations unique across all
111 // panes in this session.103 // panes in this session.
112 syncGen atomic.Uint64104 syncGen atomic.Uint64
113- // coordinator fans in this session's producer render invalidations.
114- coordinator atomic.Pointer[renderCoordinator]
115 // layoutApplyMu serializes whole-session resize prepare/apply/admit/publish105 // layoutApplyMu serializes whole-session resize prepare/apply/admit/publish
116 // transactions. It is deliberately not an architecture lock and may span106 // transactions. It is deliberately not an architecture lock and may span
117 // external PTY.Resize calls.107 // external PTY.Resize calls.
@@ -293,17 +283,19 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
293 lastUsedSeq = d.mruSeq.Add(1)283 lastUsedSeq = d.mruSeq.Add(1)
294 }284 }
295 sess := &session{285 sess := &session{
296- id: id,286+ sessionCore: sessionCore{
297- name: name,287+ id: id,
298- ephemeral: ephemeral,288+ name: name,
289+ ephemeral: ephemeral,
290+ createdAt: createdAt,
291+ incarnation: incarnation,
292+ },
299 ctx: sctx,293 ctx: sctx,
300 cancel: cancel,294 cancel: cancel,
301 tabs: tabs,295 tabs: tabs,
302 cwd: cwd,296 cwd: cwd,
303 terminal: term,297 terminal: term,
304 env: env,298 env: env,
305- createdAt: createdAt,
306- incarnation: incarnation,
307 snapshotWake: d.snapshotWake,299 snapshotWake: d.snapshotWake,
308 }300 }
309 if !ephemeral && name != "" {301 if !ephemeral && name != "" {
@@ -316,38 +308,68 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
316 for _, tb := range tabs {308 for _, tb := range tabs {
317 publishTiledPaneOwners(sess, tb)309 publishTiledPaneOwners(sess, tb)
318 }310 }
319- if !ephemeral {311+ var rollbackDurable func() error
320- if d.persistEnabled {312+ if !ephemeral && d.persistEnabled {
321- record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}313+ record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}
322- if d.recovery == nil {314+ if d.recovery == nil {
323- closeTabs(tabs)315+ closeTabs(tabs)
324- cancel()316+ cancel()
325- return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("durable session authority is not configured"))317+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("durable session authority is not configured"))
318+ }
319+ var err error
320+ if resuming && authoritativeExists {
321+ err = d.catalogue.UpdateMetadata(record.MetadataUpdate())
322+ if err == nil {
323+ rollbackDurable = func() error { return d.catalogue.Replace(name, authoritative) }
324+ err = d.catalogue.Sync()
326 }325 }
327- var err error326+ } else {
328- if resuming && authoritativeExists {327+ record, err = d.recovery.Create(d.serveCtx, record)
329- err = d.catalogue.UpdateMetadata(record.MetadataUpdate())328+ if err == nil {
330- if err == nil {329+ rollbackDurable = func() error {
331- err = d.catalogue.Sync()330+ current, ok, recordErr := d.catalogue.Record(name)
331+ if recordErr != nil {
332+ return recordErr
333+ }
334+ if !ok {
335+ return nil
336+ }
337+ if current.IncarnationID != record.IncarnationID {
338+ return errors.New("daemon: created session authority changed before rollback")
339+ }
340+ return d.catalogue.Delete(name)
332 }341 }
333- } else {
334- record, err = d.recovery.Create(d.serveCtx, record)
335- }
336- if err != nil {
337- closeTabs(tabs)
338- cancel()
339- return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
340 }342 }
341- sess.incarnation = record.IncarnationID343+ }
342- if resuming && authoritativeExists && authoritative.Committed != nil && authoritative.IncarnationID == sess.incarnation {344+ if err != nil {
343- checkpoint := *authoritative.Committed345+ if rollbackDurable != nil {
344- sess.snapshotPublishedGeneration = checkpoint.Generation346+ err = errors.Join(err, rollbackDurable())
345- sess.snapshotPublishedCheckpoint = &checkpoint
346 }347 }
348+ closeTabs(tabs)
349+ cancel()
350+ return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
347 }351 }
352+ sess.incarnation = record.IncarnationID
353+ if resuming && authoritativeExists && authoritative.Committed != nil && authoritative.IncarnationID == sess.incarnation {
354+ checkpoint := *authoritative.Committed
355+ sess.snapshotPublishedGeneration = checkpoint.Generation
356+ sess.snapshotPublishedCheckpoint = &checkpoint
357+ }
358+ }
359+ if !d.registerSessionLocked(sess) {
360+ err := errors.New("daemon: session registry rejected local session")
361+ if rollbackDurable != nil {
362+ err = errors.Join(err, rollbackDurable())
363+ }
364+ closeTabs(tabs)
365+ cancel()
366+ return nil, err
367+ }
368+ if !ephemeral {
369+ // Keep stopped lifecycle authority intact until runtime publication and
370+ // its durable side effects have both succeeded.
348 delete(d.stopped, name)371 delete(d.stopped, name)
349 }372 }
350- d.sessions[id] = sess
351 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)373 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
352 for i, tb := range tabs {374 for i, tb := range tabs {
353 d.log.Info("tab created", "session", name, "tab", i)375 d.log.Info("tab created", "session", name, "tab", i)
@@ -442,15 +464,19 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
442 expectedTransport: token.transport, sourceToken: &token, action: "create-session",464 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
443 copySourceEnvironment: true, ready: true,465 copySourceEnvironment: true, ready: true,
444 createTargetLocked: func() (*session, error) {466 createTargetLocked: func() (*session, error) {
467+ source, ok := localSession(token.sess)
468+ if !ok {
469+ return nil, errAttachmentTransition
470+ }
445 if d.closing {471 if d.closing {
446 return nil, errors.New("daemon is shutting down")472 return nil, errors.New("daemon is shutting down")
447 }473 }
448 if d.nameLiveOrStoppedLocked(name) {474 if d.nameLiveOrStoppedLocked(name) {
449 return nil, errSessionNameInUse475 return nil, errSessionNameInUse
450 }476 }
451- token.sess.mu.Lock()477+ source.mu.Lock()
452- cwd, term, env := token.sess.cwd, token.sess.terminal, copyEnvironment(token.sess.env)478+ cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
453- token.sess.mu.Unlock()479+ source.mu.Unlock()
454 var createErr error480 var createErr error
455 created, createErr = d.createSessionLocked(name, false, cwd, token.ac.size, term, env)481 created, createErr = d.createSessionLocked(name, false, cwd, token.ac.size, term, env)
456 return created, createErr482 return created, createErr
@@ -464,7 +490,9 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
464 }490 }
465 491
466 d.touchMRU(created)492 d.touchMRU(created)
467- token.ac.recordPreviousSession(token.sess)493+ if source, ok := localSession(token.sess); ok {
494+ token.ac.recordPreviousSession(source)
495+ }
468 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)496 d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
469 d.deferAttachmentTransitionCleanups(transition)497 d.deferAttachmentTransitionCleanups(transition)
470 d.firstPaintForTransition(transition.published)498 d.firstPaintForTransition(transition.published)
@@ -729,13 +757,14 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
729 d.afterDetachRoleEffectsFrozen()757 d.afterDetachRoleEffectsFrozen()
730 }758 }
731 d.mu.Lock()759 d.mu.Lock()
732- if d.sessions[token.sess.id] != token.sess {760+ core := token.sess.core()
761+ if core == nil || d.sessions[core.id] != token.sess {
733 d.mu.Unlock()762 d.mu.Unlock()
734 return false763 return false
735 }764 }
736 d.notices.routingMu.Lock()765 d.notices.routingMu.Lock()
737- token.sess.mu.Lock()766+ core.mu.Lock()
738- coordinator := token.sess.renderCoordinator()767+ coordinator := core.coordinator.Load()
739 if coordinator != nil {768 if coordinator != nil {
740 coordinator.mu.Lock()769 coordinator.mu.Lock()
741 }770 }
@@ -745,7 +774,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
745 }774 }
746 current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)775 current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
747 if current {776 if current {
748- token.sess.client = nil777+ core.client = nil
749 token.ac.roleGeneration.Add(1)778 token.ac.roleGeneration.Add(1)
750 token.ac.setSession(nil)779 token.ac.setSession(nil)
751 token.ac.invalidateFrozenRoleCapability()780 token.ac.invalidateFrozenRoleCapability()
@@ -753,7 +782,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
753 if coordinator != nil {782 if coordinator != nil {
754 coordinator.mu.Unlock()783 coordinator.mu.Unlock()
755 }784 }
756- token.sess.mu.Unlock()785+ core.mu.Unlock()
757 d.notices.routingMu.Unlock()786 d.notices.routingMu.Unlock()
758 d.mu.Unlock()787 d.mu.Unlock()
759 return current788 return current
@@ -1107,7 +1136,11 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1107 if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {1136 if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {
1108 return sessionKillParticipants{}, false1137 return sessionKillParticipants{}, false
1109 }1138 }
1110- snapshot.source = token.sess1139+ var ok bool
1140+ snapshot.source, ok = localSession(token.sess)
1141+ if !ok {
1142+ return sessionKillParticipants{}, false
1143+ }
1111 snapshot.sourceToken = &admission.token1144 snapshot.sourceToken = &admission.token
1112 }1145 }
1113 1146
@@ -1481,7 +1514,13 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1481 // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held1514 // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held
1482 // here. Registry removal and every role invalidation are one publication1515 // here. Registry removal and every role invalidation are one publication
1483 // while the complete immutable gate set remains frozen.1516 // while the complete immutable gate set remains frozen.
1484- delete(d.sessions, sess.id)1517+ if !d.unregisterSessionLocked(sess) {
1518+ unlockCoordinators()
1519+ unlockSessions()
1520+ d.notices.routingMu.Unlock()
1521+ d.mu.Unlock()
1522+ return errAttachmentTransition
1523+ }
1485 d.clearBarScriptsForSession(sess.id)1524 d.clearBarScriptsForSession(sess.id)
1486 d.purgeParkingForSessionLocked(sess)1525 d.purgeParkingForSessionLocked(sess)
1487 parkedRetirements := d.purgeParkedForSessionLocked(sess)1526 parkedRetirements := d.purgeParkedForSessionLocked(sess)
@@ -1591,8 +1630,10 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1591 // d.mu.1630 // d.mu.
1592 func (d *Daemon) allocEphemeralNameLocked() string {1631 func (d *Daemon) allocEphemeralNameLocked() string {
1593 used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))1632 used := make(map[string]struct{}, len(d.sessions)+len(d.stopped))
1594- for _, s := range d.sessions {1633+ for _, entry := range d.sessions {
1595- used[s.name] = struct{}{}1634+ if s, ok := localSession(entry); ok {
1635+ used[s.name] = struct{}{}
1636+ }
1596 }1637 }
1597 for name := range d.stopped {1638 for name := range d.stopped {
1598 used[name] = struct{}{}1639 used[name] = struct{}{}
@@ -1606,8 +1647,9 @@ func (d *Daemon) allocEphemeralNameLocked() string {
1606 }1647 }
1607 1648
1608 func (d *Daemon) findByNameLocked(name string) *session {1649 func (d *Daemon) findByNameLocked(name string) *session {
1609- for _, s := range d.sessions {1650+ for _, entry := range d.sessions {
1610- if s.name == name {1651+ s, ok := localSession(entry)
1652+ if ok && s.name == name {
1611 return s1653 return s
1612 }1654 }
1613 }1655 }
M internal/usecase/daemon/session_back.go +7 -7
@@ -9,11 +9,11 @@ func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {
99 return nil
1010 }
1111 target := token.ac.previousSession.Get()
12- if target == nil || target == token.sess || d.sessionByID(target.id) != target {
12+ if target == nil || target == token.sess || target.core() == nil || d.sessionByID(target.core().id) != target {
1313 token.ac.clearPreviousSessionIf(target)
1414 return nil
1515 }
16- return d.switchToTargetForRole(token, picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "back-session")
16+ return d.switchToTargetForRole(token, picker.Target{Session: target.core().id, TabIndex: -1}, sessionHandoffGuard{}, "back-session")
1717 }
1818
1919 func (d *Daemon) backSession(current *session, ac *attachedClient) {
@@ -21,23 +21,23 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
2121 return
2222 }
2323 target := ac.previousSession.Get()
24- if target == nil || target == current || d.sessionByID(target.id) != target {
24+ if target == nil || target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
2525 ac.clearPreviousSessionIf(target)
2626 d.invalidateRender(current, ac, true, "session_back.go")
2727 return
2828 }
29- if err := d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil {
29+ if err := d.switchToTarget(current, ac, picker.Target{Session: target.core().id, TabIndex: -1}); err != nil {
3030 d.reportError(current, err)
3131 }
3232 }
3333
3434 // clearPreviousSessionIf clears target only if it has not been replaced since
3535 // the caller observed it. This preserves a concurrent successful hand-off.
36-func (ac *attachedClient) clearPreviousSessionIf(target *session) {
36+func (ac *attachedClient) clearPreviousSessionIf(target attachmentSession) {
3737 if ac == nil {
3838 return
3939 }
40- ac.previousSession.With(func(previous **session) {
40+ ac.previousSession.With(func(previous *attachmentSession) {
4141 if *previous == target {
4242 *previous = nil
4343 }
@@ -47,7 +47,7 @@ func (ac *attachedClient) clearPreviousSessionIf(target *session) {
4747 // recordPreviousSession is called only after a cross-session hand-off has
4848 // committed. Keeping this after the commit makes failed/displaced switches
4949 // leave the toggle pair intact.
50-func (ac *attachedClient) recordPreviousSession(origin *session) {
50+func (ac *attachedClient) recordPreviousSession(origin attachmentSession) {
5151 if ac != nil && origin != nil {
5252 ac.previousSession.Set(origin)
5353 }
@@ -9,11 +9,11 @@ func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {
9 return nil9 return nil
10 }10 }
11 target := token.ac.previousSession.Get()11 target := token.ac.previousSession.Get()
12- if target == nil || target == token.sess || d.sessionByID(target.id) != target {12+ if target == nil || target == token.sess || target.core() == nil || d.sessionByID(target.core().id) != target {
13 token.ac.clearPreviousSessionIf(target)13 token.ac.clearPreviousSessionIf(target)
14 return nil14 return nil
15 }15 }
16- return d.switchToTargetForRole(token, picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "back-session")16+ return d.switchToTargetForRole(token, picker.Target{Session: target.core().id, TabIndex: -1}, sessionHandoffGuard{}, "back-session")
17 }17 }
18 18
19 func (d *Daemon) backSession(current *session, ac *attachedClient) {19 func (d *Daemon) backSession(current *session, ac *attachedClient) {
@@ -21,23 +21,23 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
21 return21 return
22 }22 }
23 target := ac.previousSession.Get()23 target := ac.previousSession.Get()
24- if target == nil || target == current || d.sessionByID(target.id) != target {24+ if target == nil || target == current || target.core() == nil || d.sessionByID(target.core().id) != target {
25 ac.clearPreviousSessionIf(target)25 ac.clearPreviousSessionIf(target)
26 d.invalidateRender(current, ac, true, "session_back.go")26 d.invalidateRender(current, ac, true, "session_back.go")
27 return27 return
28 }28 }
29- if err := d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil {29+ if err := d.switchToTarget(current, ac, picker.Target{Session: target.core().id, TabIndex: -1}); err != nil {
30 d.reportError(current, err)30 d.reportError(current, err)
31 }31 }
32 }32 }
33 33
34 // clearPreviousSessionIf clears target only if it has not been replaced since34 // clearPreviousSessionIf clears target only if it has not been replaced since
35 // the caller observed it. This preserves a concurrent successful hand-off.35 // the caller observed it. This preserves a concurrent successful hand-off.
36-func (ac *attachedClient) clearPreviousSessionIf(target *session) {36+func (ac *attachedClient) clearPreviousSessionIf(target attachmentSession) {
37 if ac == nil {37 if ac == nil {
38 return38 return
39 }39 }
40- ac.previousSession.With(func(previous **session) {40+ ac.previousSession.With(func(previous *attachmentSession) {
41 if *previous == target {41 if *previous == target {
42 *previous = nil42 *previous = nil
43 }43 }
@@ -47,7 +47,7 @@ func (ac *attachedClient) clearPreviousSessionIf(target *session) {
47 // recordPreviousSession is called only after a cross-session hand-off has47 // recordPreviousSession is called only after a cross-session hand-off has
48 // committed. Keeping this after the commit makes failed/displaced switches48 // committed. Keeping this after the commit makes failed/displaced switches
49 // leave the toggle pair intact.49 // leave the toggle pair intact.
50-func (ac *attachedClient) recordPreviousSession(origin *session) {50+func (ac *attachedClient) recordPreviousSession(origin attachmentSession) {
51 if ac != nil && origin != nil {51 if ac != nil && origin != nil {
52 ac.previousSession.Set(origin)52 ac.previousSession.Set(origin)
53 }53 }
M internal/usecase/daemon/session_capabilities_test.go +2 -2
@@ -8,7 +8,7 @@ import (
88 )
99
1010 func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) {
11- s := &session{id: domain.SessionID("s1"), name: "alpha"}
11+ s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha"}}
1212 caps := s.capabilities()
1313 if caps.cannotAcceptMoves || caps.cannotYieldMoves || !caps.yieldsMoves() {
1414 t.Fatalf("zero-value capabilities = %+v, want fully capable", caps)
@@ -26,7 +26,7 @@ func TestSnapshotViewCarriesCannotAcceptMoves(t *testing.T) {
2626 }
2727 for _, tt := range tests {
2828 t.Run(tt.name, func(t *testing.T) {
29- s := &session{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps}
29+ s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps}}
3030 if got := s.snapshotView(viewOptions{}).cannotAcceptMoves; got != tt.want {
3131 t.Fatalf("snapshotView().cannotAcceptMoves = %v, want %v", got, tt.want)
3232 }
@@ -8,7 +8,7 @@ import (
8 )8 )
9 9
10 func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) {10 func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) {
11- s := &session{id: domain.SessionID("s1"), name: "alpha"}11+ s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha"}}
12 caps := s.capabilities()12 caps := s.capabilities()
13 if caps.cannotAcceptMoves || caps.cannotYieldMoves || !caps.yieldsMoves() {13 if caps.cannotAcceptMoves || caps.cannotYieldMoves || !caps.yieldsMoves() {
14 t.Fatalf("zero-value capabilities = %+v, want fully capable", caps)14 t.Fatalf("zero-value capabilities = %+v, want fully capable", caps)
@@ -26,7 +26,7 @@ func TestSnapshotViewCarriesCannotAcceptMoves(t *testing.T) {
26 }26 }
27 for _, tt := range tests {27 for _, tt := range tests {
28 t.Run(tt.name, func(t *testing.T) {28 t.Run(tt.name, func(t *testing.T) {
29- s := &session{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps}29+ s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps}}
30 if got := s.snapshotView(viewOptions{}).cannotAcceptMoves; got != tt.want {30 if got := s.snapshotView(viewOptions{}).cannotAcceptMoves; got != tt.want {
31 t.Fatalf("snapshotView().cannotAcceptMoves = %v, want %v", got, tt.want)31 t.Fatalf("snapshotView().cannotAcceptMoves = %v, want %v", got, tt.want)
32 }32 }
A internal/usecase/daemon/session_core.go +79 -0
@@ -0,0 +1,79 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "sync/atomic"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+// attachmentSession is the private attachment-facing session contract. Local
11+// process, tab, persistence, and teardown behavior remains on *session.
12+type attachmentSession interface {
13+ core() *sessionCore
14+ snapshotView(viewOptions) sessionView
15+ statusSegments(includeTerminalTitle bool) statusSnapshot
16+ capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool)
17+ activateTargetLocked(tabIndex int) bool
18+ isProxy() bool
19+}
20+
21+// sessionCore is the state shared by every attachment-capable session. It is
22+// embedded as the first field of session so the existing s.mu selector remains
23+// the one lock guarding both attachment membership and local tab state.
24+type sessionCore struct {
25+ mu sync.Mutex
26+
27+ id domain.SessionID
28+ name string
29+ ephemeral bool
30+ caps sessionCapabilities
31+ client *attachedClient
32+ snatched map[*attachedClient]struct{}
33+ createdAt int64
34+ incarnation domain.IncarnationID
35+ mruAt atomic.Uint64
36+ coordinator atomic.Pointer[renderCoordinator]
37+}
38+
39+func (s *session) core() *sessionCore {
40+ if s == nil {
41+ return nil
42+ }
43+ return &s.sessionCore
44+}
45+
46+func (s *session) isProxy() bool { return false }
47+
48+func (s *session) capturePrimary(ac *attachedClient, req primaryCaptureRequest) (*capturedRenderState, bool) {
49+ return captureLocalPrimaryRenderState(s, ac, req)
50+}
51+
52+// activateTargetLocked validates and selects a local tab. Caller holds s.mu.
53+func (s *session) activateTargetLocked(tabIndex int) bool {
54+ if tabIndex < 0 {
55+ return true
56+ }
57+ if tabIndex >= len(s.tabs) {
58+ return false
59+ }
60+ s.active = tabIndex
61+ return true
62+}
63+
64+// localSession narrows an attachment entry for operations that own local PTYs,
65+// tabs, snapshots, or durable lifecycle state.
66+func localSession(entry attachmentSession) (*session, bool) {
67+ sess, ok := entry.(*session)
68+ return sess, ok && sess != nil
69+}
70+
71+func localSessionsSnapshot(entries map[domain.SessionID]attachmentSession) []*session {
72+ locals := make([]*session, 0, len(entries))
73+ for _, entry := range entries {
74+ if sess, ok := localSession(entry); ok {
75+ locals = append(locals, sess)
76+ }
77+ }
78+ return locals
79+}
@@ -0,0 +1,79 @@
1+package daemon
2+
3+import (
4+ "sync"
5+ "sync/atomic"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+// attachmentSession is the private attachment-facing session contract. Local
11+// process, tab, persistence, and teardown behavior remains on *session.
12+type attachmentSession interface {
13+ core() *sessionCore
14+ snapshotView(viewOptions) sessionView
15+ statusSegments(includeTerminalTitle bool) statusSnapshot
16+ capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool)
17+ activateTargetLocked(tabIndex int) bool
18+ isProxy() bool
19+}
20+
21+// sessionCore is the state shared by every attachment-capable session. It is
22+// embedded as the first field of session so the existing s.mu selector remains
23+// the one lock guarding both attachment membership and local tab state.
24+type sessionCore struct {
25+ mu sync.Mutex
26+
27+ id domain.SessionID
28+ name string
29+ ephemeral bool
30+ caps sessionCapabilities
31+ client *attachedClient
32+ snatched map[*attachedClient]struct{}
33+ createdAt int64
34+ incarnation domain.IncarnationID
35+ mruAt atomic.Uint64
36+ coordinator atomic.Pointer[renderCoordinator]
37+}
38+
39+func (s *session) core() *sessionCore {
40+ if s == nil {
41+ return nil
42+ }
43+ return &s.sessionCore
44+}
45+
46+func (s *session) isProxy() bool { return false }
47+
48+func (s *session) capturePrimary(ac *attachedClient, req primaryCaptureRequest) (*capturedRenderState, bool) {
49+ return captureLocalPrimaryRenderState(s, ac, req)
50+}
51+
52+// activateTargetLocked validates and selects a local tab. Caller holds s.mu.
53+func (s *session) activateTargetLocked(tabIndex int) bool {
54+ if tabIndex < 0 {
55+ return true
56+ }
57+ if tabIndex >= len(s.tabs) {
58+ return false
59+ }
60+ s.active = tabIndex
61+ return true
62+}
63+
64+// localSession narrows an attachment entry for operations that own local PTYs,
65+// tabs, snapshots, or durable lifecycle state.
66+func localSession(entry attachmentSession) (*session, bool) {
67+ sess, ok := entry.(*session)
68+ return sess, ok && sess != nil
69+}
70+
71+func localSessionsSnapshot(entries map[domain.SessionID]attachmentSession) []*session {
72+ locals := make([]*session, 0, len(entries))
73+ for _, entry := range entries {
74+ if sess, ok := localSession(entry); ok {
75+ locals = append(locals, sess)
76+ }
77+ }
78+ return locals
79+}
A internal/usecase/daemon/session_core_test.go +179 -0
@@ -0,0 +1,179 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
13+ sess := &session{}
14+ sess.id = domain.SessionID("local")
15+ sess.name = "work"
16+ sess.tabs = []*tab{{name: "shell"}}
17+ ac := &attachedClient{}
18+
19+ core := sess.core()
20+ require.Same(t, &sess.sessionCore, core)
21+ require.Same(t, sess, mustLocalSession(t, attachmentSession(sess)))
22+ require.False(t, sess.isProxy())
23+ require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")
24+
25+ core.mu.Lock()
26+ require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu")
27+ core.client = ac
28+ require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac))
29+ sess.tabs = append(sess.tabs, &tab{name: "logs"})
30+ require.Len(t, sess.tabs, 2)
31+ core.mu.Unlock()
32+
33+ require.True(t, sess.mu.TryLock())
34+ sess.mu.Unlock()
35+}
36+
37+func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.T) {
38+ sess := &session{
39+ sessionCore: sessionCore{id: domain.SessionID("local"), name: "work"},
40+ tabs: []*tab{{name: "shell"}},
41+ }
42+ ac := &attachedClient{}
43+
44+ // Mutate local-only tab state and shared attachment role state under the
45+ // promoted core mutex. snapshotView must observe the same mutex before it
46+ // can publish either field.
47+ sess.core().mu.Lock()
48+ sess.tabs = append(sess.tabs, &tab{name: "logs"})
49+ sess.active = 1
50+ sess.client = ac
51+
52+ preCall := make(chan struct{})
53+ allowCall := make(chan struct{})
54+ snapshotted := make(chan sessionView, 1)
55+ go func() {
56+ // The barrier places the goroutine immediately before snapshotView, so
57+ // the lock is definitely held before the call can begin.
58+ close(preCall)
59+ <-allowCall
60+ snapshotted <- sess.snapshotView(viewOptions{})
61+ }()
62+ awaitTestCompletion(t, preCall, "snapshot goroutine did not reach the pre-call barrier")
63+ close(allowCall)
64+
65+ // Give the released call a bounded opportunity to acquire the mutex; it
66+ // must remain blocked until the owner releases it below.
67+ timer := time.NewTimer(50 * time.Millisecond)
68+ defer timer.Stop()
69+ select {
70+ case <-snapshotted:
71+ t.Fatal("snapshotView completed while sessionCore.mu guarded local session state")
72+ case <-timer.C:
73+ }
74+ require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac))
75+ sess.core().mu.Unlock()
76+
77+ view := awaitTestValue(t, snapshotted, "snapshotView did not complete after sessionCore.mu was released")
78+ require.True(t, view.attached)
79+ require.Equal(t, 2, view.tabCount)
80+ require.Equal(t, 1, view.active)
81+}
82+
83+func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) {
84+ pty, releasePTY := newBlockingPTY(t)
85+ defer releasePTY()
86+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
87+
88+ sess, err := createSessionForTest(d, "0", true, "/tmp", defaultSize, terminalEnv{}, nil)
89+ require.NoError(t, err)
90+ d.mu.Lock()
91+ registered := d.sessions[sess.id]
92+ d.mu.Unlock()
93+ require.Same(t, sess, registered)
94+
95+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
96+ d.mu.Lock()
97+ _, live := d.sessions[sess.id]
98+ d.mu.Unlock()
99+ require.False(t, live, "killed local session must not remain in the live registry")
100+ d.sessWg.Wait()
101+}
102+
103+func TestAttachmentSessionRegistryUsesExactIdentity(t *testing.T) {
104+ d := &Daemon{sessions: make(map[domain.SessionID]attachmentSession)}
105+ first := &session{}
106+ first.id = domain.SessionID("same")
107+ second := &session{}
108+ second.id = first.id
109+
110+ require.True(t, d.registerSessionLocked(first))
111+ require.False(t, d.registerSessionLocked(first), "duplicate registration must be rejected")
112+ require.False(t, d.registerSessionLocked(second), "a different pointer with the same ID must be rejected")
113+ require.False(t, d.unregisterSessionLocked(second), "unregister must not remove a replacement pointer")
114+ require.Same(t, first, d.sessions[first.id])
115+ require.True(t, d.unregisterSessionLocked(first))
116+ require.NotContains(t, d.sessions, first.id)
117+ require.False(t, d.unregisterSessionLocked(first))
118+}
119+
120+func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) {
121+ first := &session{}
122+ first.id = domain.SessionID("a")
123+ second := &session{}
124+ second.id = domain.SessionID("z")
125+
126+ unlock := lockAttachmentSessions(second, first)
127+ require.False(t, first.core().mu.TryLock())
128+ require.False(t, second.core().mu.TryLock())
129+ unlock()
130+
131+ require.True(t, first.core().mu.TryLock())
132+ first.core().mu.Unlock()
133+ require.True(t, second.core().mu.TryLock())
134+ second.core().mu.Unlock()
135+}
136+
137+func TestProxiedInitialMetadataSkipsInvalidSnapshot(t *testing.T) {
138+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
139+ hello := ports.Hello{
140+ Version: ports.ProtocolVersion,
141+ Intent: ports.IntentNew,
142+ Proxied: true,
143+ Name: "remote-work",
144+ Size: defaultSize,
145+ }
146+ tr := newWelcomeBlockingTransport(t)
147+ done := make(chan struct{})
148+ go func() {
149+ d.handleHello(tr.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
150+ close(done)
151+ }()
152+
153+ awaitTestCompletion(t, tr.welcomeEntered, "proxied handshake did not send Welcome")
154+ sess := firstSession(d)
155+ require.NotNil(t, sess)
156+ sess.mu.Lock()
157+ sess.active = len(sess.tabs)
158+ sess.mu.Unlock()
159+ tr.release()
160+ awaitTestCompletion(t, done, "invalid metadata snapshot did not end the handshake")
161+
162+ welcome := <-tr.sends
163+ require.Equal(t, ports.MsgWelcome, welcome.Type)
164+ select {
165+ case frame := <-tr.sends:
166+ t.Fatalf("invalid metadata snapshot was sent as frame type %d", frame.Type)
167+ default:
168+ }
169+
170+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
171+ d.sessWg.Wait()
172+}
173+
174+func mustLocalSession(t *testing.T, entry attachmentSession) *session {
175+ t.Helper()
176+ sess, ok := localSession(entry)
177+ require.True(t, ok)
178+ return sess
179+}
@@ -0,0 +1,179 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
13+ sess := &session{}
14+ sess.id = domain.SessionID("local")
15+ sess.name = "work"
16+ sess.tabs = []*tab{{name: "shell"}}
17+ ac := &attachedClient{}
18+
19+ core := sess.core()
20+ require.Same(t, &sess.sessionCore, core)
21+ require.Same(t, sess, mustLocalSession(t, attachmentSession(sess)))
22+ require.False(t, sess.isProxy())
23+ require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities")
24+
25+ core.mu.Lock()
26+ require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu")
27+ core.client = ac
28+ require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac))
29+ sess.tabs = append(sess.tabs, &tab{name: "logs"})
30+ require.Len(t, sess.tabs, 2)
31+ core.mu.Unlock()
32+
33+ require.True(t, sess.mu.TryLock())
34+ sess.mu.Unlock()
35+}
36+
37+func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.T) {
38+ sess := &session{
39+ sessionCore: sessionCore{id: domain.SessionID("local"), name: "work"},
40+ tabs: []*tab{{name: "shell"}},
41+ }
42+ ac := &attachedClient{}
43+
44+ // Mutate local-only tab state and shared attachment role state under the
45+ // promoted core mutex. snapshotView must observe the same mutex before it
46+ // can publish either field.
47+ sess.core().mu.Lock()
48+ sess.tabs = append(sess.tabs, &tab{name: "logs"})
49+ sess.active = 1
50+ sess.client = ac
51+
52+ preCall := make(chan struct{})
53+ allowCall := make(chan struct{})
54+ snapshotted := make(chan sessionView, 1)
55+ go func() {
56+ // The barrier places the goroutine immediately before snapshotView, so
57+ // the lock is definitely held before the call can begin.
58+ close(preCall)
59+ <-allowCall
60+ snapshotted <- sess.snapshotView(viewOptions{})
61+ }()
62+ awaitTestCompletion(t, preCall, "snapshot goroutine did not reach the pre-call barrier")
63+ close(allowCall)
64+
65+ // Give the released call a bounded opportunity to acquire the mutex; it
66+ // must remain blocked until the owner releases it below.
67+ timer := time.NewTimer(50 * time.Millisecond)
68+ defer timer.Stop()
69+ select {
70+ case <-snapshotted:
71+ t.Fatal("snapshotView completed while sessionCore.mu guarded local session state")
72+ case <-timer.C:
73+ }
74+ require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac))
75+ sess.core().mu.Unlock()
76+
77+ view := awaitTestValue(t, snapshotted, "snapshotView did not complete after sessionCore.mu was released")
78+ require.True(t, view.attached)
79+ require.Equal(t, 2, view.tabCount)
80+ require.Equal(t, 1, view.active)
81+}
82+
83+func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) {
84+ pty, releasePTY := newBlockingPTY(t)
85+ defer releasePTY()
86+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
87+
88+ sess, err := createSessionForTest(d, "0", true, "/tmp", defaultSize, terminalEnv{}, nil)
89+ require.NoError(t, err)
90+ d.mu.Lock()
91+ registered := d.sessions[sess.id]
92+ d.mu.Unlock()
93+ require.Same(t, sess, registered)
94+
95+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
96+ d.mu.Lock()
97+ _, live := d.sessions[sess.id]
98+ d.mu.Unlock()
99+ require.False(t, live, "killed local session must not remain in the live registry")
100+ d.sessWg.Wait()
101+}
102+
103+func TestAttachmentSessionRegistryUsesExactIdentity(t *testing.T) {
104+ d := &Daemon{sessions: make(map[domain.SessionID]attachmentSession)}
105+ first := &session{}
106+ first.id = domain.SessionID("same")
107+ second := &session{}
108+ second.id = first.id
109+
110+ require.True(t, d.registerSessionLocked(first))
111+ require.False(t, d.registerSessionLocked(first), "duplicate registration must be rejected")
112+ require.False(t, d.registerSessionLocked(second), "a different pointer with the same ID must be rejected")
113+ require.False(t, d.unregisterSessionLocked(second), "unregister must not remove a replacement pointer")
114+ require.Same(t, first, d.sessions[first.id])
115+ require.True(t, d.unregisterSessionLocked(first))
116+ require.NotContains(t, d.sessions, first.id)
117+ require.False(t, d.unregisterSessionLocked(first))
118+}
119+
120+func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) {
121+ first := &session{}
122+ first.id = domain.SessionID("a")
123+ second := &session{}
124+ second.id = domain.SessionID("z")
125+
126+ unlock := lockAttachmentSessions(second, first)
127+ require.False(t, first.core().mu.TryLock())
128+ require.False(t, second.core().mu.TryLock())
129+ unlock()
130+
131+ require.True(t, first.core().mu.TryLock())
132+ first.core().mu.Unlock()
133+ require.True(t, second.core().mu.TryLock())
134+ second.core().mu.Unlock()
135+}
136+
137+func TestProxiedInitialMetadataSkipsInvalidSnapshot(t *testing.T) {
138+ d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
139+ hello := ports.Hello{
140+ Version: ports.ProtocolVersion,
141+ Intent: ports.IntentNew,
142+ Proxied: true,
143+ Name: "remote-work",
144+ Size: defaultSize,
145+ }
146+ tr := newWelcomeBlockingTransport(t)
147+ done := make(chan struct{})
148+ go func() {
149+ d.handleHello(tr.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
150+ close(done)
151+ }()
152+
153+ awaitTestCompletion(t, tr.welcomeEntered, "proxied handshake did not send Welcome")
154+ sess := firstSession(d)
155+ require.NotNil(t, sess)
156+ sess.mu.Lock()
157+ sess.active = len(sess.tabs)
158+ sess.mu.Unlock()
159+ tr.release()
160+ awaitTestCompletion(t, done, "invalid metadata snapshot did not end the handshake")
161+
162+ welcome := <-tr.sends
163+ require.Equal(t, ports.MsgWelcome, welcome.Type)
164+ select {
165+ case frame := <-tr.sends:
166+ t.Fatalf("invalid metadata snapshot was sent as frame type %d", frame.Type)
167+ default:
168+ }
169+
170+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
171+ d.sessWg.Wait()
172+}
173+
174+func mustLocalSession(t *testing.T, entry attachmentSession) *session {
175+ t.Helper()
176+ sess, ok := localSession(entry)
177+ require.True(t, ok)
178+ return sess
179+}
M internal/usecase/daemon/session_move_locator_test.go +2 -3
@@ -11,10 +11,9 @@ import (
1111 )
1212
1313 func TestSessionMoveLocatorSynchronizesMutableNameRead(t *testing.T) {
14- sess := &session{
15- id: domain.SessionID("session-id"),
14+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("session-id"),
1615 incarnation: domain.IncarnationID{1},
17- name: "initial",
16+ name: "initial"},
1817 }
1918 start := make(chan struct{})
2019 var wg sync.WaitGroup
@@ -11,10 +11,9 @@ import (
11 )11 )
12 12
13 func TestSessionMoveLocatorSynchronizesMutableNameRead(t *testing.T) {13 func TestSessionMoveLocatorSynchronizesMutableNameRead(t *testing.T) {
14- sess := &session{14+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("session-id"),
15- id: domain.SessionID("session-id"),
16 incarnation: domain.IncarnationID{1},15 incarnation: domain.IncarnationID{1},
17- name: "initial",16+ name: "initial"},
18 }17 }
19 start := make(chan struct{})18 start := make(chan struct{})
20 var wg sync.WaitGroup19 var wg sync.WaitGroup
M internal/usecase/daemon/session_purge_retry_test.go +1 -1
@@ -38,7 +38,7 @@ func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *te
3838 pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
3939 require.True(t, ok)
4040 pty.EXPECT().Close().Return(nil).Once()
41- d.sessions = map[domain.SessionID]*session{sess.id: sess}
41+ d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}
4242
4343 require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))
4444 require.Equal(t, []string{"delete incarnation"}, repository.calls)
@@ -38,7 +38,7 @@ func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *te
38 pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)38 pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
39 require.True(t, ok)39 require.True(t, ok)
40 pty.EXPECT().Close().Return(nil).Once()40 pty.EXPECT().Close().Return(nil).Once()
41- d.sessions = map[domain.SessionID]*session{sess.id: sess}41+ d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}
42 42
43 require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))43 require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true))
44 require.Equal(t, []string{"delete incarnation"}, repository.calls)44 require.Equal(t, []string{"delete incarnation"}, repository.calls)
M internal/usecase/daemon/session_rename_purge_test.go +1 -1
@@ -16,7 +16,7 @@ func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
1616 store, state := newMockStore(t)
1717 WithStore(t, store)(d)
1818 sess := newSnapshotTestSession(t, "old", false, "/work")
19- d.sessions = map[domain.SessionID]*session{sess.id: sess}
19+ d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}
2020 sess.mu.Lock()
2121 record := sess.persistRecordLocked(1)
2222 sess.mu.Unlock()
@@ -16,7 +16,7 @@ func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) {
16 store, state := newMockStore(t)16 store, state := newMockStore(t)
17 WithStore(t, store)(d)17 WithStore(t, store)(d)
18 sess := newSnapshotTestSession(t, "old", false, "/work")18 sess := newSnapshotTestSession(t, "old", false, "/work")
19- d.sessions = map[domain.SessionID]*session{sess.id: sess}19+ d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess}
20 sess.mu.Lock()20 sess.mu.Lock()
21 record := sess.persistRecordLocked(1)21 record := sess.persistRecordLocked(1)
22 sess.mu.Unlock()22 sess.mu.Unlock()
M internal/usecase/daemon/session_test.go +8 -8
@@ -168,7 +168,7 @@ func TestHandshakeOldHelloLayoutReportsVersionMismatch(t *testing.T) {
168168 func TestHandshakeNameTaken(t *testing.T) {
169169 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
170170 // Pre-populate a bare session (no goroutines) to collide with.
171- d.sessions[domain.SessionID("x")] = &session{id: "x", name: "work"}
171+ d.sessions[domain.SessionID("x")] = &session{sessionCore: sessionCore{id: "x", name: "work"}}
172172
173173 tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
174174 d.handleConn(tr)
@@ -272,8 +272,8 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
272272 func TestEphemeralNumberingReuse(t *testing.T) {
273273 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
274274 // Emulate two ephemeral sessions "0" and "1".
275- d.sessions["a"] = &session{id: "a", name: "0"}
276- d.sessions["b"] = &session{id: "b", name: "1"}
275+ d.sessions["a"] = &session{sessionCore: sessionCore{id: "a", name: "0"}}
276+ d.sessions["b"] = &session{sessionCore: sessionCore{id: "b", name: "1"}}
277277 require.Equal(t, "2", d.allocEphemeralNameLocked())
278278
279279 // Kill "0": the freed number is reused before "2".
@@ -890,14 +890,14 @@ func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) {
890890 store, state := newMockStore(t)
891891 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
892892 WithStore(t, store)(d)
893- named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}}
893+ named := &session{sessionCore: sessionCore{name: "work", createdAt: 1, incarnation: domain.IncarnationID{1}}, tabs: []*tab{{}}}
894894 require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
895895
896896 d.touchMRU(named)
897897 require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)
898898 setsAfterNamed := state.sets
899899
900- ephemeral := &session{name: "0", ephemeral: true, tabs: []*tab{{}}}
900+ ephemeral := &session{sessionCore: sessionCore{name: "0", ephemeral: true}, tabs: []*tab{{}}}
901901 d.touchMRU(ephemeral)
902902 require.False(t, state.has("0"))
903903 require.Equal(t, setsAfterNamed, state.sets)
@@ -1609,7 +1609,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
16091609 ac.sendMu.Unlock()
16101610
16111611 require.NoError(t, d.createSessionAndSwitch(sess, ac, "next"))
1612- got := ac.sess.Get()
1612+ got := ac.currentSession()
16131613 require.NotNil(t, got)
16141614 sourceCoordinator.mu.Lock()
16151615 require.NotNil(t, sourceCoordinator.lease)
@@ -1839,7 +1839,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
18391839 t.Run("active replacement is rejected", func(t *testing.T) {
18401840 d, from, ac, _, releases := newRecentNavigationTestSessions(t)
18411841 defer releaseAll(releases)
1842- target := d.sessions["recent"]
1842+ target := mustLocalSession(t, d.sessions["recent"])
18431843 target.createdAt = 22
18441844 expected := int64(21)
18451845
@@ -1865,7 +1865,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
18651865 t.Run("stopped target that became active switches same lifecycle", func(t *testing.T) {
18661866 d, from, ac, _, releases := newRecentNavigationTestSessions(t)
18671867 defer releaseAll(releases)
1868- target := d.sessions["recent"]
1868+ target := mustLocalSession(t, d.sessions["recent"])
18691869 target.createdAt = 41
18701870 expected := int64(41)
18711871
@@ -168,7 +168,7 @@ func TestHandshakeOldHelloLayoutReportsVersionMismatch(t *testing.T) {
168 func TestHandshakeNameTaken(t *testing.T) {168 func TestHandshakeNameTaken(t *testing.T) {
169 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})169 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
170 // Pre-populate a bare session (no goroutines) to collide with.170 // Pre-populate a bare session (no goroutines) to collide with.
171- d.sessions[domain.SessionID("x")] = &session{id: "x", name: "work"}171+ d.sessions[domain.SessionID("x")] = &session{sessionCore: sessionCore{id: "x", name: "work"}}
172 172
173 tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))173 tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24}))
174 d.handleConn(tr)174 d.handleConn(tr)
@@ -272,8 +272,8 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
272 func TestEphemeralNumberingReuse(t *testing.T) {272 func TestEphemeralNumberingReuse(t *testing.T) {
273 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))273 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
274 // Emulate two ephemeral sessions "0" and "1".274 // Emulate two ephemeral sessions "0" and "1".
275- d.sessions["a"] = &session{id: "a", name: "0"}275+ d.sessions["a"] = &session{sessionCore: sessionCore{id: "a", name: "0"}}
276- d.sessions["b"] = &session{id: "b", name: "1"}276+ d.sessions["b"] = &session{sessionCore: sessionCore{id: "b", name: "1"}}
277 require.Equal(t, "2", d.allocEphemeralNameLocked())277 require.Equal(t, "2", d.allocEphemeralNameLocked())
278 278
279 // Kill "0": the freed number is reused before "2".279 // Kill "0": the freed number is reused before "2".
@@ -890,14 +890,14 @@ func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) {
890 store, state := newMockStore(t)890 store, state := newMockStore(t)
891 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})891 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
892 WithStore(t, store)(d)892 WithStore(t, store)(d)
893- named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}}893+ named := &session{sessionCore: sessionCore{name: "work", createdAt: 1, incarnation: domain.IncarnationID{1}}, tabs: []*tab{{}}}
894 require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))894 require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
895 895
896 d.touchMRU(named)896 d.touchMRU(named)
897 require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)897 require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)
898 setsAfterNamed := state.sets898 setsAfterNamed := state.sets
899 899
900- ephemeral := &session{name: "0", ephemeral: true, tabs: []*tab{{}}}900+ ephemeral := &session{sessionCore: sessionCore{name: "0", ephemeral: true}, tabs: []*tab{{}}}
901 d.touchMRU(ephemeral)901 d.touchMRU(ephemeral)
902 require.False(t, state.has("0"))902 require.False(t, state.has("0"))
903 require.Equal(t, setsAfterNamed, state.sets)903 require.Equal(t, setsAfterNamed, state.sets)
@@ -1609,7 +1609,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1609 ac.sendMu.Unlock()1609 ac.sendMu.Unlock()
1610 1610
1611 require.NoError(t, d.createSessionAndSwitch(sess, ac, "next"))1611 require.NoError(t, d.createSessionAndSwitch(sess, ac, "next"))
1612- got := ac.sess.Get()1612+ got := ac.currentSession()
1613 require.NotNil(t, got)1613 require.NotNil(t, got)
1614 sourceCoordinator.mu.Lock()1614 sourceCoordinator.mu.Lock()
1615 require.NotNil(t, sourceCoordinator.lease)1615 require.NotNil(t, sourceCoordinator.lease)
@@ -1839,7 +1839,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
1839 t.Run("active replacement is rejected", func(t *testing.T) {1839 t.Run("active replacement is rejected", func(t *testing.T) {
1840 d, from, ac, _, releases := newRecentNavigationTestSessions(t)1840 d, from, ac, _, releases := newRecentNavigationTestSessions(t)
1841 defer releaseAll(releases)1841 defer releaseAll(releases)
1842- target := d.sessions["recent"]1842+ target := mustLocalSession(t, d.sessions["recent"])
1843 target.createdAt = 221843 target.createdAt = 22
1844 expected := int64(21)1844 expected := int64(21)
1845 1845
@@ -1865,7 +1865,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing
1865 t.Run("stopped target that became active switches same lifecycle", func(t *testing.T) {1865 t.Run("stopped target that became active switches same lifecycle", func(t *testing.T) {
1866 d, from, ac, _, releases := newRecentNavigationTestSessions(t)1866 d, from, ac, _, releases := newRecentNavigationTestSessions(t)
1867 defer releaseAll(releases)1867 defer releaseAll(releases)
1868- target := d.sessions["recent"]1868+ target := mustLocalSession(t, d.sessions["recent"])
1869 target.createdAt = 411869 target.createdAt = 41
1870 expected := int64(41)1870 expected := int64(41)
1871 1871
M internal/usecase/daemon/session_view_test.go +7 -9
@@ -17,13 +17,13 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) {
1717 {
1818 name: "named session with attention tab",
1919 build: func() *session {
20- s := &session{
21- id: domain.SessionID("s1"),
20+ s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"),
2221 incarnation: domain.IncarnationID{1},
2322 name: "alpha",
2423 createdAt: 42,
25- active: 1,
26- client: &attachedClient{},
24+
25+ client: &attachedClient{}}, active: 1,
26+
2727 tabs: []*tab{
2828 {stableID: "t1", name: "build"},
2929 {stableID: "t2", name: "logs", attention: true},
@@ -52,7 +52,7 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) {
5252 {
5353 name: "ephemeral detached session without tabs",
5454 build: func() *session {
55- return &session{id: domain.SessionID("s2"), name: "9", ephemeral: true}
55+ return &session{sessionCore: sessionCore{id: domain.SessionID("s2"), name: "9", ephemeral: true}}
5656 },
5757 opts: viewOptions{},
5858 want: sessionView{
@@ -74,10 +74,8 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) {
7474 }
7575
7676 func TestSnapshotViewFocusedTitlesOption(t *testing.T) {
77- s := &session{
78- id: domain.SessionID("s1"),
79- name: "alpha",
80- tabs: []*tab{{stableID: "t1", name: "build"}},
77+ s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"),
78+ name: "alpha"}, tabs: []*tab{{stableID: "t1", name: "build"}},
8179 }
8280 off := s.snapshotView(viewOptions{tabDetails: true})
8381 if off.tabs[0].focusedTitle != "" {
@@ -17,13 +17,13 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) {
17 {17 {
18 name: "named session with attention tab",18 name: "named session with attention tab",
19 build: func() *session {19 build: func() *session {
20- s := &session{20+ s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"),
21- id: domain.SessionID("s1"),
22 incarnation: domain.IncarnationID{1},21 incarnation: domain.IncarnationID{1},
23 name: "alpha",22 name: "alpha",
24 createdAt: 42,23 createdAt: 42,
25- active: 1,24+
26- client: &attachedClient{},25+ client: &attachedClient{}}, active: 1,
26+
27 tabs: []*tab{27 tabs: []*tab{
28 {stableID: "t1", name: "build"},28 {stableID: "t1", name: "build"},
29 {stableID: "t2", name: "logs", attention: true},29 {stableID: "t2", name: "logs", attention: true},
@@ -52,7 +52,7 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) {
52 {52 {
53 name: "ephemeral detached session without tabs",53 name: "ephemeral detached session without tabs",
54 build: func() *session {54 build: func() *session {
55- return &session{id: domain.SessionID("s2"), name: "9", ephemeral: true}55+ return &session{sessionCore: sessionCore{id: domain.SessionID("s2"), name: "9", ephemeral: true}}
56 },56 },
57 opts: viewOptions{},57 opts: viewOptions{},
58 want: sessionView{58 want: sessionView{
@@ -74,10 +74,8 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) {
74 }74 }
75 75
76 func TestSnapshotViewFocusedTitlesOption(t *testing.T) {76 func TestSnapshotViewFocusedTitlesOption(t *testing.T) {
77- s := &session{77+ s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"),
78- id: domain.SessionID("s1"),78+ name: "alpha"}, tabs: []*tab{{stableID: "t1", name: "build"}},
79- name: "alpha",
80- tabs: []*tab{{stableID: "t1", name: "build"}},
81 }79 }
82 off := s.snapshotView(viewOptions{tabDetails: true})80 off := s.snapshotView(viewOptions{tabDetails: true})
83 if off.tabs[0].focusedTitle != "" {81 if off.tabs[0].focusedTitle != "" {
M internal/usecase/daemon/snapshot_restore.go +7 -2
@@ -290,7 +290,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
290290 }
291291
292292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {
293- sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), createdAt: int64(snap.CreatedAt), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
293+ sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
294294 // Restored tabs remain private until persistAndRegisterRestoredSession.
295295 // Initialize owners now so registration and reader startup cannot expose an
296296 // ownerless pane.
@@ -350,8 +350,13 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se
350350 sess.mu.Lock()
351351 sess.restoreDone = stopped.restoreDone
352352 sess.mu.Unlock()
353+ if !d.registerSessionLocked(sess) {
354+ return false, errors.New("daemon: session registry rejected restored session")
355+ }
356+ // The stopped entry is the durable authority and restore barrier until the
357+ // runtime identity has actually been published. A registry rejection must
358+ // leave that exact entry available for attach/recovery retries.
353359 delete(d.stopped, sess.name)
354- d.sessions[sess.id] = sess
355360 sess.snapDirty.Store(false)
356361 return true, nil
357362 }
@@ -290,7 +290,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
290 }290 }
291 291
292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {
293- sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), createdAt: int64(snap.CreatedAt), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}293+ sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
294 // Restored tabs remain private until persistAndRegisterRestoredSession.294 // Restored tabs remain private until persistAndRegisterRestoredSession.
295 // Initialize owners now so registration and reader startup cannot expose an295 // Initialize owners now so registration and reader startup cannot expose an
296 // ownerless pane.296 // ownerless pane.
@@ -350,8 +350,13 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se
350 sess.mu.Lock()350 sess.mu.Lock()
351 sess.restoreDone = stopped.restoreDone351 sess.restoreDone = stopped.restoreDone
352 sess.mu.Unlock()352 sess.mu.Unlock()
353+ if !d.registerSessionLocked(sess) {
354+ return false, errors.New("daemon: session registry rejected restored session")
355+ }
356+ // The stopped entry is the durable authority and restore barrier until the
357+ // runtime identity has actually been published. A registry rejection must
358+ // leave that exact entry available for attach/recovery retries.
353 delete(d.stopped, sess.name)359 delete(d.stopped, sess.name)
354- d.sessions[sess.id] = sess
355 sess.snapDirty.Store(false)360 sess.snapDirty.Store(false)
356 return true, nil361 return true, nil
357 }362 }
M internal/usecase/daemon/snapshot_test.go +1 -1
@@ -89,7 +89,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
8989 p := tb.panes["pane-1"]
9090 p.screen.Write([]byte("hello"))
9191 appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
92- sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}
92+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, createdAt: 42}, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd}
9393 if !ephemeral {
9494 sess.incarnation = domain.IncarnationID{1}
9595 }
@@ -89,7 +89,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin
89 p := tb.panes["pane-1"]89 p := tb.panes["pane-1"]
90 p.screen.Write([]byte("hello"))90 p.screen.Write([]byte("hello"))
91 appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})91 appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
92- sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42}92+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, createdAt: 42}, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd}
93 if !ephemeral {93 if !ephemeral {
94 sess.incarnation = domain.IncarnationID{1}94 sess.incarnation = domain.IncarnationID{1}
95 }95 }
M internal/usecase/daemon/snatched_cleanup.go +24 -21
@@ -15,16 +15,17 @@ func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal b
1515 frozen := freezeRoleEffectGates(token.ac)
1616 defer frozen.unfreeze()
1717 d.notices.routingMu.Lock()
18- token.sess.mu.Lock()
19- current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
18+ core := token.sess.core()
19+ core.mu.Lock()
20+ current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched &&
2021 token.ac.roleGeneration.Load() == token.generation &&
2122 token.ac.transportSnapshotCurrent(token.transport)
2223 if current {
23- delete(token.sess.snatched, token.ac)
24+ delete(core.snatched, token.ac)
2425 token.ac.roleGeneration.Add(1)
2526 token.ac.invalidateFrozenRoleCapability()
2627 }
27- token.sess.mu.Unlock()
28+ core.mu.Unlock()
2829 d.notices.routingMu.Unlock()
2930 if !current {
3031 return false
@@ -50,20 +51,21 @@ func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal b
5051 // parkSnatchedAttachment removes only the exact lost snatched incarnation and
5152 // retains its resume identity for a role-preserving reconnect.
5253 func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool {
53- if token.ac == nil || !token.ac.resumeCapable {
54+ sess, ok := localSession(token.sess)
55+ if !ok || token.ac == nil || !token.ac.resumeCapable {
5456 return false
5557 }
5658 // Advertise parking before unroute so IntentResume never observes both the
5759 // snatched seat and parking/parked registries empty for a still-valid token.
58- parkingToken := d.markParkingInFlight(token.sess, token.ac)
60+ parkingToken := d.markParkingInFlight(sess, token.ac)
5961 if !d.unrouteSnatchedAttachment(token, false) {
60- d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)
62+ d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
6163 return false
6264 }
6365 if d.afterSnatchedUnrouteBeforePark != nil {
6466 d.afterSnatchedUnrouteBeforePark()
6567 }
66- if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
68+ if d.parkAttachmentAs(sess, token.ac, attachmentSnatched) {
6769 captured := token.transport.transport
6870 _ = token.ac.revokeTransport(captured)
6971 _ = token.ac.closeCapturedTransport(captured)
@@ -90,18 +92,19 @@ func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool {
9092 // published snatched incarnation. Attachment-local cleanup runs without routing
9193 // or session locks while the terminal capability remains frozen.
9294 func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool {
93- if token.sess == nil || token.ac == nil || token.transport.transport == nil {
95+ sess, ok := localSession(token.sess)
96+ if !ok || token.ac == nil || token.transport.transport == nil {
9497 return false
9598 }
9699 frozen := freezeRoleEffectGates(token.ac)
97100 defer frozen.unfreeze()
98101
99102 d.notices.routingMu.Lock()
100- token.sess.mu.Lock()
101- current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
102- token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
103+ sess.mu.Lock()
104+ current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched &&
105+ token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
103106 token.ac.transportSnapshotCurrent(token.transport)
104- token.sess.mu.Unlock()
107+ sess.mu.Unlock()
105108 d.notices.routingMu.Unlock()
106109 if !current {
107110 return false
@@ -111,7 +114,7 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken)
111114 // the unroute→park gap for a still-valid snatched credential.
112115 var parkingToken uint64
113116 if token.ac.resumeCapable {
114- parkingToken = d.markParkingInFlight(token.sess, token.ac)
117+ parkingToken = d.markParkingInFlight(sess, token.ac)
115118 }
116119
117120 // The frozen gate prevents a promotion from publishing between overlay
@@ -119,19 +122,19 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken)
119122 d.clearForSnatch(token)
120123
121124 d.notices.routingMu.Lock()
122- token.sess.mu.Lock()
123- current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&
124- token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
125+ sess.mu.Lock()
126+ current = attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched &&
127+ token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
125128 token.ac.transportSnapshotCurrent(token.transport)
126129 if current {
127- delete(token.sess.snatched, token.ac)
130+ delete(sess.snatched, token.ac)
128131 token.ac.roleGeneration.Add(1)
129132 token.ac.invalidateFrozenRoleCapability()
130133 }
131- token.sess.mu.Unlock()
134+ sess.mu.Unlock()
132135 d.notices.routingMu.Unlock()
133136 if !current {
134- d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)
137+ d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
135138 return false
136139 }
137140
@@ -139,7 +142,7 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken)
139142 if d.afterSnatchedUnrouteBeforePark != nil {
140143 d.afterSnatchedUnrouteBeforePark()
141144 }
142- if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {
145+ if token.ac.resumeCapable && d.parkAttachmentAs(sess, token.ac, attachmentSnatched) {
143146 captured := token.transport.transport
144147 _ = token.ac.revokeTransport(captured)
145148 _ = token.ac.closeCapturedTransport(captured)
@@ -15,16 +15,17 @@ func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal b
15 frozen := freezeRoleEffectGates(token.ac)15 frozen := freezeRoleEffectGates(token.ac)
16 defer frozen.unfreeze()16 defer frozen.unfreeze()
17 d.notices.routingMu.Lock()17 d.notices.routingMu.Lock()
18- token.sess.mu.Lock()18+ core := token.sess.core()
19- current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&19+ core.mu.Lock()
20+ current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched &&
20 token.ac.roleGeneration.Load() == token.generation &&21 token.ac.roleGeneration.Load() == token.generation &&
21 token.ac.transportSnapshotCurrent(token.transport)22 token.ac.transportSnapshotCurrent(token.transport)
22 if current {23 if current {
23- delete(token.sess.snatched, token.ac)24+ delete(core.snatched, token.ac)
24 token.ac.roleGeneration.Add(1)25 token.ac.roleGeneration.Add(1)
25 token.ac.invalidateFrozenRoleCapability()26 token.ac.invalidateFrozenRoleCapability()
26 }27 }
27- token.sess.mu.Unlock()28+ core.mu.Unlock()
28 d.notices.routingMu.Unlock()29 d.notices.routingMu.Unlock()
29 if !current {30 if !current {
30 return false31 return false
@@ -50,20 +51,21 @@ func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal b
50 // parkSnatchedAttachment removes only the exact lost snatched incarnation and51 // parkSnatchedAttachment removes only the exact lost snatched incarnation and
51 // retains its resume identity for a role-preserving reconnect.52 // retains its resume identity for a role-preserving reconnect.
52 func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool {53 func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool {
53- if token.ac == nil || !token.ac.resumeCapable {54+ sess, ok := localSession(token.sess)
55+ if !ok || token.ac == nil || !token.ac.resumeCapable {
54 return false56 return false
55 }57 }
56 // Advertise parking before unroute so IntentResume never observes both the58 // Advertise parking before unroute so IntentResume never observes both the
57 // snatched seat and parking/parked registries empty for a still-valid token.59 // snatched seat and parking/parked registries empty for a still-valid token.
58- parkingToken := d.markParkingInFlight(token.sess, token.ac)60+ parkingToken := d.markParkingInFlight(sess, token.ac)
59 if !d.unrouteSnatchedAttachment(token, false) {61 if !d.unrouteSnatchedAttachment(token, false) {
60- d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)62+ d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
61 return false63 return false
62 }64 }
63 if d.afterSnatchedUnrouteBeforePark != nil {65 if d.afterSnatchedUnrouteBeforePark != nil {
64 d.afterSnatchedUnrouteBeforePark()66 d.afterSnatchedUnrouteBeforePark()
65 }67 }
66- if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {68+ if d.parkAttachmentAs(sess, token.ac, attachmentSnatched) {
67 captured := token.transport.transport69 captured := token.transport.transport
68 _ = token.ac.revokeTransport(captured)70 _ = token.ac.revokeTransport(captured)
69 _ = token.ac.closeCapturedTransport(captured)71 _ = token.ac.closeCapturedTransport(captured)
@@ -90,18 +92,19 @@ func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool {
90 // published snatched incarnation. Attachment-local cleanup runs without routing92 // published snatched incarnation. Attachment-local cleanup runs without routing
91 // or session locks while the terminal capability remains frozen.93 // or session locks while the terminal capability remains frozen.
92 func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool {94 func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool {
93- if token.sess == nil || token.ac == nil || token.transport.transport == nil {95+ sess, ok := localSession(token.sess)
96+ if !ok || token.ac == nil || token.transport.transport == nil {
94 return false97 return false
95 }98 }
96 frozen := freezeRoleEffectGates(token.ac)99 frozen := freezeRoleEffectGates(token.ac)
97 defer frozen.unfreeze()100 defer frozen.unfreeze()
98 101
99 d.notices.routingMu.Lock()102 d.notices.routingMu.Lock()
100- token.sess.mu.Lock()103+ sess.mu.Lock()
101- current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&104+ current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched &&
102- token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&105+ token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
103 token.ac.transportSnapshotCurrent(token.transport)106 token.ac.transportSnapshotCurrent(token.transport)
104- token.sess.mu.Unlock()107+ sess.mu.Unlock()
105 d.notices.routingMu.Unlock()108 d.notices.routingMu.Unlock()
106 if !current {109 if !current {
107 return false110 return false
@@ -111,7 +114,7 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken)
111 // the unroute→park gap for a still-valid snatched credential.114 // the unroute→park gap for a still-valid snatched credential.
112 var parkingToken uint64115 var parkingToken uint64
113 if token.ac.resumeCapable {116 if token.ac.resumeCapable {
114- parkingToken = d.markParkingInFlight(token.sess, token.ac)117+ parkingToken = d.markParkingInFlight(sess, token.ac)
115 }118 }
116 119
117 // The frozen gate prevents a promotion from publishing between overlay120 // The frozen gate prevents a promotion from publishing between overlay
@@ -119,19 +122,19 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken)
119 d.clearForSnatch(token)122 d.clearForSnatch(token)
120 123
121 d.notices.routingMu.Lock()124 d.notices.routingMu.Lock()
122- token.sess.mu.Lock()125+ sess.mu.Lock()
123- current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched &&126+ current = attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched &&
124- token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&127+ token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation &&
125 token.ac.transportSnapshotCurrent(token.transport)128 token.ac.transportSnapshotCurrent(token.transport)
126 if current {129 if current {
127- delete(token.sess.snatched, token.ac)130+ delete(sess.snatched, token.ac)
128 token.ac.roleGeneration.Add(1)131 token.ac.roleGeneration.Add(1)
129 token.ac.invalidateFrozenRoleCapability()132 token.ac.invalidateFrozenRoleCapability()
130 }133 }
131- token.sess.mu.Unlock()134+ sess.mu.Unlock()
132 d.notices.routingMu.Unlock()135 d.notices.routingMu.Unlock()
133 if !current {136 if !current {
134- d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken)137+ d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
135 return false138 return false
136 }139 }
137 140
@@ -139,7 +142,7 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken)
139 if d.afterSnatchedUnrouteBeforePark != nil {142 if d.afterSnatchedUnrouteBeforePark != nil {
140 d.afterSnatchedUnrouteBeforePark()143 d.afterSnatchedUnrouteBeforePark()
141 }144 }
142- if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) {145+ if token.ac.resumeCapable && d.parkAttachmentAs(sess, token.ac, attachmentSnatched) {
143 captured := token.transport.transport146 captured := token.transport.transport
144 _ = token.ac.revokeTransport(captured)147 _ = token.ac.revokeTransport(captured)
145 _ = token.ac.closeCapturedTransport(captured)148 _ = token.ac.closeCapturedTransport(captured)
M internal/usecase/daemon/snatched_client_test.go +14 -14
@@ -21,7 +21,7 @@ func TestSessionAttachmentRole(t *testing.T) {
2121 active := &attachedClient{}
2222 waiting := &attachedClient{}
2323 unknown := &attachedClient{}
24- sess := &session{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}
24+ sess := &session{sessionCore: sessionCore{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}}
2525
2626 require.Equal(t, attachmentActive, sess.attachmentRole(active))
2727 require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
@@ -30,7 +30,7 @@ func TestSessionAttachmentRole(t *testing.T) {
3030
3131 func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) {
3232 d := newTestDaemon(t, nil, stubClock{})
33- sess := &session{id: domain.SessionID("work")}
33+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
3434 oldTransport := &closeTrackingTransport{}
3535 old := &attachedClient{tr: oldTransport}
3636 old.setSession(sess)
@@ -67,7 +67,7 @@ func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) {
6767
6868 func TestDeferredAttachmentCleanupKeepsReplacementOpenAndSendsResetPanel(t *testing.T) {
6969 d := newTestDaemon(t, nil, stubClock{})
70- sess := &session{id: domain.SessionID("work")}
70+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
7171 oldTransport := &closeTrackingTransport{}
7272 old := &attachedClient{
7373 tr: oldTransport,
@@ -283,7 +283,7 @@ func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) {
283283 otherTransport := newDatagramTestTransport()
284284 otherClient := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: first.size}
285285 otherClient.initOverlays()
286- other := &session{id: "other", name: "other", ctx: otherCtx, cancel: otherCancel, client: otherClient}
286+ other := &session{sessionCore: sessionCore{id: "other", name: "other", client: otherClient}, ctx: otherCtx, cancel: otherCancel}
287287 otherClient.setSession(other)
288288 d.mu.Lock()
289289 d.sessions[other.id] = other
@@ -754,7 +754,7 @@ func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) {
754754 oldTransport := &failingSnatchedTransport{}
755755 old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
756756 old.initOverlays()
757- sess := &session{id: "work", client: old}
757+ sess := &session{sessionCore: sessionCore{id: "work", client: old}}
758758 old.setSession(sess)
759759 d.sessions[sess.id] = sess
760760
@@ -823,7 +823,7 @@ func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {
823823 oldTransport := &failingSnatchedTransport{}
824824 old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
825825 old.initOverlays()
826- sess := &session{id: "work", client: old}
826+ sess := &session{sessionCore: sessionCore{id: "work", client: old}}
827827 old.setSession(sess)
828828 d.sessions[sess.id] = sess
829829
@@ -864,7 +864,7 @@ func TestClearForSnatchReleasesEachOverlayFamilyBeforeTakingNext(t *testing.T) {
864864 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
865865 ac.initOverlays()
866866 ac.roleGeneration.Store(1)
867- sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}
867+ sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}}
868868 ac.setSession(sess)
869869 token := attachmentRoleToken{
870870 sess: sess, ac: ac, role: attachmentSnatched,
@@ -905,7 +905,7 @@ func TestClearForSnatchRejectsStaleGenerationWithoutClearingNewOverlay(t *testin
905905 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
906906 ac.initOverlays()
907907 ac.roleGeneration.Store(1)
908- sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}
908+ sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}}
909909 ac.setSession(sess)
910910 token := attachmentRoleToken{
911911 sess: sess, ac: ac, role: attachmentSnatched,
@@ -960,7 +960,7 @@ func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) {
960960 for _, tt := range tests {
961961 t.Run(tt.name, func(t *testing.T) {
962962 d := newTestDaemon(t, nil, stubClock{})
963- sess := &session{id: domain.SessionID("work")}
963+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
964964 old := &attachedClient{tr: &closeTrackingTransport{}}
965965 old.setSession(sess)
966966 sess.client = old
@@ -986,8 +986,8 @@ func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) {
986986
987987 func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) {
988988 d := newTestDaemon(t, nil, stubClock{})
989- from := &session{id: "z-source"}
990- target := &session{id: "a-target"}
989+ from := &session{sessionCore: sessionCore{id: "z-source"}}
990+ target := &session{sessionCore: sessionCore{id: "a-target"}}
991991 moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
992992 displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size}
993993 moving.initOverlays()
@@ -1088,7 +1088,7 @@ func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *test
10881088 ac.roleGeneration.Store(2)
10891089 ac.output.next = 4
10901090 ac.output.acked = 1
1091- sess := &session{id: "work", client: ac}
1091+ sess := &session{sessionCore: sessionCore{id: "work", client: ac}}
10921092 ac.setSession(sess)
10931093 d.sessions[sess.id] = sess
10941094 rc := d.attachCoordinator(sess, nil, ac, true)
@@ -1109,8 +1109,8 @@ func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *test
11091109
11101110 func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) {
11111111 d := newTestDaemon(t, nil, stubClock{})
1112- from := &session{id: domain.SessionID("z-source")}
1113- target := &session{id: domain.SessionID("a-target")}
1112+ from := &session{sessionCore: sessionCore{id: domain.SessionID("z-source")}}
1113+ target := &session{sessionCore: sessionCore{id: domain.SessionID("a-target")}}
11141114 moving := &attachedClient{tr: &closeTrackingTransport{}}
11151115 displaced := &attachedClient{tr: &closeTrackingTransport{}}
11161116 moving.setSession(from)
@@ -21,7 +21,7 @@ func TestSessionAttachmentRole(t *testing.T) {
21 active := &attachedClient{}21 active := &attachedClient{}
22 waiting := &attachedClient{}22 waiting := &attachedClient{}
23 unknown := &attachedClient{}23 unknown := &attachedClient{}
24- sess := &session{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}24+ sess := &session{sessionCore: sessionCore{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}}
25 25
26 require.Equal(t, attachmentActive, sess.attachmentRole(active))26 require.Equal(t, attachmentActive, sess.attachmentRole(active))
27 require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))27 require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting))
@@ -30,7 +30,7 @@ func TestSessionAttachmentRole(t *testing.T) {
30 30
31 func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) {31 func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) {
32 d := newTestDaemon(t, nil, stubClock{})32 d := newTestDaemon(t, nil, stubClock{})
33- sess := &session{id: domain.SessionID("work")}33+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
34 oldTransport := &closeTrackingTransport{}34 oldTransport := &closeTrackingTransport{}
35 old := &attachedClient{tr: oldTransport}35 old := &attachedClient{tr: oldTransport}
36 old.setSession(sess)36 old.setSession(sess)
@@ -67,7 +67,7 @@ func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) {
67 67
68 func TestDeferredAttachmentCleanupKeepsReplacementOpenAndSendsResetPanel(t *testing.T) {68 func TestDeferredAttachmentCleanupKeepsReplacementOpenAndSendsResetPanel(t *testing.T) {
69 d := newTestDaemon(t, nil, stubClock{})69 d := newTestDaemon(t, nil, stubClock{})
70- sess := &session{id: domain.SessionID("work")}70+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
71 oldTransport := &closeTrackingTransport{}71 oldTransport := &closeTrackingTransport{}
72 old := &attachedClient{72 old := &attachedClient{
73 tr: oldTransport,73 tr: oldTransport,
@@ -283,7 +283,7 @@ func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) {
283 otherTransport := newDatagramTestTransport()283 otherTransport := newDatagramTestTransport()
284 otherClient := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: first.size}284 otherClient := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: first.size}
285 otherClient.initOverlays()285 otherClient.initOverlays()
286- other := &session{id: "other", name: "other", ctx: otherCtx, cancel: otherCancel, client: otherClient}286+ other := &session{sessionCore: sessionCore{id: "other", name: "other", client: otherClient}, ctx: otherCtx, cancel: otherCancel}
287 otherClient.setSession(other)287 otherClient.setSession(other)
288 d.mu.Lock()288 d.mu.Lock()
289 d.sessions[other.id] = other289 d.sessions[other.id] = other
@@ -754,7 +754,7 @@ func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) {
754 oldTransport := &failingSnatchedTransport{}754 oldTransport := &failingSnatchedTransport{}
755 old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}755 old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
756 old.initOverlays()756 old.initOverlays()
757- sess := &session{id: "work", client: old}757+ sess := &session{sessionCore: sessionCore{id: "work", client: old}}
758 old.setSession(sess)758 old.setSession(sess)
759 d.sessions[sess.id] = sess759 d.sessions[sess.id] = sess
760 760
@@ -823,7 +823,7 @@ func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) {
823 oldTransport := &failingSnatchedTransport{}823 oldTransport := &failingSnatchedTransport{}
824 old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}824 old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
825 old.initOverlays()825 old.initOverlays()
826- sess := &session{id: "work", client: old}826+ sess := &session{sessionCore: sessionCore{id: "work", client: old}}
827 old.setSession(sess)827 old.setSession(sess)
828 d.sessions[sess.id] = sess828 d.sessions[sess.id] = sess
829 829
@@ -864,7 +864,7 @@ func TestClearForSnatchReleasesEachOverlayFamilyBeforeTakingNext(t *testing.T) {
864 ac := &attachedClient{tr: tr, output: newOutputStateStream()}864 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
865 ac.initOverlays()865 ac.initOverlays()
866 ac.roleGeneration.Store(1)866 ac.roleGeneration.Store(1)
867- sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}867+ sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}}
868 ac.setSession(sess)868 ac.setSession(sess)
869 token := attachmentRoleToken{869 token := attachmentRoleToken{
870 sess: sess, ac: ac, role: attachmentSnatched,870 sess: sess, ac: ac, role: attachmentSnatched,
@@ -905,7 +905,7 @@ func TestClearForSnatchRejectsStaleGenerationWithoutClearingNewOverlay(t *testin
905 ac := &attachedClient{tr: tr, output: newOutputStateStream()}905 ac := &attachedClient{tr: tr, output: newOutputStateStream()}
906 ac.initOverlays()906 ac.initOverlays()
907 ac.roleGeneration.Store(1)907 ac.roleGeneration.Store(1)
908- sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}908+ sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}}
909 ac.setSession(sess)909 ac.setSession(sess)
910 token := attachmentRoleToken{910 token := attachmentRoleToken{
911 sess: sess, ac: ac, role: attachmentSnatched,911 sess: sess, ac: ac, role: attachmentSnatched,
@@ -960,7 +960,7 @@ func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) {
960 for _, tt := range tests {960 for _, tt := range tests {
961 t.Run(tt.name, func(t *testing.T) {961 t.Run(tt.name, func(t *testing.T) {
962 d := newTestDaemon(t, nil, stubClock{})962 d := newTestDaemon(t, nil, stubClock{})
963- sess := &session{id: domain.SessionID("work")}963+ sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}}
964 old := &attachedClient{tr: &closeTrackingTransport{}}964 old := &attachedClient{tr: &closeTrackingTransport{}}
965 old.setSession(sess)965 old.setSession(sess)
966 sess.client = old966 sess.client = old
@@ -986,8 +986,8 @@ func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) {
986 986
987 func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) {987 func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) {
988 d := newTestDaemon(t, nil, stubClock{})988 d := newTestDaemon(t, nil, stubClock{})
989- from := &session{id: "z-source"}989+ from := &session{sessionCore: sessionCore{id: "z-source"}}
990- target := &session{id: "a-target"}990+ target := &session{sessionCore: sessionCore{id: "a-target"}}
991 moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}991 moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
992 displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size}992 displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size}
993 moving.initOverlays()993 moving.initOverlays()
@@ -1088,7 +1088,7 @@ func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *test
1088 ac.roleGeneration.Store(2)1088 ac.roleGeneration.Store(2)
1089 ac.output.next = 41089 ac.output.next = 4
1090 ac.output.acked = 11090 ac.output.acked = 1
1091- sess := &session{id: "work", client: ac}1091+ sess := &session{sessionCore: sessionCore{id: "work", client: ac}}
1092 ac.setSession(sess)1092 ac.setSession(sess)
1093 d.sessions[sess.id] = sess1093 d.sessions[sess.id] = sess
1094 rc := d.attachCoordinator(sess, nil, ac, true)1094 rc := d.attachCoordinator(sess, nil, ac, true)
@@ -1109,8 +1109,8 @@ func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *test
1109 1109
1110 func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) {1110 func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) {
1111 d := newTestDaemon(t, nil, stubClock{})1111 d := newTestDaemon(t, nil, stubClock{})
1112- from := &session{id: domain.SessionID("z-source")}1112+ from := &session{sessionCore: sessionCore{id: domain.SessionID("z-source")}}
1113- target := &session{id: domain.SessionID("a-target")}1113+ target := &session{sessionCore: sessionCore{id: domain.SessionID("a-target")}}
1114 moving := &attachedClient{tr: &closeTrackingTransport{}}1114 moving := &attachedClient{tr: &closeTrackingTransport{}}
1115 displaced := &attachedClient{tr: &closeTrackingTransport{}}1115 displaced := &attachedClient{tr: &closeTrackingTransport{}}
1116 moving.setSession(from)1116 moving.setSession(from)
M internal/usecase/daemon/snatched_input_test.go +1 -3
@@ -139,9 +139,7 @@ func newSnatchedInputFixture(t *testing.T, clock ports.Clock) (*Daemon, *session
139139 }
140140 waiting.initOverlays()
141141 active.initOverlays()
142- sess := &session{
143- id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}},
144- }
142+ sess := &session{sessionCore: sessionCore{id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}}
145143 waiting.setSession(sess)
146144 active.setSession(sess)
147145 d.sessions[sess.id] = sess
@@ -139,9 +139,7 @@ func newSnatchedInputFixture(t *testing.T, clock ports.Clock) (*Daemon, *session
139 }139 }
140 waiting.initOverlays()140 waiting.initOverlays()
141 active.initOverlays()141 active.initOverlays()
142- sess := &session{142+ sess := &session{sessionCore: sessionCore{id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}}
143- id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}},
144- }
145 waiting.setSession(sess)143 waiting.setSession(sess)
146 active.setSession(sess)144 active.setSession(sess)
147 d.sessions[sess.id] = sess145 d.sessions[sess.id] = sess
M internal/usecase/daemon/snatched_reclaim_test.go +2 -3
@@ -42,9 +42,8 @@ func newAtomicReclaimFixture(t *testing.T, clock ports.Clock, waitingTransport,
4242 active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
4343 waiting.initOverlays()
4444 active.initOverlays()
45- sess := &session{
46- id: "atomic-reclaim", client: active,
47- snatched: map[*attachedClient]struct{}{waiting: {}},
45+ sess := &session{sessionCore: sessionCore{id: "atomic-reclaim", client: active,
46+ snatched: map[*attachedClient]struct{}{waiting: {}}},
4847 }
4948 waiting.setSession(sess)
5049 active.setSession(sess)
@@ -42,9 +42,8 @@ func newAtomicReclaimFixture(t *testing.T, clock ports.Clock, waitingTransport,
42 active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}42 active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size}
43 waiting.initOverlays()43 waiting.initOverlays()
44 active.initOverlays()44 active.initOverlays()
45- sess := &session{45+ sess := &session{sessionCore: sessionCore{id: "atomic-reclaim", client: active,
46- id: "atomic-reclaim", client: active,46+ snatched: map[*attachedClient]struct{}{waiting: {}}},
47- snatched: map[*attachedClient]struct{}{waiting: {}},
48 }47 }
49 waiting.setSession(sess)48 waiting.setSession(sess)
50 active.setSession(sess)49 active.setSession(sess)
M internal/usecase/daemon/status_test.go +6 -6
@@ -966,7 +966,7 @@ func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.
966966 p, releasePTY := newBlockingPTY(t)
967967 d, sess, _, _ := newManualSessionWithPTYs(t, p)
968968 defer releasePTY()
969- live := &session{id: "live", name: "live", tabs: []*tab{{}}}
969+ live := &session{sessionCore: sessionCore{id: "live", name: "live"}, tabs: []*tab{{}}}
970970 live.mruAt.Store(1)
971971 d.sessions[live.id] = live
972972
@@ -1007,7 +1007,7 @@ func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T)
10071007 sess.name = "current"
10081008 sess.mruAt.Store(100)
10091009 for i := range 10 {
1010- other := &session{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i), tabs: []*tab{{attention: i == 8}}}
1010+ other := &session{sessionCore: sessionCore{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i)}, tabs: []*tab{{attention: i == 8}}}
10111011 other.mruAt.Store(uint64(i + 1))
10121012 d.sessions[other.id] = other
10131013 }
@@ -1028,9 +1028,9 @@ func TestBarStateForMRUExcludesEphemeralSessions(t *testing.T) {
10281028 d, current, _, _ := newManualSessionWithPTYs(t, p)
10291029 defer releasePTY()
10301030
1031- persistent := &session{id: "named", name: "named", tabs: []*tab{{}}}
1031+ persistent := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
10321032 persistent.mruAt.Store(1)
1033- ephemeral := &session{id: "ephemeral", name: "1", ephemeral: true, tabs: []*tab{{}}}
1033+ ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
10341034 ephemeral.mruAt.Store(2)
10351035 d.sessions[persistent.id] = persistent
10361036 d.sessions[ephemeral.id] = ephemeral
@@ -1046,7 +1046,7 @@ func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) {
10461046 d, sess, _, _ := newManualSessionWithPTYs(t, p)
10471047 defer releasePTY()
10481048 for _, name := range []string{"bravo", "alpha", "charlie"} {
1049- d.sessions[domain.SessionID(name)] = &session{id: domain.SessionID(name), name: name, tabs: []*tab{{}}}
1049+ d.sessions[domain.SessionID(name)] = &session{sessionCore: sessionCore{id: domain.SessionID(name), name: name}, tabs: []*tab{{}}}
10501050 }
10511051
10521052 state := d.barStateFor(sess, "")
@@ -1065,7 +1065,7 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
10651065 d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30}
10661066 d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20}
10671067 d.mruSeq.Store(30)
1068- cur := &session{id: "cur", name: "current", tabs: []*tab{{}}}
1068+ cur := &session{sessionCore: sessionCore{id: "cur", name: "current"}, tabs: []*tab{{}}}
10691069 d.sessions[cur.id] = cur
10701070
10711071 _, err := createSessionForTest(d, "zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv)
@@ -966,7 +966,7 @@ func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.
966 p, releasePTY := newBlockingPTY(t)966 p, releasePTY := newBlockingPTY(t)
967 d, sess, _, _ := newManualSessionWithPTYs(t, p)967 d, sess, _, _ := newManualSessionWithPTYs(t, p)
968 defer releasePTY()968 defer releasePTY()
969- live := &session{id: "live", name: "live", tabs: []*tab{{}}}969+ live := &session{sessionCore: sessionCore{id: "live", name: "live"}, tabs: []*tab{{}}}
970 live.mruAt.Store(1)970 live.mruAt.Store(1)
971 d.sessions[live.id] = live971 d.sessions[live.id] = live
972 972
@@ -1007,7 +1007,7 @@ func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T)
1007 sess.name = "current"1007 sess.name = "current"
1008 sess.mruAt.Store(100)1008 sess.mruAt.Store(100)
1009 for i := range 10 {1009 for i := range 10 {
1010- other := &session{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i), tabs: []*tab{{attention: i == 8}}}1010+ other := &session{sessionCore: sessionCore{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i)}, tabs: []*tab{{attention: i == 8}}}
1011 other.mruAt.Store(uint64(i + 1))1011 other.mruAt.Store(uint64(i + 1))
1012 d.sessions[other.id] = other1012 d.sessions[other.id] = other
1013 }1013 }
@@ -1028,9 +1028,9 @@ func TestBarStateForMRUExcludesEphemeralSessions(t *testing.T) {
1028 d, current, _, _ := newManualSessionWithPTYs(t, p)1028 d, current, _, _ := newManualSessionWithPTYs(t, p)
1029 defer releasePTY()1029 defer releasePTY()
1030 1030
1031- persistent := &session{id: "named", name: "named", tabs: []*tab{{}}}1031+ persistent := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}}
1032 persistent.mruAt.Store(1)1032 persistent.mruAt.Store(1)
1033- ephemeral := &session{id: "ephemeral", name: "1", ephemeral: true, tabs: []*tab{{}}}1033+ ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}}
1034 ephemeral.mruAt.Store(2)1034 ephemeral.mruAt.Store(2)
1035 d.sessions[persistent.id] = persistent1035 d.sessions[persistent.id] = persistent
1036 d.sessions[ephemeral.id] = ephemeral1036 d.sessions[ephemeral.id] = ephemeral
@@ -1046,7 +1046,7 @@ func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) {
1046 d, sess, _, _ := newManualSessionWithPTYs(t, p)1046 d, sess, _, _ := newManualSessionWithPTYs(t, p)
1047 defer releasePTY()1047 defer releasePTY()
1048 for _, name := range []string{"bravo", "alpha", "charlie"} {1048 for _, name := range []string{"bravo", "alpha", "charlie"} {
1049- d.sessions[domain.SessionID(name)] = &session{id: domain.SessionID(name), name: name, tabs: []*tab{{}}}1049+ d.sessions[domain.SessionID(name)] = &session{sessionCore: sessionCore{id: domain.SessionID(name), name: name}, tabs: []*tab{{}}}
1050 }1050 }
1051 1051
1052 state := d.barStateFor(sess, "")1052 state := d.barStateFor(sess, "")
@@ -1065,7 +1065,7 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) {
1065 d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30}1065 d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30}
1066 d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20}1066 d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20}
1067 d.mruSeq.Store(30)1067 d.mruSeq.Store(30)
1068- cur := &session{id: "cur", name: "current", tabs: []*tab{{}}}1068+ cur := &session{sessionCore: sessionCore{id: "cur", name: "current"}, tabs: []*tab{{}}}
1069 d.sessions[cur.id] = cur1069 d.sessions[cur.id] = cur
1070 1070
1071 _, err := createSessionForTest(d, "zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv)1071 _, err := createSessionForTest(d, "zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv)
M internal/usecase/daemon/transactional_resize_test.go +1 -1
@@ -521,7 +521,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
521521 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
522522 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
523523
524- destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
524+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}}
525525 publishPaneOwner(popup, destination, tb, 7)
526526 destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9}
527527 popup.mu.Lock()
@@ -521,7 +521,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
521 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)521 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
522 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))522 require.True(t, d.runResizeTransaction(source, ac, lease, epoch))
523 523
524- destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}524+ destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}}
525 publishPaneOwner(popup, destination, tb, 7)525 publishPaneOwner(popup, destination, tb, 7)
526 destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9}526 destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9}
527 popup.mu.Lock()527 popup.mu.Lock()

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