detached 481ac41

refactor(daemon): replace attachment roles with connection fences

commit
481ac411d1daccdb7a79514545b8719b3bb082fa
author
brice · · 2026-08-03 01:58 UTC
committer
brice · · 2026-08-03 20:53 UTC
parent
30c3dce
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
62 files · +1232 -1501
A internal/usecase/daemon/attachment_connection.go +164 -0
@@ -0,0 +1,164 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+// attachmentSessionCore normalizes nil interfaces and implementations that
6+// deliberately expose no attachment core.
7+func attachmentSessionCore(entry attachmentSession) *sessionCore {
8+ if entry == nil {
9+ return nil
10+ }
11+ return entry.core()
12+}
13+
14+// attachmentConnectionToken is the exact capability for one registered
15+// attachment connection incarnation. It is invalid as soon as any of the
16+// session, attachment membership, connection generation, transport link, or
17+// coordinator lease changes.
18+type attachmentConnectionToken struct {
19+ sess attachmentSession
20+ ac *attachedClient
21+ generation uint64
22+ transport transportSnapshot
23+ lease *attachmentLease
24+ effect *attachmentEffectTicket
25+ // rebase marks a cross-session move whose first paint starts a dependency-
26+ // free output chain after publication releases architecture locks.
27+ rebase bool
28+}
29+
30+// attachmentTokenLocked captures the exact registered attachment, transport
31+// incarnation, generation, and coordinator lease. Caller holds entry.core().mu.
32+func attachmentTokenLocked(entry attachmentSession, ac *attachedClient) attachmentConnectionToken {
33+ if entry == nil || entry.core() == nil || ac == nil || !attachmentRegisteredLocked(entry, ac) {
34+ return attachmentConnectionToken{}
35+ }
36+ transport := ac.transportSnapshot()
37+ if transport.transport == nil {
38+ return attachmentConnectionToken{}
39+ }
40+ token := attachmentConnectionToken{
41+ sess: entry,
42+ ac: ac,
43+ generation: ac.connectionGeneration.Load(),
44+ transport: transport,
45+ }
46+ if rc := entry.core().coordinator.Load(); rc != nil {
47+ token.lease = rc.attachmentLease(ac)
48+ }
49+ return token
50+}
51+
52+func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentConnectionToken {
53+ return attachmentTokenLocked(s, ac)
54+}
55+
56+func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
57+ if entry == nil || entry.core() == nil || ac == nil || tr == nil {
58+ return attachmentConnectionToken{}
59+ }
60+ core := entry.core()
61+ core.mu.Lock()
62+ transport := ac.transportSnapshot()
63+ if transport.transport != tr {
64+ core.mu.Unlock()
65+ return attachmentConnectionToken{}
66+ }
67+ token := attachmentTokenLocked(entry, ac)
68+ core.mu.Unlock()
69+ if token.ac == nil {
70+ return attachmentConnectionToken{}
71+ }
72+ ac.bootstrapAttachmentCapability(token)
73+ return token
74+}
75+
76+func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
77+ return attachmentToken(s, ac, tr)
78+}
79+
80+// current validates every immutable identity captured by the token. Membership
81+// is the sole authority; there is no owner, replacement, or compatibility role.
82+func (t attachmentConnectionToken) current() bool {
83+ return t.sess != nil && t.ac != nil &&
84+ t.ac.connectionGeneration.Load() == t.generation &&
85+ t.ac.currentAttachmentSession() == t.sess &&
86+ t.ac.transportSnapshotCurrent(t.transport) &&
87+ attachmentRegistered(t.sess, t.ac)
88+}
89+
90+// attachmentCurrent additionally validates the optional coordinator lease used
91+// by render and resize effects. Attachments without that optional lease still
92+// own their independent connection lifecycle.
93+func (t attachmentConnectionToken) attachmentCurrent() bool {
94+ if !t.current() || t.ac.currentAttachmentSession() != t.sess {
95+ return false
96+ }
97+ if t.lease == nil {
98+ return true
99+ }
100+ rc := t.sess.core().coordinator.Load()
101+ return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
102+}
103+
104+func (t attachmentConnectionToken) attachmentEffectCurrent() bool {
105+ if t.effect != nil && !t.effect.ended.Load() {
106+ return true
107+ }
108+ return t.attachmentCurrent()
109+}
110+
111+func beginAttachmentLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {
112+ if sess == nil || ac == nil || lease == nil {
113+ return nil, false
114+ }
115+ token := attachmentToken(sess, ac, ac.transport())
116+ token.lease = lease
117+ return ac.beginAttachmentEffect(token)
118+}
119+
120+func (t *attachmentConnectionToken) endAttachmentEffect() {
121+ if t == nil || t.effect == nil {
122+ return
123+ }
124+ t.effect.End()
125+ t.effect = nil
126+}
127+
128+// attachmentEffectCurrentSessionLocked is the same check at a session-owned
129+// mutation boundary where sess.core().mu is already held.
130+func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {
131+ if t.effect != nil && !t.effect.ended.Load() {
132+ return true
133+ }
134+ if t.sess == nil || t.ac == nil ||
135+ t.ac.connectionGeneration.Load() != t.generation ||
136+ !t.ac.transportSnapshotCurrent(t.transport) ||
137+ t.ac.currentAttachmentSession() != t.sess ||
138+ !attachmentRegisteredLocked(t.sess, t.ac) {
139+ return false
140+ }
141+ if t.lease == nil {
142+ return true
143+ }
144+ rc := t.sess.core().coordinator.Load()
145+ return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
146+}
147+
148+func (t attachmentConnectionToken) sendControl(frame ports.Frame) error {
149+ if t.ac == nil || t.transport.transport == nil {
150+ return errAttachmentTransition
151+ }
152+ t.ac.sendMu.Lock()
153+ defer t.ac.sendMu.Unlock()
154+ if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) ||
155+ !t.effect.beginTransportSend(t.transport) {
156+ return errAttachmentTransition
157+ }
158+ err := t.transport.transport.Send(frame)
159+ if err != nil {
160+ t.effect.reportTransportFailure(t.transport)
161+ }
162+ t.effect.endTransportSend()
163+ return err
164+}
R internal/usecase/daemon/role_effect_freeze.go → internal/usecase/daemon/attachment_effect_freeze.go +51 -60 renamed, 55% similar
@@ -8,7 +8,7 @@ import (
88 "github.com/bnema/vev/internal/ports"
99 )
1010
11-type roleEffectDrainDeadline struct {
11+type attachmentEffectDrainDeadline struct {
1212 clock ports.Clock
1313 done chan struct{}
1414 stopCh chan struct{}
@@ -18,13 +18,13 @@ type roleEffectDrainDeadline struct {
1818 started atomic.Bool
1919 }
2020
21-func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline {
22- return &roleEffectDrainDeadline{
21+func newAttachmentEffectDrainDeadline(clock ports.Clock) *attachmentEffectDrainDeadline {
22+ return &attachmentEffectDrainDeadline{
2323 clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}),
2424 }
2525 }
2626
27-func (d *roleEffectDrainDeadline) stop() {
27+func (d *attachmentEffectDrainDeadline) stop() {
2828 if d == nil {
2929 return
3030 }
@@ -40,7 +40,7 @@ func (d *roleEffectDrainDeadline) stop() {
4040 })
4141 }
4242
43-func (d *roleEffectDrainDeadline) Done() <-chan struct{} {
43+func (d *attachmentEffectDrainDeadline) Done() <-chan struct{} {
4444 if d == nil {
4545 return nil
4646 }
@@ -60,71 +60,62 @@ func (d *roleEffectDrainDeadline) Done() <-chan struct{} {
6060 return d.done
6161 }
6262
63-type frozenRoleEffectGates struct {
63+type frozenAttachmentEffectGates struct {
6464 clients []*attachedClient
6565 interruptions map[*attachedClient]transportSnapshot
6666 acquired bool
6767 drained bool
6868 }
6969
70-func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {
70+func (f frozenAttachmentEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {
7171 interrupted, ok := f.interruptions[ac]
7272 return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation
7373 }
7474
75-func (f frozenRoleEffectGates) contains(target *attachedClient) bool {
76- for _, ac := range f.clients {
77- if ac == target {
78- return true
79- }
80- }
81- return false
82-}
83-
84-type roleTransportInterrupt struct {
75+type attachmentTransportInterrupt struct {
8576 ac *attachedClient
8677 transport transportSnapshot
8778 }
8879
89-// roleEffectFreezeOptions controls one role-gate freeze operation. A freeze
80+// attachmentEffectFreezeOptions controls one role-gate freeze operation. A freeze
9081 // must be called without daemon, routing, session, coordinator, send, overlay,
9182 // router, or pane locks held.
92-type roleEffectFreezeOptions struct {
93- interrupts []roleTransportInterrupt
83+type attachmentEffectFreezeOptions struct {
84+ interrupts []attachmentTransportInterrupt
9485 done func() <-chan struct{}
9586 afterFrozen func(*attachedClient)
9687 nonblocking bool
9788 }
9889
99-// freezeRoleEffectGates establishes transition priority in immutable attachment
90+// freezeAttachmentEffectGates establishes transition priority in immutable attachment
10091 // order, then drains earlier admissions.
101-func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {
102- return freezeRoleEffectGatesWith(roleEffectFreezeOptions{}, clients...)
92+func freezeAttachmentEffectGates(clients ...*attachedClient) frozenAttachmentEffectGates {
93+ return freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{}, clients...)
10394 }
10495
105-// freezeRoleEffectGatesWith uses done as one overall bound for ordered
96+// freezeAttachmentEffectGatesWith uses done as one overall bound for ordered
10697 // acquisition and drain. Acquisition is all-or-nothing: if the bound expires
10798 // behind another owner, only gates acquired by this call are rolled back, in
10899 // reverse order. nonblocking is used by move reservations to avoid inverting
109100 // the move/teardown protocols.
110-func freezeRoleEffectGatesWith(options roleEffectFreezeOptions, clients ...*attachedClient) frozenRoleEffectGates {
111- ordered := orderedRoleEffectClients(clients)
112- frozen, acquired := acquireRoleEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)
101+func freezeAttachmentEffectGatesWith(options attachmentEffectFreezeOptions, clients ...*attachedClient) frozenAttachmentEffectGates {
102+ ordered := orderedAttachmentEffectClients(clients)
103+ frozen, acquired := acquireAttachmentEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)
113104 if !acquired {
114- return frozenRoleEffectGates{}
105+ return frozenAttachmentEffectGates{}
115106 }
116107
117- interruptByClient := roleTransportInterruptsByClient(options.interrupts)
108+ interruptByClient := attachmentTransportInterruptsByClient(options.interrupts)
118109 frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient))
119- interruptFrozenRoleTransports(ordered, interruptByClient)
110+ interruptFrozenAttachmentTransports(ordered, interruptByClient)
120111 frozen.acquired = true
121- frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, options.done)
112+ frozen.drained = drainFrozenAttachmentEffects(ordered, interruptByClient, frozen.interruptions, options.done)
122113 return frozen
123114 }
124115
125-// orderedRoleEffectClients snapshots unique participants in immutable lock
116+// orderedAttachmentEffectClients snapshots unique participants in immutable lock
126117 // order. Assigning a previously unused order does not freeze or wait on a gate.
127-func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient {
118+func orderedAttachmentEffectClients(clients []*attachedClient) []*attachedClient {
128119 unique := make(map[*attachedClient]struct{}, len(clients))
129120 ordered := make([]*attachedClient, 0, len(clients))
130121 for _, ac := range clients {
@@ -135,18 +126,18 @@ func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient {
135126 continue
136127 }
137128 unique[ac] = struct{}{}
138- ac.roleEffects.immutableOrder()
129+ ac.attachmentEffects.immutableOrder()
139130 ordered = append(ordered, ac)
140131 }
141132 sort.Slice(ordered, func(i, j int) bool {
142- return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load()
133+ return ordered[i].attachmentEffects.order.Load() < ordered[j].attachmentEffects.order.Load()
143134 })
144135 return ordered
145136 }
146137
147-// waitForRoleEffectChangeLocked requires g.mu and returns with g.mu held. A
138+// waitForAttachmentEffectChangeLocked requires g.mu and returns with g.mu held. A
148139 // true result means the overall deadline won and the caller must stop waiting.
149-func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{}) bool {
140+func waitForAttachmentEffectChangeLocked(g *attachmentEffectGate, done func() <-chan struct{}) bool {
150141 if done == nil {
151142 g.cond.Wait()
152143 return false
@@ -167,28 +158,28 @@ func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{
167158 }
168159 }
169160
170-// acquireRoleEffectGates establishes exclusive transition ownership in order.
161+// acquireAttachmentEffectGates establishes exclusive transition ownership in order.
171162 // It must run without architecture locks. Deadline rollback releases only this
172163 // call's partial acquisition and does so in reverse order.
173-func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (frozenRoleEffectGates, bool) {
174- frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))}
164+func acquireAttachmentEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (frozenAttachmentEffectGates, bool) {
165+ frozen := frozenAttachmentEffectGates{clients: make([]*attachedClient, 0, len(ordered))}
175166 for _, ac := range ordered {
176- g := &ac.roleEffects
167+ g := &ac.attachmentEffects
177168 g.mu.Lock()
178169 g.initLocked()
179- for g.phase == roleEffectsFrozen {
170+ for g.phase == attachmentEffectsFrozen {
180171 if nonblocking {
181172 g.mu.Unlock()
182173 frozen.unfreeze()
183- return frozenRoleEffectGates{}, false
174+ return frozenAttachmentEffectGates{}, false
184175 }
185- if waitForRoleEffectChangeLocked(g, done) {
176+ if waitForAttachmentEffectChangeLocked(g, done) {
186177 g.mu.Unlock()
187178 frozen.unfreeze()
188- return frozenRoleEffectGates{}, false
179+ return frozenAttachmentEffectGates{}, false
189180 }
190181 }
191- g.phase = roleEffectsFrozen
182+ g.phase = attachmentEffectsFrozen
192183 g.mu.Unlock()
193184 frozen.clients = append(frozen.clients, ac)
194185 if afterFrozen != nil {
@@ -198,7 +189,7 @@ func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct
198189 return frozen, true
199190 }
200191
201-func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*attachedClient]transportSnapshot {
192+func attachmentTransportInterruptsByClient(interrupts []attachmentTransportInterrupt) map[*attachedClient]transportSnapshot {
202193 byClient := make(map[*attachedClient]transportSnapshot, len(interrupts))
203194 for _, interrupt := range interrupts {
204195 if interrupt.ac != nil && interrupt.transport.transport != nil {
@@ -208,20 +199,20 @@ func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*a
208199 return byClient
209200 }
210201
211-// interruptFrozenRoleTransports snapshots every exact in-flight link while all
202+// interruptFrozenAttachmentTransports snapshots every exact in-flight link while all
212203 // gates are frozen, then closes the complete set before any drain wait. One
213204 // uncooperative Send cannot prevent another participant from being interrupted.
214-func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) {
215- toClose := make([]roleTransportInterrupt, 0, len(interruptByClient))
205+func interruptFrozenAttachmentTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) {
206+ toClose := make([]attachmentTransportInterrupt, 0, len(interruptByClient))
216207 for _, ac := range ordered {
217208 expected, mayInterrupt := interruptByClient[ac]
218209 if !mayInterrupt {
219210 continue
220211 }
221- g := &ac.roleEffects
212+ g := &ac.attachmentEffects
222213 g.mu.Lock()
223214 if gateHasTransportEffectLocked(g, expected) {
224- toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected})
215+ toClose = append(toClose, attachmentTransportInterrupt{ac: ac, transport: expected})
225216 }
226217 g.mu.Unlock()
227218 }
@@ -230,15 +221,15 @@ func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient
230221 }
231222 }
232223
233-// drainFrozenRoleEffects waits for admissions made before the freeze. A closed
224+// drainFrozenAttachmentEffects waits for admissions made before the freeze. A closed
234225 // deadline permits terminal teardown to continue with the capability frozen;
235226 // late tickets can then retire without pinning the owner indefinitely.
236-func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool {
227+func drainFrozenAttachmentEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool {
237228 for _, ac := range ordered {
238- g := &ac.roleEffects
229+ g := &ac.attachmentEffects
239230 g.mu.Lock()
240231 for g.inFlight != 0 {
241- if waitForRoleEffectChangeLocked(g, done) {
232+ if waitForAttachmentEffectChangeLocked(g, done) {
242233 g.mu.Unlock()
243234 return false
244235 }
@@ -252,7 +243,7 @@ func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interr
252243 return true
253244 }
254245
255-func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool {
246+func gateHasTransportEffectLocked(g *attachmentEffectGate, expected transportSnapshot) bool {
256247 for _, active := range g.transportEffects {
257248 if active.transport == expected.transport && active.incarnation == expected.incarnation {
258249 return true
@@ -261,12 +252,12 @@ func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot)
261252 return false
262253 }
263254
264-func (f frozenRoleEffectGates) unfreeze() {
255+func (f frozenAttachmentEffectGates) unfreeze() {
265256 for i := len(f.clients) - 1; i >= 0; i-- {
266- g := &f.clients[i].roleEffects
257+ g := &f.clients[i].attachmentEffects
267258 g.mu.Lock()
268259 g.initLocked()
269- g.phase = roleEffectsStable
260+ g.phase = attachmentEffectsStable
270261 g.signalLocked()
271262 g.mu.Unlock()
272263 }
R internal/usecase/daemon/role_effect_gate.go → internal/usecase/daemon/attachment_effect_gate.go +71 -74 renamed, 50% similar
@@ -7,55 +7,53 @@ import (
77 "github.com/bnema/vev/internal/ports"
88 )
99
10-type roleEffectPhase uint8
10+type attachmentEffectPhase uint8
1111
1212 const (
13- roleEffectsStable roleEffectPhase = iota
14- roleEffectsFrozen
13+ attachmentEffectsStable attachmentEffectPhase = iota
14+ attachmentEffectsFrozen
1515 )
1616
17-// roleCapability is the single attachment-local publication consumed by role
17+// attachmentCapability is the single attachment-local publication consumed by
1818 // effect admission. It deliberately contains no independently mutable state:
1919 // a ticket is admitted only when every field matches the frame's token.
20-type roleCapability struct {
20+type attachmentCapability struct {
2121 sess attachmentSession
22- role attachmentRole
2322 generation uint64
2423 transport transportSnapshot
2524 lease *attachmentLease
2625 }
2726
28-func capabilityFromToken(token attachmentRoleToken) roleCapability {
29- return roleCapability{
27+func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
28+ return attachmentCapability{
3029 sess: token.sess,
31- role: token.role,
3230 generation: token.generation,
3331 transport: token.transport,
3432 lease: token.lease,
3533 }
3634 }
3735
38-func (c roleCapability) matches(token attachmentRoleToken) bool {
39- return token.ac != nil && c.sess == token.sess && c.role == token.role &&
36+func (c attachmentCapability) matches(token attachmentConnectionToken) bool {
37+ return token.ac != nil && c.sess == token.sess &&
4038 c.generation == token.generation && c.transport.transport == token.transport.transport &&
4139 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
4240 }
4341
44-type roleEffectGate struct {
42+type attachmentEffectGate struct {
4543 mu sync.Mutex
4644 cond *sync.Cond
4745 changed chan struct{}
48- phase roleEffectPhase
49- capability roleCapability
46+ phase attachmentEffectPhase
47+ capability attachmentCapability
5048 inFlight uint64
51- transportEffects map[*roleEffectTicket]transportSnapshot
49+ transportEffects map[*attachmentEffectTicket]transportSnapshot
5250 failedTransport transportSnapshot
5351 order atomic.Uint64
5452 }
5553
56-var nextRoleEffectGateOrder atomic.Uint64
54+var nextAttachmentEffectGateOrder atomic.Uint64
5755
58-func (g *roleEffectGate) initLocked() {
56+func (g *attachmentEffectGate) initLocked() {
5957 if g.cond == nil {
6058 g.cond = sync.NewCond(&g.mu)
6159 }
@@ -64,25 +62,25 @@ func (g *roleEffectGate) initLocked() {
6462 }
6563 }
6664
67-func (g *roleEffectGate) signalLocked() {
65+func (g *attachmentEffectGate) signalLocked() {
6866 g.initLocked()
6967 close(g.changed)
7068 g.changed = make(chan struct{})
7169 g.cond.Broadcast()
7270 }
7371
74-func (g *roleEffectGate) immutableOrder() uint64 {
72+func (g *attachmentEffectGate) immutableOrder() uint64 {
7573 if order := g.order.Load(); order != 0 {
7674 return order
7775 }
78- order := nextRoleEffectGateOrder.Add(1)
76+ order := nextAttachmentEffectGateOrder.Add(1)
7977 if g.order.CompareAndSwap(0, order) {
8078 return order
8179 }
8280 return g.order.Load()
8381 }
8482
85-func (g *roleEffectGate) inFlightCount() uint64 {
83+func (g *attachmentEffectGate) inFlightCount() uint64 {
8684 if g == nil {
8785 return 0
8886 }
@@ -91,17 +89,17 @@ func (g *roleEffectGate) inFlightCount() uint64 {
9189 return g.inFlight
9290 }
9391
94-// roleEffectTicket reserves the exact published capability until End. End is
92+// attachmentEffectTicket reserves the exact published capability until End. End is
9593 // idempotent so error paths can safely defer it before doing any observable I/O.
96-type roleEffectTicket struct {
97- gate *roleEffectGate
98- token attachmentRoleToken
94+type attachmentEffectTicket struct {
95+ gate *attachmentEffectGate
96+ token attachmentConnectionToken
9997 actionDaemon *Daemon
10098 action string
10199 ended atomic.Bool
102100 }
103101
104-func (t *roleEffectTicket) bindActionEnd(d *Daemon, action string) {
102+func (t *attachmentEffectTicket) bindActionEnd(d *Daemon, action string) {
105103 if t == nil || t.ended.Load() {
106104 return
107105 }
@@ -109,16 +107,16 @@ func (t *roleEffectTicket) bindActionEnd(d *Daemon, action string) {
109107 t.action = action
110108 }
111109
112-func (t *roleEffectTicket) roleToken() attachmentRoleToken {
110+func (t *attachmentEffectTicket) connectionToken() attachmentConnectionToken {
113111 if t == nil {
114- return attachmentRoleToken{}
112+ return attachmentConnectionToken{}
115113 }
116114 token := t.token
117115 token.effect = t
118116 return token
119117 }
120118
121-func (t *roleEffectTicket) End() {
119+func (t *attachmentEffectTicket) End() {
122120 if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) {
123121 return
124122 }
@@ -127,18 +125,18 @@ func (t *roleEffectTicket) End() {
127125 g.initLocked()
128126 if g.inFlight == 0 {
129127 g.mu.Unlock()
130- panic("daemon: role effect ticket ended without admission")
128+ panic("daemon: attachment effect ticket ended without admission")
131129 }
132130 delete(g.transportEffects, t)
133131 g.inFlight--
134132 g.signalLocked()
135133 g.mu.Unlock()
136- if t.actionDaemon != nil && t.actionDaemon.afterActionRoleEffectEnded != nil {
137- t.actionDaemon.afterActionRoleEffectEnded(t.action)
134+ if t.actionDaemon != nil && t.actionDaemon.afterActionAttachmentEffectEnded != nil {
135+ t.actionDaemon.afterActionAttachmentEffectEnded(t.action)
138136 }
139137 }
140138
141-func (d *Daemon) endActionRoleEffect(effect *roleEffectTicket, _ string) {
139+func (d *Daemon) endActionAttachmentEffect(effect *attachmentEffectTicket, _ string) {
142140 if effect != nil {
143141 effect.End()
144142 }
@@ -146,8 +144,8 @@ func (d *Daemon) endActionRoleEffect(effect *roleEffectTicket, _ string) {
146144
147145 // beginTransportSend marks only the interval spent in an external transport
148146 // send as interruptible. A replacement may close this exact published link to
149-// release the send, but cannot retire an idle healthy snatched connection.
150-func (t *roleEffectTicket) beginTransportSend(expected transportSnapshot) bool {
147+// release the send without retiring an idle healthy attachment.
148+func (t *attachmentEffectTicket) beginTransportSend(expected transportSnapshot) bool {
151149 if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil {
152150 return false
153151 }
@@ -160,7 +158,7 @@ func (t *roleEffectTicket) beginTransportSend(expected transportSnapshot) bool {
160158 return false
161159 }
162160 if g.transportEffects == nil {
163- g.transportEffects = make(map[*roleEffectTicket]transportSnapshot)
161+ g.transportEffects = make(map[*attachmentEffectTicket]transportSnapshot)
164162 }
165163 g.transportEffects[t] = expected
166164 g.signalLocked()
@@ -168,7 +166,7 @@ func (t *roleEffectTicket) beginTransportSend(expected transportSnapshot) bool {
168166 return true
169167 }
170168
171-func (t *roleEffectTicket) reportTransportFailure(expected transportSnapshot) {
169+func (t *attachmentEffectTicket) reportTransportFailure(expected transportSnapshot) {
172170 if t == nil || t.gate == nil || expected.transport == nil {
173171 return
174172 }
@@ -182,7 +180,7 @@ func (t *roleEffectTicket) reportTransportFailure(expected transportSnapshot) {
182180 g.mu.Unlock()
183181 }
184182
185-func (t *roleEffectTicket) endTransportSend() {
183+func (t *attachmentEffectTicket) endTransportSend() {
186184 if t == nil || t.gate == nil {
187185 return
188186 }
@@ -195,17 +193,17 @@ func (t *roleEffectTicket) endTransportSend() {
195193 g.mu.Unlock()
196194 }
197195
198-// beginRoleEffect is the sole role-bound effect admission point. The gate is
196+// beginAttachmentEffect is the sole attachment-bound effect admission point. The gate is
199197 // held only long enough to reserve the capability; the effect itself must run
200198 // without the gate mutex.
201-func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) {
199+func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken) (*attachmentEffectTicket, bool) {
202200 if ac == nil || token.ac != ac {
203201 return nil, false
204202 }
205- g := &ac.roleEffects
203+ g := &ac.attachmentEffects
206204 g.mu.Lock()
207205 g.initLocked()
208- if g.phase != roleEffectsStable || !g.capability.matches(token) {
206+ if g.phase != attachmentEffectsStable || !g.capability.matches(token) {
209207 g.mu.Unlock()
210208 return nil, false
211209 }
@@ -213,30 +211,30 @@ func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffec
213211 g.mu.Unlock()
214212 captured := token
215213 captured.effect = nil
216- return &roleEffectTicket{gate: g, token: captured}, true
214+ return &attachmentEffectTicket{gate: g, token: captured}, true
217215 }
218216
219-// beginCurrentRoleEffect waits out a transition that already froze ac, then
220-// admits the role capability derived from the session registries. Handshakes
221-// use it after Welcome has completed so a replacement blocked behind that send
222-// can publish before readiness, first paint, or the snatched reset panel.
223-func (ac *attachedClient) beginCurrentRoleEffect(sess *session, tr ports.Transport) (attachmentRoleToken, *roleEffectTicket, bool) {
217+// beginCurrentAttachmentEffect waits out a transition that already froze ac, then
218+// admits the capability derived from the session registry. Handshakes use it
219+// after Welcome has completed so a replacement blocked behind that send can
220+// publish before readiness or first paint.
221+func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
224222 if ac == nil || sess == nil || tr == nil {
225- return attachmentRoleToken{}, nil, false
223+ return attachmentConnectionToken{}, nil, false
226224 }
227225 for {
228226 token := sess.attachmentToken(ac, tr)
229- if token.role == attachmentDetached {
227+ if token.ac == nil {
230228 return token, nil, false
231229 }
232- if ticket, admitted := ac.beginRoleEffect(token); admitted {
230+ if ticket, admitted := ac.beginAttachmentEffect(token); admitted {
233231 return token, ticket, true
234232 }
235233
236- g := &ac.roleEffects
234+ g := &ac.attachmentEffects
237235 g.mu.Lock()
238236 g.initLocked()
239- if g.phase == roleEffectsFrozen {
237+ if g.phase == attachmentEffectsFrozen {
240238 changed := g.changed
241239 g.mu.Unlock()
242240 <-changed
@@ -244,19 +242,18 @@ func (ac *attachedClient) beginCurrentRoleEffect(sess *session, tr ports.Transpo
244242 }
245243 g.mu.Unlock()
246244 // Publication may have completed between token capture and admission.
247- // Re-read the registry and exact generation instead of using stale role
248- // or lease authority.
245+ // Re-read the registry and exact generation instead of using stale authority.
249246 }
250247 }
251248
252-// publishRoleCapability is used by direct/headless setup and by transition
253-// publication while the gate is frozen. Production role changes publish only
254-// through publishFrozenRoleCapability.
255-func (ac *attachedClient) publishRoleCapability(token attachmentRoleToken) {
249+// publishAttachmentCapability is used by direct/headless setup and by transition
250+// publication while the gate is frozen. Production attachment changes publish only
251+// through publishFrozenAttachmentCapability.
252+func (ac *attachedClient) publishAttachmentCapability(token attachmentConnectionToken) {
256253 if ac == nil {
257254 return
258255 }
259- g := &ac.roleEffects
256+ g := &ac.attachmentEffects
260257 g.mu.Lock()
261258 g.initLocked()
262259 g.capability = capabilityFromToken(token)
@@ -264,44 +261,44 @@ func (ac *attachedClient) publishRoleCapability(token attachmentRoleToken) {
264261 g.mu.Unlock()
265262 }
266263
267-// bootstrapRoleCapability supports direct/headless session construction. It
264+// bootstrapAttachmentCapability supports direct/headless session construction. It
268265 // never changes an existing or frozen production publication.
269-func (ac *attachedClient) bootstrapRoleCapability(token attachmentRoleToken) {
270- if ac == nil || token.sess == nil || token.role == attachmentDetached {
266+func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) {
267+ if ac == nil || token.sess == nil || token.ac != ac {
271268 return
272269 }
273- g := &ac.roleEffects
270+ g := &ac.attachmentEffects
274271 g.mu.Lock()
275272 g.initLocked()
276- if g.phase == roleEffectsStable && g.capability.sess == nil {
273+ if g.phase == attachmentEffectsStable && g.capability.sess == nil {
277274 g.capability = capabilityFromToken(token)
278275 g.failedTransport = transportSnapshot{}
279276 }
280277 g.mu.Unlock()
281278 }
282279
283-func (ac *attachedClient) publishFrozenRoleCapability(token attachmentRoleToken) {
284- g := &ac.roleEffects
280+func (ac *attachedClient) publishFrozenAttachmentCapability(token attachmentConnectionToken) {
281+ g := &ac.attachmentEffects
285282 g.mu.Lock()
286283 g.initLocked()
287- if g.phase != roleEffectsFrozen {
284+ if g.phase != attachmentEffectsFrozen {
288285 g.mu.Unlock()
289- panic("daemon: publishing role capability without transition freeze")
286+ panic("daemon: publishing attachment capability without transition freeze")
290287 }
291288 g.capability = capabilityFromToken(token)
292289 g.failedTransport = transportSnapshot{}
293290 g.mu.Unlock()
294291 }
295292
296-func (ac *attachedClient) invalidateFrozenRoleCapability() {
297- g := &ac.roleEffects
293+func (ac *attachedClient) invalidateFrozenAttachmentCapability() {
294+ g := &ac.attachmentEffects
298295 g.mu.Lock()
299296 g.initLocked()
300- if g.phase != roleEffectsFrozen {
297+ if g.phase != attachmentEffectsFrozen {
301298 g.mu.Unlock()
302- panic("daemon: invalidating role capability without transition freeze")
299+ panic("daemon: invalidating attachment capability without transition freeze")
303300 }
304- g.capability = roleCapability{}
301+ g.capability = attachmentCapability{}
305302 g.failedTransport = transportSnapshot{}
306303 g.mu.Unlock()
307304 }
R internal/usecase/daemon/role_effect_gate_test.go → internal/usecase/daemon/attachment_effect_gate_test.go +142 -181 renamed, 79% similar
@@ -119,32 +119,32 @@ func (t *teardownBlockingTransport) Closed() bool {
119119 }
120120 }
121121
122-func startBlockedRoleSend(t *testing.T, token attachmentRoleToken) <-chan struct{} {
122+func startBlockedRoleSend(t *testing.T, token attachmentConnectionToken) <-chan struct{} {
123123 t.Helper()
124- ticket, admitted := token.ac.beginRoleEffect(token)
124+ ticket, admitted := token.ac.beginAttachmentEffect(token)
125125 require.True(t, admitted)
126126 done := make(chan struct{})
127127 go func() {
128128 defer close(done)
129129 defer ticket.End()
130- _ = token.ac.sendExpectedTransportForRole(token.transport, framePong(), ticket)
130+ _ = token.ac.sendExpectedTransportForAttachment(token.transport, framePong(), ticket)
131131 }()
132132 return done
133133 }
134134
135135 func requireRoleGateRetired(t *testing.T, ac *attachedClient) {
136136 t.Helper()
137- ac.roleEffects.mu.Lock()
138- defer ac.roleEffects.mu.Unlock()
139- require.Equal(t, roleEffectsStable, ac.roleEffects.phase)
140- require.Zero(t, ac.roleEffects.inFlight)
141- require.Empty(t, ac.roleEffects.transportEffects)
137+ ac.attachmentEffects.mu.Lock()
138+ defer ac.attachmentEffects.mu.Unlock()
139+ require.Equal(t, attachmentEffectsStable, ac.attachmentEffects.phase)
140+ require.Zero(t, ac.attachmentEffects.inFlight)
141+ require.Empty(t, ac.attachmentEffects.transportEffects)
142142 }
143143
144-func roleGatePublicationSnapshot(ac *attachedClient) (roleEffectPhase, roleCapability) {
145- ac.roleEffects.mu.Lock()
146- defer ac.roleEffects.mu.Unlock()
147- return ac.roleEffects.phase, ac.roleEffects.capability
144+func roleGatePublicationSnapshot(ac *attachedClient) (attachmentEffectPhase, attachmentCapability) {
145+ ac.attachmentEffects.mu.Lock()
146+ defer ac.attachmentEffects.mu.Unlock()
147+ return ac.attachmentEffects.phase, ac.attachmentEffects.capability
148148 }
149149
150150 func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) {
@@ -168,21 +168,21 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
168168 rc := d.attachCoordinator(sess, nil, first, true)
169169 firstToken := sess.attachmentToken(first, firstTransport)
170170 firstToken.lease = rc.attachmentLease(first)
171- first.publishRoleCapability(firstToken)
171+ first.publishAttachmentCapability(firstToken)
172172 blockedToken := sess.attachmentToken(blocked, blockedTransport)
173- blocked.publishRoleCapability(blockedToken)
173+ blocked.publishAttachmentCapability(blockedToken)
174174 laterToken := sess.attachmentToken(later, laterTransport)
175- later.publishRoleCapability(laterToken)
175+ later.publishAttachmentCapability(laterToken)
176176
177177 // Fix the canonical order so teardown acquires first, waits behind the gate
178178 // owned by this test, and never reaches the still-stable later participant.
179- first.roleEffects.immutableOrder()
180- blocked.roleEffects.immutableOrder()
181- later.roleEffects.immutableOrder()
182- require.Less(t, first.roleEffects.order.Load(), blocked.roleEffects.order.Load())
183- require.Less(t, blocked.roleEffects.order.Load(), later.roleEffects.order.Load())
179+ first.attachmentEffects.immutableOrder()
180+ blocked.attachmentEffects.immutableOrder()
181+ later.attachmentEffects.immutableOrder()
182+ require.Less(t, first.attachmentEffects.order.Load(), blocked.attachmentEffects.order.Load())
183+ require.Less(t, blocked.attachmentEffects.order.Load(), later.attachmentEffects.order.Load())
184184
185- blockedOwner := freezeRoleEffectGates(blocked)
185+ blockedOwner := freezeAttachmentEffectGates(blocked)
186186 blockedOwnerHeld := true
187187 t.Cleanup(func() {
188188 if blockedOwnerHeld {
@@ -193,12 +193,12 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
193193 firstPhase, firstCapability := roleGatePublicationSnapshot(first)
194194 blockedPhase, blockedCapability := roleGatePublicationSnapshot(blocked)
195195 laterPhase, laterCapability := roleGatePublicationSnapshot(later)
196- require.Equal(t, roleEffectsStable, firstPhase)
197- require.Equal(t, roleEffectsFrozen, blockedPhase)
198- require.Equal(t, roleEffectsStable, laterPhase)
199- firstGeneration := first.roleGeneration.Load()
200- blockedGeneration := blocked.roleGeneration.Load()
201- laterGeneration := later.roleGeneration.Load()
196+ require.Equal(t, attachmentEffectsStable, firstPhase)
197+ require.Equal(t, attachmentEffectsFrozen, blockedPhase)
198+ require.Equal(t, attachmentEffectsStable, laterPhase)
199+ firstGeneration := first.connectionGeneration.Load()
200+ blockedGeneration := blocked.connectionGeneration.Load()
201+ laterGeneration := later.connectionGeneration.Load()
202202 firstSnapshot := first.transportSnapshot()
203203 blockedSnapshot := blocked.transportSnapshot()
204204 laterSnapshot := later.transportSnapshot()
@@ -206,7 +206,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
206206 clock := &signalClock{timers: make(chan *signalTimer, 1)}
207207 d.clock = clock
208208 frozenByKill := make(chan *attachedClient, 3)
209- d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) { frozenByKill <- ac }
209+ d.afterAttachmentEffectGateFrozen = func(_ string, ac *attachedClient) { frozenByKill <- ac }
210210 killDone := make(chan error, 1)
211211 go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
212212
@@ -233,9 +233,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
233233 for _, ac := range []*attachedClient{first, blocked, later} {
234234 require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership")
235235 }
236- require.Equal(t, firstGeneration, first.roleGeneration.Load())
237- require.Equal(t, blockedGeneration, blocked.roleGeneration.Load())
238- require.Equal(t, laterGeneration, later.roleGeneration.Load())
236+ require.Equal(t, firstGeneration, first.connectionGeneration.Load())
237+ require.Equal(t, blockedGeneration, blocked.connectionGeneration.Load())
238+ require.Equal(t, laterGeneration, later.connectionGeneration.Load())
239239 require.True(t, first.transportSnapshotCurrent(firstSnapshot))
240240 require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot))
241241 require.True(t, later.transportSnapshotCurrent(laterSnapshot))
@@ -246,21 +246,21 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
246246 firstPhase, gotFirstCapability := roleGatePublicationSnapshot(first)
247247 blockedPhase, gotBlockedCapability := roleGatePublicationSnapshot(blocked)
248248 laterPhase, gotLaterCapability := roleGatePublicationSnapshot(later)
249- require.Equal(t, roleEffectsStable, firstPhase, "partially acquired gate was not rolled back")
250- require.Equal(t, roleEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition")
251- require.Equal(t, roleEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated")
249+ require.Equal(t, attachmentEffectsStable, firstPhase, "partially acquired gate was not rolled back")
250+ require.Equal(t, attachmentEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition")
251+ require.Equal(t, attachmentEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated")
252252 require.Equal(t, firstCapability, gotFirstCapability)
253253 require.Equal(t, blockedCapability, gotBlockedCapability)
254254 require.Equal(t, laterCapability, gotLaterCapability)
255255
256256 blockedOwner.unfreeze()
257257 blockedOwnerHeld = false
258- complete := freezeRoleEffectGates(first, blocked, later)
258+ complete := freezeAttachmentEffectGates(first, blocked, later)
259259 require.True(t, complete.acquired, "rolled-back gate set could not be acquired again")
260260 require.True(t, complete.drained)
261261 complete.unfreeze()
262- for _, token := range []attachmentRoleToken{firstToken, blockedToken, laterToken} {
263- ticket, admitted := token.ac.beginRoleEffect(token)
262+ for _, token := range []attachmentConnectionToken{firstToken, blockedToken, laterToken} {
263+ ticket, admitted := token.ac.beginAttachmentEffect(token)
264264 require.True(t, admitted, "pre-publication abort changed a role capability")
265265 ticket.End()
266266 requireRoleGateRetired(t, token.ac)
@@ -272,14 +272,13 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
272272 snatchedTransport := newTeardownBlockingTransport()
273273 snatched.replaceTransport(snatchedTransport)
274274 d.attachCoordinator(sess, nil, snatched, true)
275- snatched.publishRoleCapability(sess.attachmentToken(snatched, snatchedTransport))
275+ snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))
276276
277277 activeTransport := newTeardownBlockingTransport()
278278 active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size}
279279 active.initOverlays()
280280 transition, err := d.transitionAttachment(attachmentTransitionRequest{
281- target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,
282- expectedTransport: active.transportSnapshot(), ready: true,
281+ target: sess, next: active, expectedTransport: active.transportSnapshot(), ready: true,
283282 })
284283 require.NoError(t, err)
285284 defer d.deferAttachmentTransitionCleanups(transition)
@@ -329,13 +328,13 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test
329328 stale := newTeardownBlockingTransport()
330329 ac.replaceTransport(stale)
331330 d.attachCoordinator(sess, nil, ac, true)
332- ac.publishRoleCapability(sess.attachmentToken(ac, stale))
331+ ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))
333332
334333 token := sess.attachmentToken(ac, stale)
335334 sendDone := startBlockedRoleSend(t, token)
336335 awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block")
337336 fresh := &closeTrackingTransport{}
338- d.afterRoleEffectParticipantsSnapshotted = func(string, []*attachedClient) {
337+ d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) {
339338 ac.replaceTransport(fresh)
340339 }
341340
@@ -358,9 +357,9 @@ func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T
358357 transport := &closeTrackingTransport{}
359358 active.replaceTransport(transport)
360359 d.attachCoordinator(sess, nil, active, true)
361- active.publishRoleCapability(sess.attachmentToken(active, transport))
360+ active.publishAttachmentCapability(sess.attachmentToken(active, transport))
362361
363- owner := freezeRoleEffectGates(active)
362+ owner := freezeAttachmentEffectGates(active)
364363 require.True(t, owner.acquired)
365364 defer owner.unfreeze()
366365
@@ -389,7 +388,7 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
389388 blocked := newTeardownBlockingTransport()
390389 active.replaceTransport(blocked)
391390 d.attachCoordinator(sess, nil, active, true)
392- active.publishRoleCapability(sess.attachmentToken(active, blocked))
391+ active.publishAttachmentCapability(sess.attachmentToken(active, blocked))
393392
394393 token := sess.attachmentToken(active, blocked)
395394 sendDone := startBlockedRoleSend(t, token)
@@ -436,19 +435,19 @@ func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing
436435 rc := d.attachCoordinator(sess, nil, original, true)
437436 originalToken := sess.attachmentToken(original, failedTransport)
438437 originalToken.lease = rc.attachmentLease(original)
439- original.publishRoleCapability(originalToken)
438+ original.publishAttachmentCapability(originalToken)
440439
441440 cleanupStarted := make(chan struct{})
442441 releaseCleanup := make(chan struct{})
443442 cleanupDone := make(chan struct{})
444- d.beforeRoleSendErrorCleanup = func(token attachmentRoleToken) {
443+ d.beforeAttachmentSendErrorCleanup = func(token attachmentConnectionToken) {
445444 require.Equal(t, originalToken.generation, token.generation)
446445 require.Equal(t, originalToken.transport, token.transport)
447446 require.Same(t, originalToken.lease, token.lease)
448447 close(cleanupStarted)
449448 <-releaseCleanup
450449 }
451- d.afterRoleSendErrorCleanup = func() { close(cleanupDone) }
450+ d.afterAttachmentSendErrorCleanup = func() { close(cleanupDone) }
452451
453452 d.paint(sess, original, true, originalToken.lease)
454453 awaitTestCompletion(t, cleanupStarted, "render failure cleanup did not retain the captured role token")
@@ -463,17 +462,15 @@ func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing
463462 replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: original.size}
464463 replacement.initOverlays()
465464 displaced, err := d.transitionAttachment(attachmentTransitionRequest{
466- target: sess, next: replacement, expectedRole: attachmentDetached, targetRole: attachmentActive,
467- expectedTransport: replacement.transportSnapshot(), ready: true,
465+ target: sess, next: replacement, expectedTransport: replacement.transportSnapshot(), ready: true,
468466 })
469467 require.NoError(t, err)
470468
471469 resumed, err := d.transitionAttachment(attachmentTransitionRequest{
472- target: sess, next: original, expectedRole: attachmentSnatched, targetRole: attachmentActive,
473- expectedTransport: original.transportSnapshot(), ready: true,
470+ target: sess, next: original, expectedTransport: original.transportSnapshot(), ready: true,
474471 })
475472 require.NoError(t, err)
476- resumedGeneration := original.roleGeneration.Load()
473+ resumedGeneration := original.connectionGeneration.Load()
477474 resumedLease := resumed.published.lease
478475 require.NotSame(t, originalToken.lease, resumedLease)
479476
@@ -484,8 +481,8 @@ func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing
484481 sess.mu.Lock()
485482 require.Contains(t, sess.snapshotAttachmentsLocked(), original, "stale render cleanup detached the resumed incarnation")
486483 sess.mu.Unlock()
487- require.Equal(t, resumedGeneration, original.roleGeneration.Load())
488- require.True(t, resumed.published.activeCurrent())
484+ require.Equal(t, resumedGeneration, original.connectionGeneration.Load())
485+ require.True(t, resumed.published.attachmentCurrent())
489486 require.False(t, failedTransport.Closed(), "stale cleanup closed the resumed incarnation's transport")
490487
491488 d.deferAttachmentTransitionCleanups(displaced)
@@ -500,16 +497,16 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing
500497 rc := d.attachCoordinator(sess, nil, ac, true)
501498 token := sess.attachmentToken(ac, transport)
502499 token.lease = rc.attachmentLease(ac)
503- ac.publishRoleCapability(token)
500+ ac.publishAttachmentCapability(token)
504501
505502 clock := &signalClock{timers: make(chan *signalTimer, 1)}
506503 d.clock = clock
507- frozen := freezeRoleEffectGates(ac)
504+ frozen := freezeAttachmentEffectGates(ac)
508505 defer frozen.unfreeze()
509506 cleanupDone := make(chan struct{})
510- d.afterRoleSendErrorCleanup = func() { close(cleanupDone) }
507+ d.afterAttachmentSendErrorCleanup = func() { close(cleanupDone) }
511508
512- launchCleanup := d.reserveRoleSendErrorCleanup(token, transport)
509+ launchCleanup := d.reserveAttachmentSendErrorCleanup(token, transport)
513510 launchCleanup()
514511 deadline := awaitTestValue(t, clock.timers, "render cleanup did not arm its deadline")
515512 require.Equal(t, detachNotifyTimeout, deadline.duration)
@@ -523,7 +520,7 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing
523520 require.False(t, transport.Closed())
524521 }
525522
526-func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t *testing.T) {
523+func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t *testing.T) {
527524 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
528525 oldTransport := newBlockedRenderReplacementTransport(d, sess)
529526 old.replaceTransport(oldTransport)
@@ -531,7 +528,7 @@ func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t
531528 lease := rc.attachmentLease(old)
532529 token := sess.attachmentToken(old, oldTransport)
533530 token.lease = lease
534- old.publishRoleCapability(token)
531+ old.publishAttachmentCapability(token)
535532
536533 paintDone := make(chan struct{})
537534 go func() {
@@ -547,8 +544,7 @@ func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t
547544 transitionErr := make(chan error, 1)
548545 go func() {
549546 result, err := d.transitionAttachment(attachmentTransitionRequest{
550- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
551- expectedTransport: next.transportSnapshot(), ready: true,
547+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
552548 })
553549 transitionDone <- result
554550 transitionErr <- err
@@ -561,32 +557,34 @@ func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t
561557 t.Fatal("replacement publication remained blocked behind the old render send")
562558 }
563559 require.NoError(t, <-transitionErr)
564- require.True(t, <-oldTransport.locksFree, "transport close ran while an architecture lock was held")
560+ require.Contains(t, sess.snapshotAttachments(), next)
561+ require.Contains(t, sess.snapshotAttachments(), old)
562+ require.False(t, newTransport.Closed(), "new attachment transport was affected by publication")
563+
564+ // Generic attachment publication does not interrupt or retire another
565+ // registered connection. Release the test transport explicitly.
566+ require.NoError(t, oldTransport.Close())
565567 select {
566568 case <-paintDone:
567569 case <-time.After(time.Second):
568- t.Fatal("closing the exact old transport did not unblock its render")
570+ t.Fatal("closing the test transport did not unblock its render")
569571 }
570- require.Contains(t, sess.snapshotAttachments(), next)
571- require.False(t, newTransport.Closed(), "replacement transport was affected by old-link interruption")
572-
573572 d.deferAttachmentTransitionCleanups(result)
574573 d.attachmentCleanupWg.Wait()
575- require.Nil(t, old.transport(), "the unhealthy old link was not retired")
576- require.Contains(t, sess.snapshotAttachments(), next)
574+ require.Same(t, oldTransport, old.transport(), "unrelated attachment link was retired")
577575 require.False(t, newTransport.Closed())
578576 }
579577
580-func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) {
578+func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) {
581579 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
582580 oldTransport := newDatagramTestTransport()
583581 old.replaceTransport(oldTransport)
584582 rc := d.attachCoordinator(sess, nil, old, true)
585583 token := sess.attachmentToken(old, oldTransport)
586584 token.lease = rc.attachmentLease(old)
587- old.publishRoleCapability(token)
585+ old.publishAttachmentCapability(token)
588586
589- ticket, ok := old.beginRoleEffect(token)
587+ ticket, ok := old.beginAttachmentEffect(token)
590588 require.True(t, ok)
591589
592590 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
@@ -594,22 +592,18 @@ func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *tes
594592 done := make(chan error, 1)
595593 go func() {
596594 _, err := d.transitionAttachment(attachmentTransitionRequest{
597- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
598- expectedTransport: next.transportSnapshot(), ready: true,
595+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
599596 })
600597 done <- err
601598 }()
602599
603- select {
604- case err := <-done:
605- t.Fatalf("transition committed before admitted effect ended: %v", err)
606- case <-time.After(20 * time.Millisecond):
607- }
608- ticket.End()
600+ // Adding an independent attachment does not wait on another connection's
601+ // effect gate.
609602 require.NoError(t, <-done)
603+ ticket.End()
610604 }
611605
612-func TestRoleEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T) {
606+func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T) {
613607 tests := []struct {
614608 name string
615609 setup func(*Daemon, *session, *attachedClient, *transactionalResizePTY)
@@ -695,13 +689,13 @@ func TestRoleEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T
695689
696690 admitted := make(chan struct{})
697691 release := make(chan struct{})
698- d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
692+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {
699693 close(admitted)
700694 <-release
701695 }
702696 effectDone := make(chan struct{})
703697 go func() {
704- d.handleActiveClientFrame(token, tt.frame)
698+ d.handleAttachmentClientFrame(token, tt.frame)
705699 close(effectDone)
706700 }()
707701 <-admitted
@@ -711,19 +705,13 @@ func TestRoleEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T
711705 transitionDone := make(chan error, 1)
712706 go func() {
713707 _, err := d.transitionAttachment(attachmentTransitionRequest{
714- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
715- expectedTransport: next.transportSnapshot(), ready: true,
708+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
716709 })
717710 transitionDone <- err
718711 }()
719- select {
720- case err := <-transitionDone:
721- t.Fatalf("transition overtook admitted effect: %v", err)
722- case <-time.After(20 * time.Millisecond):
723- }
712+ require.NoError(t, <-transitionDone)
724713 close(release)
725714 <-effectDone
726- require.NoError(t, <-transitionDone)
727715 tt.assert(t, old, pty)
728716 })
729717 }
@@ -741,13 +729,13 @@ func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T)
741729 rc := d.attachCoordinator(sess, nil, old, true)
742730 token := sess.attachmentToken(old, oldTransport)
743731 token.lease = rc.attachmentLease(old)
744- old.publishRoleCapability(token)
732+ old.publishAttachmentCapability(token)
745733
746734 admissionEnded := make(chan struct{})
747735 releaseAction := make(chan struct{})
748736 release := releaseTestGate(t, releaseAction)
749737 var admissionEndedOnce sync.Once
750- d.afterActionRoleEffectEnded = func(action string) {
738+ d.afterActionAttachmentEffectEnded = func(action string) {
751739 if action != "jump-attention" {
752740 return
753741 }
@@ -756,7 +744,7 @@ func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T)
756744 }
757745 actionDone := make(chan struct{})
758746 go func() {
759- d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))
747+ d.handleAttachmentClientFrame(token, frameInput([]byte("\x1ba")))
760748 close(actionDone)
761749 }()
762750 <-admissionEnded
@@ -764,8 +752,7 @@ func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T)
764752 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
765753 next.initOverlays()
766754 result, err := d.transitionAttachment(attachmentTransitionRequest{
767- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
768- expectedTransport: next.transportSnapshot(), ready: true,
755+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
769756 })
770757 require.NoError(t, err)
771758 sess.mu.Lock()
@@ -797,9 +784,9 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
797784 rc := d.attachCoordinator(source, nil, ac, true)
798785 token := source.attachmentToken(ac, ac.transport())
799786 token.lease = rc.attachmentLease(ac)
800- ac.publishRoleCapability(token)
787+ ac.publishAttachmentCapability(token)
801788
802- d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))
789+ d.handleAttachmentClientFrame(token, frameInput([]byte("\x1ba")))
803790
804791 require.Same(t, target, ac.currentSession())
805792 require.Equal(t, 1, testAttachmentTabIndex(target))
@@ -825,13 +812,13 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T
825812 rc := d.attachCoordinator(source, nil, old, true)
826813 token := source.attachmentToken(old, oldTransport)
827814 token.lease = rc.attachmentLease(old)
828- old.publishRoleCapability(token)
815+ old.publishAttachmentCapability(token)
829816
830817 admissionEnded := make(chan struct{})
831818 releaseAction := make(chan struct{})
832819 release := releaseTestGate(t, releaseAction)
833820 var admissionEndedOnce sync.Once
834- d.afterActionRoleEffectEnded = func(action string) {
821+ d.afterActionAttachmentEffectEnded = func(action string) {
835822 if action == "jump-attention" {
836823 admissionEndedOnce.Do(func() { close(admissionEnded) })
837824 <-releaseAction
@@ -839,7 +826,7 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T
839826 }
840827 actionDone := make(chan struct{})
841828 go func() {
842- d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))
829+ d.handleAttachmentClientFrame(token, frameInput([]byte("\x1ba")))
843830 close(actionDone)
844831 }()
845832 <-admissionEnded
@@ -847,8 +834,7 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T
847834 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
848835 next.initOverlays()
849836 result, err := d.transitionAttachment(attachmentTransitionRequest{
850- target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
851- expectedTransport: next.transportSnapshot(), ready: true,
837+ target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true,
852838 })
853839 require.NoError(t, err)
854840 release()
@@ -872,13 +858,13 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
872858 rc := d.attachCoordinator(sess, nil, old, true)
873859 token := sess.attachmentToken(old, oldTransport)
874860 token.lease = rc.attachmentLease(old)
875- old.publishRoleCapability(token)
861+ old.publishAttachmentCapability(token)
876862
877863 admissionEnded := make(chan struct{})
878864 releaseAction := make(chan struct{})
879865 release := releaseTestGate(t, releaseAction)
880866 var admissionEndedOnce sync.Once
881- d.afterActionRoleEffectEnded = func(action string) {
867+ d.afterActionAttachmentEffectEnded = func(action string) {
882868 if action != "picker-delete" {
883869 return
884870 }
@@ -887,7 +873,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
887873 }
888874 actionDone := make(chan struct{})
889875 go func() {
890- d.handleActiveClientFrame(token, frameInput([]byte("x")))
876+ d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
891877 close(actionDone)
892878 }()
893879 <-admissionEnded
@@ -895,8 +881,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
895881 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
896882 next.initOverlays()
897883 result, err := d.transitionAttachment(attachmentTransitionRequest{
898- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
899- expectedTransport: next.transportSnapshot(), ready: true,
884+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
900885 })
901886 require.NoError(t, err)
902887 release()
@@ -933,13 +918,13 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi
933918 rc := d.attachCoordinator(source, nil, old, true)
934919 token := source.attachmentToken(old, oldTransport)
935920 token.lease = rc.attachmentLease(old)
936- old.publishRoleCapability(token)
921+ old.publishAttachmentCapability(token)
937922
938923 admissionEnded := make(chan struct{})
939924 releaseAction := make(chan struct{})
940925 release := releaseTestGate(t, releaseAction)
941926 var admissionEndedOnce sync.Once
942- d.afterActionRoleEffectEnded = func(action string) {
927+ d.afterActionAttachmentEffectEnded = func(action string) {
943928 if action == "picker-delete" {
944929 admissionEndedOnce.Do(func() { close(admissionEnded) })
945930 <-releaseAction
@@ -947,7 +932,7 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi
947932 }
948933 actionDone := make(chan struct{})
949934 go func() {
950- d.handleActiveClientFrame(token, frameInput([]byte("x")))
935+ d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
951936 close(actionDone)
952937 }()
953938 <-admissionEnded
@@ -960,8 +945,7 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi
960945 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
961946 next.initOverlays()
962947 result, err := d.transitionAttachment(attachmentTransitionRequest{
963- target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
964- expectedTransport: next.transportSnapshot(), ready: true,
948+ target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true,
965949 })
966950 require.NoError(t, err)
967951 release()
@@ -981,9 +965,9 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
981965 rc := d.attachCoordinator(sess, nil, ac, true)
982966 token := sess.attachmentToken(ac, ac.transport())
983967 token.lease = rc.attachmentLease(ac)
984- ac.publishRoleCapability(token)
968+ ac.publishAttachmentCapability(token)
985969
986- d.handleActiveClientFrame(token, frameInput([]byte("x")))
970+ d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
987971
988972 d.mu.Lock()
989973 _, registered := d.sessions[sess.id]
@@ -1003,13 +987,13 @@ func TestDelayedKeyCallbackAdmittedBeforeReplacementCompletesFirst(t *testing.T)
1003987 rc := d.attachCoordinator(sess, nil, old, true)
1004988 token := sess.attachmentToken(old, oldTransport)
1005989 token.lease = rc.attachmentLease(old)
1006- old.publishRoleCapability(token)
990+ old.publishAttachmentCapability(token)
1007991
1008- d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC}))
992+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC}))
1009993 timer := <-clock.timers
1010994 admitted := make(chan struct{})
1011995 release := make(chan struct{})
1012- d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
996+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {
1013997 close(admitted)
1014998 <-release
1015999 }
@@ -1025,8 +1009,7 @@ func TestDelayedKeyCallbackAdmittedBeforeReplacementCompletesFirst(t *testing.T)
10251009 transitionDone := make(chan error, 1)
10261010 go func() {
10271011 _, err := d.transitionAttachment(attachmentTransitionRequest{
1028- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1029- expectedTransport: next.transportSnapshot(), ready: true,
1012+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
10301013 })
10311014 transitionDone <- err
10321015 }()
@@ -1052,15 +1035,14 @@ func TestDelayedKeyCallbackAfterReplacementDropsStaleWork(t *testing.T) {
10521035 rc := d.attachCoordinator(sess, nil, old, true)
10531036 token := sess.attachmentToken(old, oldTransport)
10541037 token.lease = rc.attachmentLease(old)
1055- old.publishRoleCapability(token)
1038+ old.publishAttachmentCapability(token)
10561039
1057- d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC}))
1040+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC}))
10581041 timer := <-clock.timers
10591042 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
10601043 next.initOverlays()
10611044 _, err := d.transitionAttachment(attachmentTransitionRequest{
1062- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1063- expectedTransport: next.transportSnapshot(), ready: true,
1045+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
10641046 })
10651047 require.NoError(t, err)
10661048
@@ -1075,7 +1057,7 @@ func TestDelayedKeyCallbackAfterReplacementDropsStaleWork(t *testing.T) {
10751057 }
10761058 }
10771059
1078-func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) {
1060+func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) {
10791061 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
10801062 oldTransport := newDatagramTestTransport()
10811063 old.replaceTransport(oldTransport)
@@ -1085,7 +1067,7 @@ func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T)
10851067
10861068 admitted := make(chan struct{})
10871069 release := make(chan struct{})
1088- d.afterRoleEffectAdmitted = func(attachmentRoleToken) {
1070+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {
10891071 close(admitted)
10901072 <-release
10911073 }
@@ -1098,22 +1080,18 @@ func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T)
10981080 transitionDone := make(chan error, 1)
10991081 go func() {
11001082 _, err := d.transitionAttachment(attachmentTransitionRequest{
1101- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1102- expectedTransport: next.transportSnapshot(), ready: true,
1083+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
11031084 })
11041085 transitionDone <- err
11051086 }()
1106- select {
1107- case err := <-transitionDone:
1108- t.Fatalf("replacement overtook admitted first paint: %v", err)
1109- case <-time.After(20 * time.Millisecond):
1110- }
1087+ // An independent attachment publication does not wait on this connection's
1088+ // admitted first paint.
1089+ require.NoError(t, <-transitionDone)
11111090 close(release)
11121091 require.True(t, <-paintDone)
1113- require.NoError(t, <-transitionDone)
11141092 }
11151093
1116-func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) {
1094+func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) {
11171095 d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
11181096 aTransport := newDatagramTestTransport()
11191097 a.replaceTransport(aTransport)
@@ -1137,7 +1115,7 @@ func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T)
11371115 <-start
11381116 _, err := d.transitionAttachment(attachmentTransitionRequest{
11391117 source: first, target: second, next: a,
1140- expectedRole: attachmentActive, targetRole: attachmentActive,
1118+
11411119 expectedTransport: a.transportSnapshot(), ready: true,
11421120 })
11431121 results <- err
@@ -1146,7 +1124,7 @@ func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T)
11461124 <-start
11471125 _, err := d.transitionAttachment(attachmentTransitionRequest{
11481126 source: second, target: first, next: b,
1149- expectedRole: attachmentActive, targetRole: attachmentActive,
1127+
11501128 expectedTransport: b.transportSnapshot(), ready: true,
11511129 })
11521130 results <- err
@@ -1168,7 +1146,7 @@ func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T)
11681146 successes++
11691147 }
11701148 }
1171- require.Equal(t, 1, successes)
1149+ require.Equal(t, 2, successes)
11721150 }
11731151
11741152 func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
@@ -1192,16 +1170,16 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
11921170 // Give B the earlier immutable identity. The reverse transition therefore
11931171 // freezes B then A, while the old picker-delete path froze A separately
11941172 // before discovering and freezing B.
1195- b.roleEffects.immutableOrder()
1196- a.roleEffects.immutableOrder()
1197- require.Less(t, b.roleEffects.order.Load(), a.roleEffects.order.Load())
1173+ b.attachmentEffects.immutableOrder()
1174+ a.attachmentEffects.immutableOrder()
1175+ require.Less(t, b.attachmentEffects.order.Load(), a.attachmentEffects.order.Load())
11981176
11991177 token := source.attachmentToken(a, aTransport)
12001178 token.lease = aCoordinator.attachmentLease(a)
1201- a.publishRoleCapability(token)
1202- effect, admitted := a.beginRoleEffect(token)
1179+ a.publishAttachmentCapability(token)
1180+ effect, admitted := a.beginAttachmentEffect(token)
12031181 require.True(t, admitted)
1204- token = effect.roleToken()
1182+ token = effect.connectionToken()
12051183
12061184 deleteDiscovered := make(chan []*attachedClient, 1)
12071185 transitionDiscovered := make(chan []*attachedClient, 1)
@@ -1209,7 +1187,7 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
12091187 releaseTransition := make(chan struct{})
12101188 transitionBFrozen := make(chan struct{})
12111189 deleteFrozen := make(chan *attachedClient, 2)
1212- d.afterRoleEffectParticipantsSnapshotted = func(action string, participants []*attachedClient) {
1190+ d.afterAttachmentEffectParticipantsSnapshotted = func(action string, participants []*attachedClient) {
12131191 snapshot := append([]*attachedClient(nil), participants...)
12141192 switch action {
12151193 case "picker-delete":
@@ -1220,7 +1198,7 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
12201198 <-releaseTransition
12211199 }
12221200 }
1223- d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {
1201+ d.afterAttachmentEffectGateFrozen = func(action string, ac *attachedClient) {
12241202 switch action {
12251203 case "reverse-delete-transition":
12261204 if ac == b {
@@ -1233,7 +1211,7 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
12331211
12341212 deleteDone := make(chan error, 1)
12351213 go func() {
1236- deleteDone <- d.killPickerTargetForRole(picker.Target{Session: target.id}, token)
1214+ deleteDone <- d.killPickerTargetForAttachment(picker.Target{Session: target.id}, token)
12371215 }()
12381216 deleteParticipants := <-deleteDiscovered
12391217 require.ElementsMatch(t, []*attachedClient{a, b}, deleteParticipants)
@@ -1242,7 +1220,7 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
12421220 go func() {
12431221 result, err := d.transitionAttachment(attachmentTransitionRequest{
12441222 source: target, target: source, next: b,
1245- expectedRole: attachmentActive, targetRole: attachmentActive,
1223+
12461224 expectedTransport: b.transportSnapshot(), action: "reverse-delete-transition", ready: true,
12471225 })
12481226 if err == nil {
@@ -1289,40 +1267,23 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
12891267 registered := d.sessions[target.id]
12901268 d.mu.Unlock()
12911269 require.Same(t, target, registered, "stale picker deletion mutated its target lifecycle")
1292- require.Equal(t, attachmentSnatched, source.attachmentRole(a))
1293- require.Equal(t, attachmentActive, source.attachmentRole(b))
1270+ require.Equal(t, true, source.attachmentRegistered(a))
1271+ require.Equal(t, true, source.attachmentRegistered(b))
12941272 }
12951273
1296-func TestRoleEffectGateFrozenTransitionRejectsLateEffect(t *testing.T) {
1297- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1298- oldTransport := newDatagramTestTransport()
1299- old.replaceTransport(oldTransport)
1300- rc := d.attachCoordinator(sess, nil, old, true)
1301- token := sess.attachmentToken(old, oldTransport)
1302- token.lease = rc.attachmentLease(old)
1303- old.publishRoleCapability(token)
1304-
1305- frozen := make(chan struct{})
1306- release := make(chan struct{})
1307- d.afterRoleEffectsFrozen = func() {
1308- close(frozen)
1309- <-release
1310- }
1311-
1312- next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1313- next.initOverlays()
1314- done := make(chan error, 1)
1315- go func() {
1316- _, err := d.transitionAttachment(attachmentTransitionRequest{
1317- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
1318- expectedTransport: next.transportSnapshot(), ready: true,
1319- })
1320- done <- err
1321- }()
1322- <-frozen
1274+func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) {
1275+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1276+ transport := newDatagramTestTransport()
1277+ ac.replaceTransport(transport)
1278+ rc := d.attachCoordinator(sess, nil, ac, true)
1279+ token := sess.attachmentToken(ac, transport)
1280+ token.lease = rc.attachmentLease(ac)
1281+ ac.publishAttachmentCapability(token)
13231282
1324- _, ok := old.beginRoleEffect(token)
1283+ frozen := freezeAttachmentEffectGates(ac)
1284+ require.True(t, frozen.acquired)
1285+ require.True(t, frozen.drained)
1286+ _, ok := ac.beginAttachmentEffect(token)
13251287 require.False(t, ok, "a frozen capability must admit no new effect")
1326- close(release)
1327- require.NoError(t, <-done)
1288+ frozen.unfreeze()
13281289 }
M internal/usecase/daemon/attachment_parking_test.go +4 -4
@@ -103,7 +103,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
103103 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
104104 require.NoError(t, err)
105105 oldToken := ac.resumeToken
106- oldGeneration := ac.roleGeneration.Load()
106+ oldGeneration := ac.connectionGeneration.Load()
107107 d.clientGone(sess, ac, oldTransport, false)
108108
109109 newTransport := &closeTrackingTransport{}
@@ -113,7 +113,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
113113 require.Same(t, sess, resumed)
114114 require.Same(t, ac, same)
115115 require.NotEqual(t, oldToken, ac.resumeToken)
116- require.Greater(t, ac.roleGeneration.Load(), oldGeneration)
116+ require.Greater(t, ac.connectionGeneration.Load(), oldGeneration)
117117 require.True(t, d.commitResumeClaim(ac))
118118 d.mu.Lock()
119119 _, oldRetained := d.parked[oldToken]
@@ -262,7 +262,7 @@ func TestHandleHelloAllowsMultipleAttachments(t *testing.T) {
262262 secondTransport := &closeTrackingTransport{}
263263 _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport)
264264 require.NoError(t, err)
265- require.Equal(t, attachmentActive, sess.attachmentRole(first))
266- require.Equal(t, attachmentActive, sess.attachmentRole(second))
265+ require.Equal(t, true, sess.attachmentRegistered(first))
266+ require.Equal(t, true, sess.attachmentRegistered(second))
267267 require.NotSame(t, first, second)
268268 }
M internal/usecase/daemon/attachment_registry.go +14 -0
@@ -159,6 +159,12 @@ func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient {
159159 for ac := range c.attachments {
160160 out = append(out, ac)
161161 }
162+ slices.SortStableFunc(out, func(a, b *attachedClient) int {
163+ if id := bytes.Compare(a.clientID[:], b.clientID[:]); id != 0 {
164+ return id
165+ }
166+ return cmp.Compare(c.attachmentOrder[a], c.attachmentOrder[b])
167+ })
162168 return out
163169 }
164170
@@ -199,6 +205,14 @@ func attachmentRegistered(entry attachmentSession, ac *attachedClient) bool {
199205 return attachmentRegisteredLocked(entry, ac)
200206 }
201207
208+func (s *session) attachmentRegistered(ac *attachedClient) bool {
209+ return attachmentRegistered(s, ac)
210+}
211+
212+func (s *session) attachmentRegisteredLocked(ac *attachedClient) bool {
213+ return attachmentRegisteredLocked(s, ac)
214+}
215+
202216 func (s *session) attachmentViewsTabLocked(tb *tab) bool {
203217 if s == nil || tb == nil {
204218 return false
D internal/usecase/daemon/attachment_role.go +0 -218
@@ -1,218 +0,0 @@
1-package daemon
2-
3-import "github.com/bnema/vev/internal/ports"
4-
5-type attachmentRole uint8
6-
7-// attachmentSessionCore normalizes nil interfaces, typed-nil implementations,
8-// and implementations that deliberately expose no attachment core.
9-func attachmentSessionCore(entry attachmentSession) *sessionCore {
10- if entry == nil {
11- return nil
12- }
13- return entry.core()
14-}
15-
16-const (
17- attachmentDetached attachmentRole = iota
18- attachmentActive
19- attachmentSnatched
20-)
21-
22-// attachmentRole derives the attachment's role from the session-owned
23-// registries. There is deliberately no second role field that could drift.
24-func attachmentSessionRole(entry attachmentSession, ac *attachedClient) attachmentRole {
25- if entry == nil || ac == nil || entry.core() == nil {
26- return attachmentDetached
27- }
28- core := entry.core()
29- core.mu.Lock()
30- defer core.mu.Unlock()
31- return attachmentSessionRoleLocked(entry, ac)
32-}
33-
34-// attachmentSessionRoleLocked requires entry.core().mu. Active membership wins
35-// if an invalid intermediate state contains the same attachment in both
36-// registries.
37-func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) attachmentRole {
38- if entry == nil || ac == nil || entry.core() == nil {
39- return attachmentDetached
40- }
41- core := entry.core()
42- // Membership is the lifecycle authority for every independent attachment.
43- if _, ok := core.attachments[ac]; ok {
44- return attachmentActive
45- }
46- return attachmentDetached
47-}
48-
49-func (s *session) attachmentRole(ac *attachedClient) attachmentRole {
50- return attachmentSessionRole(s, ac)
51-}
52-
53-func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole {
54- return attachmentSessionRoleLocked(s, ac)
55-}
56-
57-type attachmentRoleToken struct {
58- sess attachmentSession
59- ac *attachedClient
60- role attachmentRole
61- generation uint64
62- transport transportSnapshot
63- lease *attachmentLease
64- effect *roleEffectTicket
65- // rebase marks a cross-session ownership move whose first paint must start a
66- // dependency-free output chain. The rebase is deliberately deferred until
67- // after transition publication releases all architecture locks.
68- rebase bool
69-}
70-
71-// attachmentTokenLocked captures the exact role, transport incarnation, and
72-// active coordinator lease at an architecture commit point. The caller must
73-// hold s.mu; coordinator acquisition follows the canonical session ->
74-// coordinator order.
75-func attachmentTokenLocked(entry attachmentSession, ac *attachedClient) attachmentRoleToken {
76- if entry == nil || entry.core() == nil || ac == nil {
77- return attachmentRoleToken{}
78- }
79- transport := ac.transportSnapshot()
80- if transport.transport == nil {
81- return attachmentRoleToken{}
82- }
83- token := attachmentRoleToken{
84- sess: entry,
85- ac: ac,
86- role: attachmentSessionRoleLocked(entry, ac),
87- generation: ac.roleGeneration.Load(),
88- transport: transport,
89- }
90- if token.role == attachmentActive {
91- if rc := entry.core().coordinator.Load(); rc != nil {
92- token.lease = rc.attachmentLease(ac)
93- }
94- }
95- return token
96-}
97-
98-func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken {
99- return attachmentTokenLocked(s, ac)
100-}
101-
102-func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentRoleToken {
103- if entry == nil || entry.core() == nil || ac == nil || tr == nil {
104- return attachmentRoleToken{}
105- }
106- core := entry.core()
107- core.mu.Lock()
108- transport := ac.transportSnapshot()
109- if transport.transport != tr {
110- core.mu.Unlock()
111- return attachmentRoleToken{}
112- }
113- token := attachmentRoleToken{
114- sess: entry,
115- ac: ac,
116- role: attachmentSessionRoleLocked(entry, ac),
117- generation: ac.roleGeneration.Load(),
118- transport: transport,
119- }
120- core.mu.Unlock()
121- if token.role == attachmentActive {
122- if rc := core.coordinator.Load(); rc != nil {
123- token.lease = rc.attachmentLease(ac)
124- }
125- }
126- ac.bootstrapRoleCapability(token)
127- return token
128-}
129-
130-func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken {
131- return attachmentToken(s, ac, tr)
132-}
133-
134-func (t attachmentRoleToken) current() bool {
135- return t.sess != nil && t.ac != nil &&
136- t.ac.roleGeneration.Load() == t.generation &&
137- t.ac.transportSnapshotCurrent(t.transport) &&
138- attachmentSessionRole(t.sess, t.ac) == t.role
139-}
140-
141-// activeCurrent admits post-transition effects only for the exact published
142-// role, transport incarnation, current-session link, and coordinator lease.
143-func (t attachmentRoleToken) activeCurrent() bool {
144- if t.role != attachmentActive || !t.current() || t.ac.currentAttachmentSession() != t.sess {
145- return false
146- }
147- // Membership grants each attachment its own protocol capability. The
148- // session coordinator may still have a primary render lease while a second
149- // attachment is joining; that attachment must not be treated as displaced.
150- if t.lease == nil {
151- return true
152- }
153- rc := t.sess.core().coordinator.Load()
154- return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
155-}
156-
157-// activeEffect is the canonical admission check for a client-frame mutation.
158-// Tokens bind role generation, transport incarnation, session ownership, and
159-// coordinator lease, so handlers never rediscover authority from mutable state.
160-func (t attachmentRoleToken) activeEffect() bool {
161- if t.effect != nil && !t.effect.ended.Load() {
162- return true
163- }
164- return t.activeCurrent()
165-}
166-
167-func beginActiveLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) {
168- if sess == nil || ac == nil || lease == nil {
169- return nil, false
170- }
171- token := attachmentToken(sess, ac, ac.transport())
172- token.lease = lease
173- return ac.beginRoleEffect(token)
174-}
175-
176-func (t *attachmentRoleToken) endRoleEffect() {
177- if t == nil || t.effect == nil {
178- return
179- }
180- t.effect.End()
181- t.effect = nil
182-}
183-
184-// activeEffectSessionLocked is the same admission check while t.sess.mu is
185-// already held at a session-owned mutation boundary.
186-func (t attachmentRoleToken) activeEffectSessionLocked() bool {
187- if t.effect != nil && !t.effect.ended.Load() {
188- return true
189- }
190- if t.role != attachmentActive || t.sess == nil || t.ac == nil ||
191- t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) ||
192- t.ac.currentAttachmentSession() != t.sess || attachmentSessionRoleLocked(t.sess, t.ac) != attachmentActive {
193- return false
194- }
195- if t.lease == nil {
196- return true
197- }
198- rc := t.sess.core().coordinator.Load()
199- return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
200-}
201-
202-func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error {
203- if t.ac == nil {
204- return errAttachmentTransition
205- }
206- t.ac.sendMu.Lock()
207- defer t.ac.sendMu.Unlock()
208- if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) ||
209- !t.effect.beginTransportSend(t.transport) {
210- return errAttachmentTransition
211- }
212- err := t.transport.transport.Send(frame)
213- if err != nil {
214- t.effect.reportTransportFailure(t.transport)
215- }
216- t.effect.endTransportSend()
217- return err
218-}
M internal/usecase/daemon/attachment_transition.go +49 -66
@@ -23,70 +23,65 @@ type attachmentTransitionRequest struct {
2323 source attachmentSession
2424 target attachmentSession
2525 next *attachedClient
26- expectedRole attachmentRole
27- targetRole attachmentRole
2826 expectedTransport transportSnapshot
29- sourceToken *attachmentRoleToken
27+ sourceToken *attachmentConnectionToken
3028 action string
3129 activateTargetTab bool
3230 targetTabIndex int
3331 copySourceEnvironment bool
34- // preserveRole commits navigation state without replacing attachment
35- // ownership. It is used for same-session picker selections after the exact
36- // initiating capability has been frozen and revalidated.
37- preserveRole bool
38- expectedSourceTab *tab
32+ // preserveAttachment commits attachment-local navigation state without
33+ // changing session membership. The initiating connection token remains the
34+ // exact authority for this mutation.
35+ preserveAttachment bool
36+ expectedSourceTab *tab
3937 // transferExpectedSourceTab permits an installed visible floating pane only
40- // when the caller atomically transfers this exact tab under its own tab and
41- // floating-generation fences. Ordinary navigation still rejects visibility.
42- transferExpectedSourceTab bool
43- createTargetLocked func() (*session, error)
44- ready bool
45- expectedTargetCurrent *attachedClient
46- expectedTargetTransport transportSnapshot
47- expectedTargetLifecycle *attachmentLifecycleFence
48- preflighted bool
49- roleEffectsFrozen bool
50- expectedTargetTransportInterrupted bool
38+ // when the caller atomically transfers this exact tab under its fences.
39+ transferExpectedSourceTab bool
40+ createTargetLocked func() (*session, error)
41+ ready bool
42+ expectedTargetLifecycle *attachmentLifecycleFence
43+ preflighted bool
44+ attachmentEffectsFrozen bool
5145 }
5246
53-func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source attachmentSession, req attachmentTransitionRequest) bool {
54- return token.sess == source && token.ac == req.next && token.role == req.expectedRole &&
47+func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source attachmentSession, req attachmentTransitionRequest) bool {
48+ return token.sess == source && token.ac == req.next &&
49+ token.generation == req.next.connectionGeneration.Load() &&
5550 token.transport.transport == req.expectedTransport.transport &&
5651 token.transport.incarnation == req.expectedTransport.incarnation
5752 }
5853
59-// transitionSourceTokenCurrentLocked requires source.mu and, for an active
60-// source, sourceCoordinator.mu. This is the canonical handoff preflight: every
61-// client-originated navigation, delete, or create intent is bound to one exact
62-// role generation, transport incarnation, and coordinator lease.
63-func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
64- if token.sess != source || token.ac != req.next || token.role != req.expectedRole ||
65- token.generation != req.next.roleGeneration.Load() || attachmentSessionRoleLocked(source, req.next) != token.role ||
66- req.next.currentAttachmentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {
54+// transitionSourceTokenCurrentLocked requires source.core().mu and, when a
55+// lease exists, sourceCoordinator.mu. It is the canonical exact-connection
56+// handoff check for client-originated navigation and lifecycle mutations.
57+func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
58+ if token.sess != source || token.ac != req.next ||
59+ token.generation != req.next.connectionGeneration.Load() ||
60+ token.sess == nil || token.ac.currentAttachmentSession() != source ||
61+ !attachmentRegisteredLocked(source, req.next) ||
62+ !req.next.transportSnapshotCurrent(token.transport) {
6763 return false
6864 }
69- if token.role != attachmentActive {
65+ if token.lease == nil {
7066 return true
7167 }
72- return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next &&
68+ return sourceCoordinator != nil && token.lease.attachment == req.next &&
7369 sourceCoordinator.leaseCurrentLocked(token.lease, true)
7470 }
7571
7672 type attachmentTransitionResult struct {
77- published attachmentRoleToken
78- displaced attachmentRoleToken
73+ published attachmentConnectionToken
7974 cleanups []renderLifecycleCleanup
8075 }
8176
8277 type attachmentTransitionParticipants struct {
8378 clients []*attachedClient
84- interrupts []roleTransportInterrupt
79+ interrupts []attachmentTransportInterrupt
8580 }
8681
87-// snapshotAttachmentTransition discovers the exact participants while d.mu is
88-// held. It releases every architecture lock before any role effect is ended or
89-// any gate is frozen and drained.
82+// snapshotAttachmentTransition discovers the exact connection participant
83+// while d.mu is held, then releases architecture locks before effect gates are
84+// frozen and drained. Existing target attachments are never displaced.
9085 func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionRequest, attachmentTransitionParticipants, error) {
9186 d.mu.Lock()
9287 defer d.mu.Unlock()
@@ -107,48 +102,41 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
107102 d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target {
108103 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
109104 }
110- // Existing attachments remain independent session members. Transitions only
111- // publish the initiating attachment; there is no singleton target owner to
112- // displace or interrupt.
113- req.expectedTargetCurrent = nil
114105 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
115106 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
116107 }
117108 req.preflighted = true
118- participants := attachmentTransitionParticipants{clients: []*attachedClient{req.next}}
119- // Every attachment joins without displacing another connection.
120- return req, participants, nil
109+ return req, attachmentTransitionParticipants{clients: []*attachedClient{req.next}}, nil
121110 }
122111
123-// freezeAttachmentTransition drains the snapshotted participants without any
124-// architecture lock held. The returned guard remains frozen through atomic
125-// publication and must be released by the caller.
126-func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenRoleEffectGates, bool, error) {
127- if d.afterRoleEffectParticipantsSnapshotted != nil {
128- d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients)
112+// freezeAttachmentTransition drains the snapshotted connection without any
113+// architecture lock held. The returned guard remains frozen through publication.
114+func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenAttachmentEffectGates, bool, error) {
115+ if d.afterAttachmentEffectParticipantsSnapshotted != nil {
116+ d.afterAttachmentEffectParticipantsSnapshotted(req.action, participants.clients)
129117 }
130- frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, afterFrozen: func(ac *attachedClient) {
131- if d.afterRoleEffectGateFrozen != nil {
132- d.afterRoleEffectGateFrozen(req.action, ac)
118+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, afterFrozen: func(ac *attachedClient) {
119+ if d.afterAttachmentEffectGateFrozen != nil {
120+ d.afterAttachmentEffectGateFrozen(req.action, ac)
133121 }
134122 }}, participants.clients...)
135123 if !frozen.acquired || !frozen.drained {
136124 return frozen, false, errAttachmentTransition
137125 }
138- interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)
139- if d.afterRoleEffectsFrozen != nil {
140- d.afterRoleEffectsFrozen()
126+ interrupted := frozen.interrupted(nil, transportSnapshot{})
127+ if d.afterAttachmentEffectsFrozen != nil {
128+ d.afterAttachmentEffectsFrozen()
141129 }
142130 return frozen, interrupted, nil
143131 }
144132
145133 // publishAttachmentTransition revalidates the frozen source, creates an
146-// optional target, and performs publication while d.mu remains held.
134+// optional target, and performs exact membership publication under d.mu.
147135 func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
148136 d.mu.Lock()
149137 defer d.mu.Unlock()
150138
151- req.roleEffectsFrozen = true
139+ req.attachmentEffectsFrozen = true
152140 if req.target == nil {
153141 if !d.transitionSourcePreflightLocked(req) {
154142 return attachmentTransitionResult{}, errAttachmentTransition
@@ -158,7 +146,6 @@ func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (a
158146 if err != nil {
159147 return attachmentTransitionResult{}, err
160148 }
161- req.expectedTargetCurrent = nil
162149 }
163150 return d.transitionAttachmentLocked(req)
164151 }
@@ -169,24 +156,20 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
169156 return attachmentTransitionResult{}, err
170157 }
171158 if req.sourceToken != nil {
172- d.endActionRoleEffect(req.sourceToken.effect, req.action)
159+ d.endActionAttachmentEffect(req.sourceToken.effect, req.action)
173160 }
174161
175- frozen, interrupted, err := d.freezeAttachmentTransition(req, participants)
162+ frozen, _, err := d.freezeAttachmentTransition(req, participants)
176163 defer frozen.unfreeze()
177164 if err != nil {
178165 return attachmentTransitionResult{}, err
179166 }
180- req.expectedTargetTransportInterrupted = interrupted
181167
182168 result, err := d.publishAttachmentTransition(req)
183169 if err != nil {
184170 return result, err
185171 }
186- // Warm-proxy timers are external clock operations. Apply them only after
187- // frozen ownership publication and every architecture lock have completed;
188- // the lifecycle helper revalidates exact registry pointers and clients.
189- d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveRole)
172+ d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveAttachment)
190173 return result, nil
191174 }
192175
M internal/usecase/daemon/attachment_transition_prelocked_test.go +16 -17
@@ -23,23 +23,22 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure
2323 d.sessions[source.id] = source
2424 d.sessions[target.id] = target
2525
26- frozen := freezeRoleEffectGates(next, old)
26+ frozen := freezeAttachmentEffectGates(next, old)
2727 require.True(t, frozen.acquired)
2828 require.True(t, frozen.drained)
2929 defer frozen.unfreeze()
3030
3131 req := attachmentTransitionRequest{
32- source: source,
33- target: target,
34- next: next,
35- expectedRole: attachmentActive,
36- targetRole: attachmentActive,
37- expectedTransport: next.transportSnapshot(),
38- expectedTargetCurrent: old,
39- preflighted: true,
40- roleEffectsFrozen: true,
41- activateTargetTab: true,
42- targetTabIndex: 1,
32+ source: source,
33+ target: target,
34+ next: next,
35+
36+ expectedTransport: next.transportSnapshot(),
37+
38+ preflighted: true,
39+ attachmentEffectsFrozen: true,
40+ activateTargetTab: true,
41+ targetTabIndex: 1,
4342 }
4443
4544 d.mu.Lock()
@@ -52,11 +51,11 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure
5251
5352 require.ErrorIs(t, err, errAttachmentTransition)
5453 require.Nil(t, publication)
55- require.Equal(t, attachmentActive, source.attachmentRole(next))
56- require.Equal(t, attachmentDetached, target.attachmentRole(next))
57- require.Equal(t, attachmentActive, target.attachmentRole(old))
54+ require.Equal(t, true, source.attachmentRegistered(next))
55+ require.Equal(t, false, target.attachmentRegistered(next))
56+ require.Equal(t, true, target.attachmentRegistered(old))
5857 require.Same(t, source, next.currentSession())
59- require.Equal(t, uint64(0), next.roleGeneration.Load())
60- require.Equal(t, uint64(0), old.roleGeneration.Load())
58+ require.Equal(t, uint64(0), next.connectionGeneration.Load())
59+ require.Equal(t, uint64(0), old.connectionGeneration.Load())
6160 require.Equal(t, 0, testAttachmentTabIndex(target))
6261 }
M internal/usecase/daemon/attachment_transition_publish.go +68 -111
@@ -1,14 +1,10 @@
11 package daemon
22
3-import (
4- "slices"
3+import "github.com/bnema/vev/internal/domain"
54
6- "github.com/bnema/vev/internal/domain"
7-)
8-
9-// transitionSourcePreflightLocked validates an initiating role while its gate
10-// is frozen and d.mu is held. No caller may create or delete a target before
11-// this succeeds.
5+// transitionSourcePreflightLocked validates an initiating connection while its
6+// effect gate is frozen and d.mu is held. No target is created before this
7+// succeeds.
128 func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {
139 if req.sourceToken == nil || req.source == nil || req.source.core() == nil || d.sessions[req.source.core().id] != req.source {
1410 return false
@@ -18,19 +14,18 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest
1814 req.source.core().mu.Lock()
1915 defer req.source.core().mu.Unlock()
2016 coordinator := req.source.core().coordinator.Load()
21- if coordinator == nil {
22- return false
17+ if coordinator != nil {
18+ coordinator.mu.Lock()
19+ defer coordinator.mu.Unlock()
2320 }
24- coordinator.mu.Lock()
25- defer coordinator.mu.Unlock()
2621 if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {
2722 return false
2823 }
2924 return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)
3025 }
3126
32-func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
33- if token.sess == nil || token.ac == nil || token.role != attachmentActive {
27+func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool {
28+ if token.sess == nil || token.ac == nil {
3429 return false
3530 }
3631 d.mu.Lock()
@@ -43,15 +38,11 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
4338 token.sess.core().mu.Lock()
4439 defer token.sess.core().mu.Unlock()
4540 coordinator := token.sess.core().coordinator.Load()
46- if coordinator == nil {
47- return false
48- }
49- coordinator.mu.Lock()
50- defer coordinator.mu.Unlock()
51- req := attachmentTransitionRequest{
52- source: token.sess, next: token.ac, expectedRole: token.role,
53- expectedTransport: token.transport,
41+ if coordinator != nil {
42+ coordinator.mu.Lock()
43+ defer coordinator.mu.Unlock()
5444 }
45+ req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport}
5546 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
5647 }
5748
@@ -64,7 +55,7 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect
6455 return true
6556 }
6657 local, ok := localSession(source)
67- if !ok || !slices.Contains(local.tabs, expected) {
58+ if !ok || !containsTab(local.tabs, expected) {
6859 return false
6960 }
7061 expected.mu.Lock()
@@ -72,23 +63,28 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect
7263 return transferExpected || expected.floating.state != floatingVisible
7364 }
7465
66+func containsTab(tabs []*tab, want *tab) bool {
67+ for _, tb := range tabs {
68+ if tb == want {
69+ return true
70+ }
71+ }
72+ return false
73+}
74+
7575 // transitionAttachmentLocked is the publication half of transitionAttachment.
76-// Caller holds d.mu after the lock-free gate freeze/drain has completed.
76+// Caller holds d.mu after the lock-free effect freeze/drain.
7777 func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
7878 d.notices.routingMu.Lock()
7979 defer d.notices.routingMu.Unlock()
8080 return d.transitionAttachmentRoutedLocked(req)
8181 }
8282
83-// attachmentPublication is the validated, coordinator-locked input to the
84-// non-failing ownership and lease publication step. Its caller continues to
85-// own d.mu, notices.routingMu, and the ordered source/target session locks.
8683 type attachmentPublication struct {
8784 req attachmentTransitionRequest
8885 source attachmentSession
89- old *attachedClient
90- sourceCoordinator *renderCoordinator
9186 targetCoordinator *renderCoordinator
87+ sourceCoordinator *renderCoordinator
9288 releaseCoordinators func()
9389 nextGeneration uint64
9490 }
@@ -115,11 +111,9 @@ func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachment
115111 domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID
116112 }
117113
118-// validateAttachmentTransitionPrelocked performs every fallible transition
119-// check without acquiring d.mu, notices.routingMu, or either session lock.
120-// The caller holds those locks in that order, with session locks ordered by ID.
121-// On success the returned validation retains the ordered coordinator locks
122-// through publishAttachmentTransitionPrelocked.
114+// validateAttachmentTransitionPrelocked performs every fallible check before
115+// the non-failing membership and lease publication. Caller holds d.mu,
116+// notices.routingMu, and ordered source/target session locks.
123117 func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionRequest) (*attachmentPublication, error) {
124118 source := req.source
125119 if source == nil {
@@ -130,51 +124,43 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
130124 }
131125 sourceCore := source.core()
132126 targetCore := req.target.core()
133- if sourceCore == nil || targetCore == nil ||
134- !req.preflighted || !req.roleEffectsFrozen ||
135- !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) ||
136- req.next == nil || d.closing ||
137- d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target ||
127+ if sourceCore == nil || targetCore == nil || !req.preflighted || !req.attachmentEffectsFrozen ||
128+ req.next == nil || d.closing || d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target ||
138129 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
139- req.preserveRole && req.sourceToken == nil {
130+ req.preserveAttachment && req.sourceToken == nil {
140131 return nil, errAttachmentTransition
141132 }
142133
143- var targetCoordinator *renderCoordinator
144- if req.targetRole == attachmentActive {
145- targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
146- }
147134 sourceCoordinator := sourceCore.coordinator.Load()
148- if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||
149- !req.next.transportSnapshotCurrent(req.expectedTransport) {
135+ targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
136+ if req.sourceToken != nil && !transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) {
150137 return nil, errAttachmentTransition
151138 }
152-
153- old := (*attachedClient)(nil)
154- if req.expectedTargetCurrent != nil {
139+ if source != req.target && !attachmentRegisteredLocked(source, req.next) {
140+ return nil, errAttachmentTransition
141+ }
142+ if !req.preserveAttachment && source != req.target && attachmentRegisteredLocked(req.target, req.next) {
155143 return nil, errAttachmentTransition
156144 }
145+ if req.preserveAttachment && !attachmentRegisteredLocked(req.target, req.next) {
146+ return nil, errAttachmentTransition
147+ }
148+ if !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) ||
149+ req.activateTargetTab && !req.target.validTargetTabLocked(req.targetTabIndex) ||
150+ req.sourceToken != nil && !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab) {
151+ return nil, errAttachmentTransition
152+ }
153+
157154 publication := &attachmentPublication{
158- req: req, source: source, old: old,
155+ req: req, source: source,
159156 sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
160157 releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),
161158 }
162- invalid := req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) ||
163- !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab))
164- invalid = invalid || targetCoordinator != nil && targetCoordinator.lease == nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next)
165- invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil &&
166- (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next)
167- invalid = invalid || !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle)
168- invalid = invalid || req.activateTargetTab && !req.target.validTargetTabLocked(req.targetTabIndex)
169- if invalid {
170- publication.unlockCoordinators()
171- return nil, errAttachmentTransition
172- }
173159 return publication, nil
174160 }
175161
176-// applyTargetStateLocked commits navigation metadata before role ownership, as
177-// part of the same locked publication. It reports role-preserving transitions.
162+// applyTargetStateLocked commits attachment-local navigation metadata and
163+// shared environment state as part of the same publication.
178164 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
179165 req := publication.req
180166 if req.activateTargetTab {
@@ -190,70 +176,52 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool
190176 target.env = copyEnvironment(source.env)
191177 }
192178 }
193- return req.preserveRole
179+ return req.preserveAttachment
194180 }
195181
196-// publishAttachmentOwnershipLocked atomically changes session membership,
197-// generations, and currentSession while all role gates remain frozen.
182+// publishAttachmentOwnershipLocked adds/removes only the exact attachment.
183+// Existing target attachments and their independent views/transports remain
184+// untouched.
198185 func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
199186 req := publication.req
200- // Session membership is the sole routing publication. Existing attachments
201- // remain registered and retain their independent views and transports.
202- registerAttachmentSessionLocked(req.target, req.next)
203187 if publication.source != req.target {
204188 unregisterAttachmentSessionLocked(publication.source, req.next)
205189 }
206- publication.nextGeneration = req.next.roleGeneration.Add(1)
190+ registerAttachmentSessionLocked(req.target, req.next)
191+ publication.nextGeneration = req.next.connectionGeneration.Add(1)
207192 req.next.setSession(req.target)
208193 }
209194
210-// buildAttachmentPostcommitPlanLocked binds coordinator lifecycle cleanup and
211-// constructs the exact published tokens after ownership has committed.
212195 func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) attachmentTransitionResult {
213196 req := publication.req
214197 result := attachmentTransitionResult{}
215- if publication.source != req.target && req.expectedRole == attachmentActive && publication.sourceCoordinator != nil {
198+ if publication.source != req.target && publication.sourceCoordinator != nil {
216199 result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next))
217200 }
218201 var lease *attachmentLease
219- if publication.targetCoordinator != nil {
220- var cleanup renderLifecycleCleanup
202+ if publication.targetCoordinator != nil && !req.preserveAttachment {
221203 if publication.targetCoordinator.lease == nil {
204+ var cleanup renderLifecycleCleanup
222205 cleanup, lease = publication.targetCoordinator.beginReplaceLocked(nil, req.next, req.ready)
223206 result.cleanups = append(result.cleanups, cleanup)
224- } else {
225- // Secondary attachments use membership admission; the coordinator's
226- // optional primary lease is not reused by another attachment.
227- lease = nil
207+ } else if publication.targetCoordinator.lease.active && publication.targetCoordinator.lease.attachment == req.next {
208+ lease = publication.targetCoordinator.lease
228209 }
229210 }
230- result.published = attachmentRoleToken{
231- sess: req.target,
232- ac: req.next,
233- role: req.targetRole,
211+ result.published = attachmentConnectionToken{
212+ sess: req.target, ac: req.next,
234213 generation: publication.nextGeneration,
235- transport: req.expectedTransport,
236- lease: lease,
237- rebase: publication.source != req.target && req.expectedRole == attachmentActive,
214+ transport: req.expectedTransport, lease: lease,
215+ rebase: publication.source != req.target,
238216 }
239-
240- // Membership, currentSession, lease, generation, and both exact admission
241- // capabilities become visible as one frozen publication. The gates remain
242- // frozen until every architecture lock has been released.
243- if req.roleEffectsFrozen {
244- req.next.publishFrozenRoleCapability(result.published)
245- if result.displaced.ac != nil {
246- result.displaced.ac.publishFrozenRoleCapability(result.displaced)
247- }
217+ if req.attachmentEffectsFrozen {
218+ req.next.publishFrozenAttachmentCapability(result.published)
248219 }
249220 return result
250221 }
251222
252-// publishAttachmentTransitionPrelocked performs only non-failing metadata,
253-// ownership, generation, and coordinator-lease publication. The caller retains
254-// every lock required by validateAttachmentTransitionPrelocked.
255223 func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {
256- if publication.req.preserveRole {
224+ if publication.req.preserveAttachment {
257225 d.applyTargetStateLocked(publication)
258226 return attachmentTransitionResult{published: *publication.req.sourceToken}
259227 }
@@ -262,9 +230,6 @@ func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPub
262230 return buildAttachmentPostcommitPlanLocked(publication)
263231 }
264232
265-// transitionAttachmentRoutedLocked is the ordinary lock-acquiring wrapper for
266-// the composable prelocked validation/publication seam. Caller holds d.mu and
267-// notices.routingMu; this function acquires ordered source/target session locks.
268233 func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
269234 source := req.source
270235 if source == nil {
@@ -284,15 +249,7 @@ func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionReques
284249 return d.publishAttachmentTransitionPrelocked(publication), nil
285250 }
286251
287-func validAttachmentTransitionRole(role attachmentRole, expected bool) bool {
288- if role == attachmentActive || role == attachmentSnatched {
289- return true
290- }
291- return expected && role == attachmentDetached
292-}
293-
294252 // lockAttachmentSessions gives every two-session transition one stable order.
295-// Session IDs are immutable and unique while their lifecycles are registered.
296253 func lockAttachmentSessions(a, b attachmentSession) func() {
297254 aCore := attachmentSessionCore(a)
298255 bCore := attachmentSessionCore(b)
@@ -316,7 +273,7 @@ func lockAttachmentSessions(a, b attachmentSession) func() {
316273 }
317274
318275 // lockAttachmentCoordinators follows the same immutable session-ID order as
319-// lockAttachmentSessions. Callers already hold the ordered session locks.
276+// lockAttachmentSessions. Callers already hold ordered session locks.
320277 func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() {
321278 if aCoordinator == nil && bCoordinator == nil {
322279 return func() {}
M internal/usecase/daemon/attention.go +3 -3
@@ -50,10 +50,10 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
5050 // happened — it also covers "no target exists", which is routine and not an
5151 // error. Only a failure to reach a target that does exist is a genuine error.
5252 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
53- return d.jumpAttentionForRole(sess, ac, attachmentRoleToken{})
53+ return d.jumpAttentionForAttachment(sess, ac, attachmentConnectionToken{})
5454 }
5555
56-func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token attachmentRoleToken) error {
56+func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, token attachmentConnectionToken) error {
5757 if sess == nil || ac == nil {
5858 return nil
5959 }
@@ -73,7 +73,7 @@ func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token a
7373 if token.ac == nil {
7474 return d.switchToTarget(sess, ac, pickerTarget)
7575 }
76- return d.switchActiveTargetForRole(token, pickerTarget)
76+ return d.switchActiveTargetForAttachment(token, pickerTarget)
7777 }
7878
7979 func oldestAttentionTab(sess *session) (int, bool) {
M internal/usecase/daemon/client.go +34 -35
@@ -42,14 +42,14 @@ type attachedClient struct {
4242 overlays *overlayRuntime
4343 overlayOnce sync.Once
4444 clientID [16]byte
45- // roleGeneration is the wire-facing capability generation. roleEffects is
46- // the linearization gate for every role-bound observable operation. A role
45+ // connectionGeneration is the wire-facing capability generation. attachmentEffects is
46+ // the linearization gate for every attachment-bound observable operation. A role
4747 // transition freezes and drains this gate before changing either generation
4848 // or the session/coordinator registries.
49- roleGeneration atomic.Uint64
50- roleEffects roleEffectGate
51- resumeCapable bool
52- resumeToken uint64
49+ connectionGeneration atomic.Uint64
50+ attachmentEffects attachmentEffectGate
51+ resumeCapable bool
52+ resumeToken uint64
5353 // resumeClaimToken is non-zero only between a parked resume claim and its
5454 // successful Welcome. It lets a failed pre-claim handshake restore the old
5555 // credential instead of consuming it.
@@ -352,7 +352,7 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po
352352 // attachment's current transport incarnation and admits ticket's interruptible
353353 // transport send. The caller must already hold ac.sendMu and, on success, owns
354354 // both the send and its matching ticket.endTransportSend.
355-func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSnapshot, ticket *roleEffectTicket) error {
355+func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSnapshot, ticket *attachmentEffectTicket) error {
356356 if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {
357357 return errTransportReplaced
358358 }
@@ -362,10 +362,10 @@ func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSna
362362 return nil
363363 }
364364
365-func (ac *attachedClient) sendExpectedTransportForRole(expected transportSnapshot, f ports.Frame, ticket *roleEffectTicket) error {
365+func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportSnapshot, f ports.Frame, ticket *attachmentEffectTicket) error {
366366 ac.sendMu.Lock()
367367 defer ac.sendMu.Unlock()
368- // A role-bound send requires a live ticket, and reports every rejection,
368+ // A attachment-bound send requires a live ticket, and reports every rejection,
369369 // including a replaced transport, as a lost attachment transition.
370370 if ticket == nil {
371371 return errAttachmentTransition
@@ -523,15 +523,14 @@ type attachClientOptions struct {
523523 proxied bool
524524 }
525525
526-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient, error) {
526+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
527527 d.mu.Lock()
528528 ac := d.prepareAttachedClientLocked(tr, sz, opts)
529529 d.mu.Unlock()
530530 result, err := d.transitionAttachment(attachmentTransitionRequest{
531- target: sess,
532- next: ac,
533- expectedRole: attachmentDetached,
534- targetRole: attachmentActive,
531+ target: sess,
532+ next: ac,
533+
535534 expectedTransport: ac.transportSnapshot(),
536535 ready: false,
537536 })
@@ -539,11 +538,11 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
539538 if tr != nil {
540539 _ = tr.Close()
541540 }
542- return nil, nil, err
541+ return nil, err
543542 }
544543 d.finishAttachedClient(sess, ac, opts)
545544 d.deferAttachmentTransitionCleanups(result)
546- return ac, result.displaced.ac, nil
545+ return ac, nil
547546 }
548547
549548 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
@@ -553,7 +552,7 @@ func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts at
553552 }
554553
555554 // prepareAttachedClientLocked allocates one detached attachment. Caller holds
556-// d.mu only to allocate its resume token; role publication happens later after
555+// d.mu only to allocate its resume token; attachment publication happens later after
557556 // the caller releases every architecture lock.
558557 func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient {
559558 resumeToken := uint64(0)
@@ -664,20 +663,20 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) {
664663 }
665664
666665 // firstPaintForTransition is the only active post-transition paint entry
667-// point. It rejects a stale role capability before rebasing output and carries
666+// point. It rejects a stale attachment capability before rebasing output and carries
668667 // the exact coordinator lease through every resize and render effect.
669-func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
670- if !token.activeCurrent() {
668+func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
669+ if !token.attachmentCurrent() {
671670 return false
672671 }
673- ticket, admitted := token.ac.beginRoleEffect(token)
672+ ticket, admitted := token.ac.beginAttachmentEffect(token)
674673 if !admitted {
675674 return false
676675 }
677676 token.effect = ticket
678- defer token.endRoleEffect()
679- if d.afterRoleEffectAdmitted != nil {
680- d.afterRoleEffectAdmitted(token)
677+ defer token.endAttachmentEffect()
678+ if d.afterAttachmentEffectAdmitted != nil {
679+ d.afterAttachmentEffectAdmitted(token)
681680 }
682681 if token.rebase {
683682 if d.beforeFirstPaintSendWait != nil {
@@ -708,7 +707,7 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
708707 // firstPaint guarantees the freshly attached client sees the full screen: if
709708 // the tab size differs from the client's it resizes first, then immediately
710709 // emits a full redraw. It is retained for direct test/headless setup; active
711-// role transitions use firstPaintForTransition with their exact lease.
710+// attachment transitions use firstPaintForTransition with their exact lease.
712711 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
713712 d.firstPaintWithLease(sess, ac, clientSize, nil)
714713 }
@@ -775,17 +774,17 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
775774 d.invalidateRender(sess, ac, true, "client.go")
776775 }
777776
778-func (d *Daemon) applyThemeForRole(token attachmentRoleToken, msg ports.Theme) bool {
779- if !token.activeEffect() {
777+func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) bool {
778+ if !token.attachmentEffectCurrent() {
780779 return false
781780 }
782781 clientTheme := themeFromMessage(msg)
783782 token.ac.setClientTheme(clientTheme)
784783 sess, ok := localSession(token.sess)
785- if !ok || !token.activeEffect() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
784+ if !ok || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
786785 return false
787786 }
788- if !token.activeEffect() {
787+ if !token.attachmentEffectCurrent() {
789788 return false
790789 }
791790 if rc := token.sess.core().coordinator.Load(); rc != nil {
@@ -811,23 +810,23 @@ func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice po
811810 token := sess.attachmentToken(ac, tr)
812811 if token.lease == nil {
813812 // Direct test/headless callers retain pointer-based routing. Production
814- // client frames always carry the exact lease through the role path below.
813+ // client frames always carry the exact lease through the attachment path below.
815814 d.handleClientNoticeDirect(sess, ac, notice)
816815 return
817816 }
818- d.handleClientNoticeForRole(token, notice)
817+ d.handleClientNoticeForAttachment(token, notice)
819818 }
820819
821-func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice ports.ClientNotice) {
820+func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken, notice ports.ClientNotice) {
822821 d.notices.routingMu.Lock()
823- if !token.activeEffect() {
822+ if !token.attachmentEffectCurrent() {
824823 d.notices.routingMu.Unlock()
825824 return
826825 }
827826 if d.notices.beforeClientNoticeMutation != nil {
828827 d.notices.beforeClientNoticeMutation()
829828 }
830- if !token.activeEffect() {
829+ if !token.attachmentEffectCurrent() {
831830 d.notices.routingMu.Unlock()
832831 return
833832 }
@@ -838,7 +837,7 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por
838837 }
839838 repaint := d.mutateClientNotice(sess, token.ac, notice)
840839 d.notices.routingMu.Unlock()
841- if repaint && token.activeEffect() {
840+ if repaint && token.attachmentEffectCurrent() {
842841 d.repaintForNotice(token.ac)
843842 }
844843 }
M internal/usecase/daemon/client_frame_routing.go +43 -43
@@ -8,31 +8,31 @@ import (
88 "github.com/bnema/vev/internal/usecase/command"
99 )
1010
11-const connectionRoleAttempts = 8
11+const connectionSnapshotAttempts = 8
1212
13-// currentConnectionRole retries the lock-free session-to-role snapshot when a
14-// handoff lands between those reads. The role gate still performs the final
13+// currentAttachmentConnection retries the lock-free session-to-role snapshot when a
14+// handoff lands between those reads. The attachment gate still performs the final
1515 // mutation admission after this function returns.
16-func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentRoleToken, bool) {
17- for range connectionRoleAttempts {
16+func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentConnectionToken, bool) {
17+ for range connectionSnapshotAttempts {
1818 if !ac.currentTransportIs(tr) {
1919 break
2020 }
2121 sess := ac.currentAttachmentSession()
2222 if sess == nil {
23- return nil, attachmentRoleToken{}, false
23+ return nil, attachmentConnectionToken{}, false
2424 }
2525 if d.afterConnectionSessionSnapshot != nil {
2626 d.afterConnectionSessionSnapshot(sess)
2727 }
2828 token := attachmentToken(sess, ac, tr)
29- if token.role == attachmentDetached || ac.currentAttachmentSession() != sess {
29+ if token.ac == nil || ac.currentAttachmentSession() != sess {
3030 runtime.Gosched()
3131 continue
3232 }
3333 return sess, token, true
3434 }
35- return nil, attachmentRoleToken{}, false
35+ return nil, attachmentConnectionToken{}, false
3636 }
3737
3838 // runConnLoop is the per-connection input router: it pumps client messages
@@ -49,8 +49,8 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
4949 }
5050 f, err := tr.Recv()
5151 if err != nil {
52- for range connectionRoleAttempts {
53- sess, _, ok := d.currentConnectionRole(ac, tr)
52+ for range connectionSnapshotAttempts {
53+ sess, _, ok := d.currentAttachmentConnection(ac, tr)
5454 if !ok {
5555 return
5656 }
@@ -68,40 +68,40 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
6868 }
6969 return
7070 }
71- _, token, ok := d.currentConnectionRole(ac, tr)
71+ _, token, ok := d.currentAttachmentConnection(ac, tr)
7272 if !ok {
7373 return
7474 }
75- if token.role == attachmentActive && d.handleActiveClientFrame(token, f) {
75+ if d.handleAttachmentClientFrame(token, f) {
7676 return
7777 }
7878 }
7979 }
8080
81-// handleActiveClientFrame owns the attached-client protocol.
82-func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool {
83- if !token.activeEffect() {
81+// handleAttachmentClientFrame owns the attached-client protocol.
82+func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f ports.Frame) bool {
83+ if !token.attachmentEffectCurrent() {
8484 return false
8585 }
86- if d.afterActiveFrameDispatch != nil {
87- d.afterActiveFrameDispatch(token)
86+ if d.afterAttachmentFrameDispatch != nil {
87+ d.afterAttachmentFrameDispatch(token)
8888 }
89- ticket, admitted := token.ac.beginRoleEffect(token)
89+ ticket, admitted := token.ac.beginAttachmentEffect(token)
9090 if !admitted {
9191 return false
9292 }
9393 token.effect = ticket
94- defer token.endRoleEffect()
95- if d.afterRoleEffectAdmitted != nil {
96- d.afterRoleEffectAdmitted(token)
94+ defer token.endAttachmentEffect()
95+ if d.afterAttachmentEffectAdmitted != nil {
96+ d.afterAttachmentEffectAdmitted(token)
9797 }
9898 switch f.Type {
9999 case ports.MsgInput:
100100 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
101- d.handleSequencedInputForRole(token, in.InputSeq, in.Data)
101+ d.handleSequencedInputForAttachment(token, in.InputSeq, in.Data)
102102 }
103103 case ports.MsgResize:
104- if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() {
104+ if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() {
105105 if sess, ok := localSession(token.sess); ok {
106106 d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)
107107 } else if proxy, ok := token.sess.(*proxySession); ok {
@@ -110,30 +110,30 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
110110 }
111111 case ports.MsgTheme:
112112 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
113- d.applyThemeForRole(token, th)
113+ d.applyThemeForAttachment(token, th)
114114 }
115115 case ports.MsgImagePush:
116116 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
117- d.handleSequencedImagePushForRole(token, ip.InputSeq, ip)
117+ d.handleSequencedImagePushForAttachment(token, ip.InputSeq, ip)
118118 }
119119 case ports.MsgClientNotice:
120120 if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
121- d.handleClientNoticeForRole(token, notice)
121+ d.handleClientNoticeForAttachment(token, notice)
122122 } else {
123123 d.log.Warn("malformed client notice", "err", derr)
124124 }
125125 case ports.MsgDetach:
126- if token.activeEffect() {
127- d.clientGoneForRole(token, true)
126+ if token.attachmentEffectCurrent() {
127+ d.clientGoneForAttachment(token, true)
128128 return true
129129 }
130130 case ports.MsgAck:
131131 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
132- d.ackActiveOutput(token, ack.AckedStateNum)
132+ d.ackOutput(token, ack.AckedStateNum)
133133 }
134134 case ports.MsgOutputResetRequest:
135135 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {
136- d.resetActiveOutput(token)
136+ d.resetOutput(token)
137137 }
138138 case ports.MsgCommand:
139139 if token.ac.proxied {
@@ -142,16 +142,16 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
142142 return false
143143 }
144144 result := d.executeAttachedCommand(token, request)
145- if err := token.sendActiveControl(frameCommandResult(result)); err != nil {
146- token.endRoleEffect()
147- d.detachOnRoleSendError(token, token.transport.transport)
145+ if err := token.sendControl(frameCommandResult(result)); err != nil {
146+ token.endAttachmentEffect()
147+ d.detachOnAttachmentSendError(token, token.transport.transport)
148148 return true
149149 }
150150 }
151151 case ports.MsgPing:
152- if err := token.sendActiveControl(framePong()); err != nil {
153- token.endRoleEffect()
154- d.detachOnRoleSendError(token, token.transport.transport)
152+ if err := token.sendControl(framePong()); err != nil {
153+ token.endAttachmentEffect()
154+ d.detachOnAttachmentSendError(token, token.transport.transport)
155155 return true
156156 }
157157 default:
@@ -161,7 +161,7 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
161161 return false
162162 }
163163
164-func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports.CommandRequest) ports.CommandResult {
164+func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request ports.CommandRequest) ports.CommandResult {
165165 result := ports.CommandResult{RequestID: request.RequestID}
166166 if request.Version != ports.ProtocolVersion {
167167 result.Code = ports.ErrInvalidCommandArgs
@@ -189,7 +189,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports
189189 return result
190190 }
191191 sess, ok := localSession(token.sess)
192- if !ok || !token.activeEffect() {
192+ if !ok || !token.attachmentEffectCurrent() {
193193 result.Code = ports.ErrNoSuchTarget
194194 result.Text = "attached session is no longer active"
195195 return result
@@ -216,7 +216,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports
216216 return result
217217 }
218218
219- // The frame's role token is the target capability. No registry/name lookup is
219+ // The frame's attachment token is the target capability. No registry/name lookup is
220220 // performed, so the command cannot escape to another remote session.
221221 sess.dispatchMu.Lock()
222222 err := cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)
@@ -241,16 +241,16 @@ func proxyAttachedCommandOwnedRemotely(slug string) bool {
241241 return proxyPaletteCommandOwnership(slug) == proxyPaletteRemote
242242 }
243243
244-// resetActiveOutput rebases only the exact proxied role and transport admitted
244+// resetOutput rebases only the exact proxied attachment and transport admitted
245245 // by the frame router. sendMu serializes the rebase with every output send;
246246 // transport revalidation under that lock rejects a link replaced while waiting.
247-func (d *Daemon) resetActiveOutput(token attachmentRoleToken) bool {
247+func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
248248 ac := token.ac
249249 if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() {
250250 return false
251251 }
252252 ac.sendMu.Lock()
253- if token.effect.ended.Load() || !ac.proxied || !token.activeCurrent() || ac.output == nil {
253+ if token.effect.ended.Load() || !ac.proxied || !token.attachmentCurrent() || ac.output == nil {
254254 ac.sendMu.Unlock()
255255 return false
256256 }
@@ -263,7 +263,7 @@ func (d *Daemon) resetActiveOutput(token attachmentRoleToken) bool {
263263 })
264264 }
265265
266-func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {
266+func (d *Daemon) ackOutput(token attachmentConnectionToken, state uint64) bool {
267267 ac := token.ac
268268 if token.effect == nil || token.effect.ended.Load() {
269269 return false
M internal/usecase/daemon/client_transport_errors.go +19 -19
@@ -28,14 +28,14 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
2828 d.finishClientGone(sess, ac, failed, explicit)
2929 }
3030
31-func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {
31+func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explicit bool) bool {
3232 if token.effect == nil {
3333 return false
3434 }
3535 token.effect.bindActionEnd(d, "detach")
3636 token.effect.End()
3737 if proxy, ok := token.sess.(*proxySession); ok {
38- if !d.detachIfRoleCurrent(token) {
38+ if !d.detachIfAttachmentCurrent(token) {
3939 return false
4040 }
4141 d.finishProxyClientGone(proxy, token.ac, token.transport.transport, explicit)
@@ -50,7 +50,7 @@ func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) boo
5050 if !explicit {
5151 parkingToken = d.markParkingInFlight(sess, token.ac)
5252 }
53- if !d.detachIfRoleCurrent(token) {
53+ if !d.detachIfAttachmentCurrent(token) {
5454 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
5555 return false
5656 }
@@ -143,7 +143,7 @@ func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, fai
143143 }
144144
145145 // finishProxyClientGone retires external client ownership after the exact proxy
146-// role has been unpublished. Warm-timer publication remains with the caller so
146+// attachment has been unpublished. Warm-timer publication remains with the caller so
147147 // each detach path can arm once, after membership is empty.
148148 func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) {
149149 if rc := p.coordinator.Load(); rc != nil {
@@ -160,18 +160,18 @@ func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, fail
160160 d.log.Warn("detached proxy client", "host", p.key.Host, "session", p.key.Name, "explicit", explicit)
161161 }
162162
163-func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {
164- d.detachOnRoleSendErrorUntil(token, failed, nil)
163+func (d *Daemon) detachOnAttachmentSendError(token attachmentConnectionToken, failed ports.Transport) {
164+ d.detachOnAttachmentSendErrorUntil(token, failed, nil)
165165 }
166166
167-func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) {
167+func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToken, failed ports.Transport, done func() <-chan struct{}) {
168168 // A delayed sender may report after the client has rebound. Only the exact
169- // transport captured by this role is allowed to detach either lifecycle.
169+ // transport captured by this attachment is allowed to detach either lifecycle.
170170 if failed == nil || failed != token.transport.transport {
171171 return
172172 }
173173 if proxy, ok := token.sess.(*proxySession); ok {
174- if d.detachIfRoleCurrentUntil(token, done) {
174+ if d.detachIfAttachmentCurrentUntil(token, done) {
175175 d.finishProxyClientGone(proxy, token.ac, failed, false)
176176 d.armProxyWarm(proxy)
177177 }
@@ -182,30 +182,30 @@ func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed po
182182 return
183183 }
184184 parkingToken := d.markParkingInFlight(sess, token.ac)
185- if d.detachIfRoleCurrentUntil(token, done) {
185+ if d.detachIfAttachmentCurrentUntil(token, done) {
186186 d.finishSendErrorDetach(sess, token.ac, failed)
187187 return
188188 }
189189 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
190190 }
191191
192-// reserveRoleSendErrorCleanup accounts for cleanup before End releases the
193-// role gate. This closes the WaitGroup Add/Wait race with terminal teardown;
192+// reserveAttachmentSendErrorCleanup accounts for cleanup before End releases the
193+// attachment gate. This closes the WaitGroup Add/Wait race with terminal teardown;
194194 // the returned launch function must be invoked immediately after ticket End.
195-func (d *Daemon) reserveRoleSendErrorCleanup(token attachmentRoleToken, failed ports.Transport) func() {
195+func (d *Daemon) reserveAttachmentSendErrorCleanup(token attachmentConnectionToken, failed ports.Transport) func() {
196196 d.attachmentCleanupWg.Add(1)
197197 return func() {
198198 go func() {
199199 defer d.attachmentCleanupWg.Done()
200- if d.afterRoleSendErrorCleanup != nil {
201- defer d.afterRoleSendErrorCleanup()
200+ if d.afterAttachmentSendErrorCleanup != nil {
201+ defer d.afterAttachmentSendErrorCleanup()
202202 }
203- if d.beforeRoleSendErrorCleanup != nil {
204- d.beforeRoleSendErrorCleanup(token)
203+ if d.beforeAttachmentSendErrorCleanup != nil {
204+ d.beforeAttachmentSendErrorCleanup(token)
205205 }
206- deadline := newRoleEffectDrainDeadline(d.clock)
206+ deadline := newAttachmentEffectDrainDeadline(d.clock)
207207 defer deadline.stop()
208- d.detachOnRoleSendErrorUntil(token, failed, deadline.Done)
208+ d.detachOnAttachmentSendErrorUntil(token, failed, deadline.Done)
209209 }()
210210 }
211211 }
M internal/usecase/daemon/clipboard.go +20 -20
@@ -26,27 +26,27 @@ var (
2626 clipPasteCloseMarker = []byte("\x1b[201~")
2727 )
2828
29-func (d *Daemon) handleSequencedImagePushForRole(token attachmentRoleToken, _ uint64, ip ports.ImagePush) {
30- if !token.activeEffect() {
29+func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {
30+ if !token.attachmentEffectCurrent() {
3131 return
3232 }
33- d.handleImagePushForRole(token, ip)
33+ d.handleImagePushForAttachment(token, ip)
3434 }
3535
36-func (d *Daemon) handleImagePushForRole(token attachmentRoleToken, ip ports.ImagePush) {
37- if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.activeEffect() {
36+func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {
37+ if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {
3838 return
3939 }
40- path, err := d.writeClipboardImageForRole(token, ip)
40+ path, err := d.writeClipboardImageForAttachment(token, ip)
4141 if err != nil {
4242 d.log.Error("writing clipboard image failed", "err", err)
4343 return
4444 }
45- if !token.activeEffect() {
45+ if !token.attachmentEffectCurrent() {
4646 _ = os.Remove(path)
4747 return
4848 }
49- d.injectClipboardPathForRole(token, path)
49+ d.injectClipboardPathForAttachment(token, path)
5050 }
5151
5252 // handleImagePush writes an ImagePush's bytes to a temp file and injects the
@@ -84,7 +84,7 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
8484 return path, nil
8585 }
8686
87-func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.ImagePush) (string, error) {
87+func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {
8888 path, err := d.createClipboardImage(ip)
8989 if err != nil {
9090 return "", err
@@ -95,7 +95,7 @@ func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.
9595 return "", errAttachmentTransition
9696 }
9797 sess.mu.Lock()
98- if !token.activeEffectSessionLocked() {
98+ if !token.attachmentEffectCurrentSessionLocked() {
9999 sess.mu.Unlock()
100100 _ = os.Remove(path)
101101 return "", errAttachmentTransition
@@ -134,15 +134,15 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
134134 d.injectClipboardPathToTarget(sess, path, nil)
135135 }
136136
137-func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) {
137+func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138138 sess, ok := localSession(token.sess)
139- if !ok || !token.activeEffect() {
139+ if !ok || !token.attachmentEffectCurrent() {
140140 return
141141 }
142142 d.injectClipboardPathToTarget(sess, path, &token)
143143 }
144144
145-func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) {
145+func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentConnectionToken) {
146146 var ac *attachedClient
147147 if token != nil {
148148 ac = token.ac
@@ -175,7 +175,7 @@ func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *
175175 wrapped = append(wrapped, clipPasteCloseMarker...)
176176 data = wrapped
177177 }
178- if token != nil && !token.activeEffect() {
178+ if token != nil && !token.attachmentEffectCurrent() {
179179 return
180180 }
181181 d.writeToPane(sess, p, data)
@@ -202,7 +202,7 @@ func clipboardExt(mime string) string {
202202
203203 type clipboardForward struct {
204204 owner paneEffectLease
205- token attachmentRoleToken
205+ token attachmentConnectionToken
206206 seq []byte
207207 }
208208
@@ -238,7 +238,7 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
238238 for _, ac := range attachments {
239239 transport := ac.transport()
240240 token := sess.attachmentToken(ac, transport)
241- if token.activeCurrent() {
241+ if token.attachmentCurrent() {
242242 items = append(items, clipboardForward{owner: owner, token: token, seq: seq})
243243 }
244244 }
@@ -254,7 +254,7 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
254254 sess.mu.Unlock()
255255 }
256256
257-func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *roleEffectTicket) (ports.Transport, error) {
257+func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *attachmentEffectTicket) (ports.Transport, error) {
258258 token := item.token
259259 if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil {
260260 return token.transport.transport, errAttachmentTransition
@@ -294,7 +294,7 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *role
294294 // admission and marks the transport interval while ownership publication is
295295 // excluded. Publication therefore orders either before this check (the send is
296296 // dropped) or after transport admission (the send belongs to the old owner).
297-func beginClipboardOwnerSend(lease paneEffectLease, ticket *roleEffectTicket, expected transportSnapshot) bool {
297+func beginClipboardOwnerSend(lease paneEffectLease, ticket *attachmentEffectTicket, expected transportSnapshot) bool {
298298 if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil {
299299 return false
300300 }
@@ -325,7 +325,7 @@ func (d *Daemon) clipboardWorker(sess *session) {
325325 if !item.owner.Current() {
326326 continue
327327 }
328- ticket, admitted := item.token.ac.beginRoleEffect(item.token)
328+ ticket, admitted := item.token.ac.beginAttachmentEffect(item.token)
329329 if !admitted {
330330 continue
331331 }
@@ -335,7 +335,7 @@ func (d *Daemon) clipboardWorker(sess *session) {
335335 }
336336 ticket.End()
337337 if err != nil && item.owner.Current() {
338- d.detachOnRoleSendError(item.token, failed)
338+ d.detachOnAttachmentSendError(item.token, failed)
339339 }
340340 }
341341 }
M internal/usecase/daemon/clipboard_test.go +4 -5
@@ -206,7 +206,7 @@ func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClie
206206 rc := d.attachCoordinator(sess, nil, ac, true)
207207 token := sess.attachmentToken(ac, tr)
208208 token.lease = rc.attachmentLease(ac)
209- ac.publishRoleCapability(token)
209+ ac.publishAttachmentCapability(token)
210210 }
211211
212212 func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {
@@ -419,7 +419,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
419419 close(workerDone)
420420 }()
421421 require.Eventually(t, func() bool {
422- return ac.roleEffects.inFlightCount() == 1
422+ return ac.attachmentEffects.inFlightCount() == 1
423423 }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")
424424
425425 sourceTab := testAttachmentTab(sess)
@@ -476,7 +476,7 @@ func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) {
476476 rc := d.attachCoordinator(sess, nil, old, true)
477477 token := sess.attachmentToken(old, oldTransport)
478478 token.lease = rc.attachmentLease(old)
479- old.publishRoleCapability(token)
479+ old.publishAttachmentCapability(token)
480480
481481 sess.mu.Lock()
482482 sess.clipboardWorkerRunning = true
@@ -487,8 +487,7 @@ func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) {
487487 next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
488488 next.initOverlays()
489489 _, err := d.transitionAttachment(attachmentTransitionRequest{
490- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
491- expectedTransport: next.transportSnapshot(), ready: true,
490+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
492491 })
493492 require.NoError(t, err)
494493
M internal/usecase/daemon/daemon.go +42 -45
@@ -147,19 +147,19 @@ type Daemon struct {
147147 // afterConnectionSessionSnapshot is a deterministic test seam after the
148148 // connection loop reads its current session and before it captures that role.
149149 afterConnectionSessionSnapshot func(attachmentSession)
150- // afterActiveFrameDispatch is a deterministic test seam after the connection
151- // loop's first role check and before a decoded active frame takes effect.
152- afterActiveFrameDispatch func(attachmentRoleToken)
153- // afterRoleEffectParticipantsSnapshotted observes immutable role-gate
150+ // afterAttachmentFrameDispatch is a deterministic test seam after the
151+ // connection loop snapshots a token and before a decoded frame takes effect.
152+ afterAttachmentFrameDispatch func(attachmentConnectionToken)
153+ // afterAttachmentEffectParticipantsSnapshotted observes immutable role-gate
154154 // participants after architecture preflight locks are released and before
155155 // the globally ordered freeze begins.
156- afterRoleEffectParticipantsSnapshotted func(string, []*attachedClient)
157- // afterRoleEffectGateFrozen observes each participant after it is frozen in
156+ afterAttachmentEffectParticipantsSnapshotted func(string, []*attachedClient)
157+ // afterAttachmentEffectGateFrozen observes each participant after it is frozen in
158158 // immutable identity order and before drain/publication continues.
159- afterRoleEffectGateFrozen func(string, *attachedClient)
160- // afterRoleEffectsFrozen observes the lock-free boundary after all affected
159+ afterAttachmentEffectGateFrozen func(string, *attachedClient)
160+ // afterAttachmentEffectsFrozen observes the lock-free boundary after all affected
161161 // attachment gates are frozen and drained, before architecture publication.
162- afterRoleEffectsFrozen func()
162+ afterAttachmentEffectsFrozen func()
163163 // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only
164164 // seams. They run before role/fence work and after the pre-fence source
165165 // attachment snapshot respectively. beforeMovePaneCommit runs inside the
@@ -171,9 +171,9 @@ type Daemon struct {
171171 afterMoveTabSourceSnapshot func()
172172 beforeMovePaneCommit func()
173173 beforeMoveTabCommit func()
174- // afterDetachRoleEffectsFrozen observes terminal detach after it wins the
174+ // afterDetachAttachmentEffectsFrozen observes terminal detach after it wins the
175175 // attachment gate but before it checks session ownership.
176- afterDetachRoleEffectsFrozen func()
176+ afterDetachAttachmentEffectsFrozen func()
177177 // beforeClientGoneDetach pauses clientGone after the stale-transport
178178 // precheck and before exact transport/incarnation detach validation.
179179 beforeClientGoneDetach func()
@@ -195,34 +195,34 @@ type Daemon struct {
195195 // lookup and before the attachment send lock, so tests can consume or
196196 // replace the credential while the handshake waits.
197197 beforeResumeParkedSendMu func()
198- // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint
198+ // afterAttachmentEffectAdmitted is a deterministic test seam after a frame/paint
199199 // reserves its exact capability and before its first observable mutation.
200- afterRoleEffectAdmitted func(attachmentRoleToken)
200+ afterAttachmentEffectAdmitted func(attachmentConnectionToken)
201201 // beforeFirstPaintSendWait is a test-only seam immediately before a
202202 // transition first paint waits for the attachment send lock.
203- beforeFirstPaintSendWait func(attachmentRoleToken)
203+ beforeFirstPaintSendWait func(attachmentConnectionToken)
204204 // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a
205205 // fresh exact capability before producing PTY, action, or overlay effects.
206206 afterDelayedKeyEffectAttempt func(bool)
207- // afterActionRoleEffectEnded observes action-specific admission release. It
207+ // afterActionAttachmentEffectEnded observes action-specific admission release. It
208208 // is a deterministic seam for proving no role-bound mutation follows release.
209- afterActionRoleEffectEnded func(string)
210- // beforeRoleSendErrorCleanup pauses asynchronous render-failure retirement
209+ afterActionAttachmentEffectEnded func(string)
210+ // beforeAttachmentSendErrorCleanup pauses asynchronous render-failure retirement
211211 // after the render ticket ends and before exact lifecycle validation.
212- beforeRoleSendErrorCleanup func(attachmentRoleToken)
213- afterRoleSendErrorCleanup func()
214- ptys ports.PTYFactory
215- clock ports.Clock
216- log *slog.Logger
217- runtimeObserver ports.RuntimeObserver
218- baseEnv []string
219- shell string
220- shellArgs []string
221- shellOverride bool
222- persistEnabled bool
223- catalogue ports.Catalogue
224- catalogueRecords []domain.CatalogueRecord
225- catalogueRecordsProvided bool
212+ beforeAttachmentSendErrorCleanup func(attachmentConnectionToken)
213+ afterAttachmentSendErrorCleanup func()
214+ ptys ports.PTYFactory
215+ clock ports.Clock
216+ log *slog.Logger
217+ runtimeObserver ports.RuntimeObserver
218+ baseEnv []string
219+ shell string
220+ shellArgs []string
221+ shellOverride bool
222+ persistEnabled bool
223+ catalogue ports.Catalogue
224+ catalogueRecords []domain.CatalogueRecord
225+ catalogueRecordsProvided bool
226226 // snapshotRepository is the sole checkpoint storage contract.
227227 snapshotRepository ports.SnapshotRepository
228228 recovery *recoveryusecase.Coordinator
@@ -1099,9 +1099,8 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
10991099 expected := ac.transportSnapshot()
11001100 welcomeToken := sess.attachmentToken(ac, tr)
11011101 welcomeToken.lease = lease
1102- welcomeTicket, admitted := ac.beginRoleEffect(welcomeToken)
1103- validRole := welcomeToken.role == attachmentActive
1104- if expected.transport != tr || !admitted || !validRole {
1102+ welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1103+ if expected.transport != tr || !admitted || welcomeToken.ac == nil {
11051104 if admitted {
11061105 welcomeTicket.End()
11071106 }
@@ -1110,7 +1109,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
11101109 }
11111110 return
11121111 }
1113- if err := ac.sendExpectedTransportForRole(expected, frameWelcome(sess, ac), welcomeTicket); err != nil {
1112+ if err := ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, ac), welcomeTicket); err != nil {
11141113 welcomeTicket.End()
11151114 if !d.abortResumeClaim(ac) {
11161115 d.clientGone(sess, ac, tr, false)
@@ -1124,17 +1123,16 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
11241123 }
11251124 return
11261125 }
1127- // Welcome is the only effect allowed to retain the route-time role. Release
1128- // it before discovering post-handshake authority so a replacement blocked
1129- // behind the send can publish its generation and lease first.
1126+ // Release Welcome's effect before discovering post-handshake authority so a
1127+ // replacement blocked behind the send can publish its generation and lease.
11301128 welcomeTicket.End()
1131- postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentRoleEffect(sess, tr)
1129+ postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffect(sess, tr)
11321130 if !admitted {
11331131 d.clientGone(sess, ac, tr, false)
11341132 return
11351133 }
11361134 postWelcomeLease := postWelcomeToken.lease
1137- if postWelcomeToken.role != attachmentActive {
1135+ if postWelcomeToken.ac == nil {
11381136 postWelcomeTicket.End()
11391137 d.clientGone(sess, ac, tr, false)
11401138 return
@@ -1142,7 +1140,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
11421140 if ac.proxied {
11431141 meta, ok := sess.sessionMetaSnapshotFor(ac)
11441142 metaFrame, metaErr := frameSessionMeta(meta)
1145- if !ok || metaErr != nil || ac.sendExpectedTransportForRole(postWelcomeToken.transport, metaFrame, postWelcomeTicket) != nil {
1143+ if !ok || metaErr != nil || ac.sendExpectedTransportForAttachment(postWelcomeToken.transport, metaFrame, postWelcomeTicket) != nil {
11461144 postWelcomeTicket.End()
11471145 d.clientGone(sess, ac, tr, false)
11481146 return
@@ -1195,10 +1193,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
11951193 ac := d.prepareAttachedClientLocked(tr, sz, opts)
11961194 d.mu.Unlock()
11971195 result, err := d.transitionAttachment(attachmentTransitionRequest{
1198- target: sess,
1199- next: ac,
1200- expectedRole: attachmentDetached,
1201- targetRole: attachmentActive,
1196+ target: sess,
1197+ next: ac,
1198+
12021199 expectedTransport: ac.transportSnapshot(),
12031200 ready: false,
12041201 })
M internal/usecase/daemon/input.go +30 -30
@@ -20,11 +20,11 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
2020 d.handleInput(sess, ac, data)
2121 }
2222
23-func (d *Daemon) handleSequencedInputForRole(token attachmentRoleToken, _ uint64, data []byte) {
24- if !token.activeEffect() {
23+func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {
24+ if !token.attachmentEffectCurrent() {
2525 return
2626 }
27- d.handleInputForRole(token, data)
27+ d.handleInputForAttachment(token, data)
2828 }
2929
3030 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
@@ -40,19 +40,19 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
4040 )
4141 }
4242
43-func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) {
43+func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data []byte) {
4444 ac := token.ac
4545 ac.initOverlays()
4646 if proxy, ok := token.sess.(*proxySession); ok {
47- handler := proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: token}
47+ handler := proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: token}
4848 ac.mouseScan.Scan(data,
4949 func(ev mouse.Event) {
50- if token.activeEffect() && !proxyMouseOwnedLocally(ac.overlays) {
50+ if token.attachmentEffectCurrent() && !proxyMouseOwnedLocally(ac.overlays) {
5151 handler.Mouse(ev)
5252 }
5353 },
5454 func(b []byte) {
55- if !token.activeEffect() || ac.overlays.HandleInput(d, b, token.effect) || !token.activeEffect() {
55+ if !token.attachmentEffectCurrent() || ac.overlays.HandleInput(d, b, token.effect) || !token.attachmentEffectCurrent() {
5656 return
5757 }
5858 ac.keys.RouteWithHandler(b, handler)
@@ -62,21 +62,21 @@ func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) {
6262 }
6363 ac.mouseScan.Scan(data,
6464 func(ev mouse.Event) {
65- if token.activeEffect() {
65+ if token.attachmentEffectCurrent() {
6666 d.handleMouse(ac, ev)
6767 }
6868 },
6969 func(b []byte) {
70- if !token.activeEffect() {
70+ if !token.attachmentEffectCurrent() {
7171 return
7272 }
7373 if ac.overlays.HandleInput(d, b, token.effect) {
7474 return
7575 }
76- if !token.activeEffect() {
76+ if !token.attachmentEffectCurrent() {
7777 return
7878 }
79- ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, roleToken: token})
79+ ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, connectionToken: token})
8080 },
8181 )
8282 }
@@ -315,34 +315,34 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
315315 }
316316
317317 type daemonKeyHandler struct {
318- d *Daemon
319- ac *attachedClient
320- actions daemonActionRunner
321- roleToken attachmentRoleToken
318+ d *Daemon
319+ ac *attachedClient
320+ actions daemonActionRunner
321+ connectionToken attachmentConnectionToken
322322 }
323323
324-// acquireRoleEffect preserves a synchronous frame's existing admission and
324+// acquireAttachmentEffect preserves a synchronous frame's existing admission and
325325 // gives delayed router callbacks a fresh ticket for the exact captured role.
326326 // Direct/headless callers without a role token retain their existing behavior.
327-func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) {
328- if h.roleToken.ac != nil {
329- if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {
330- sess, _ := localSession(h.roleToken.sess)
327+func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
328+ if h.connectionToken.ac != nil {
329+ if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
330+ sess, _ := localSession(h.connectionToken.sess)
331331 return sess, effect, false
332332 }
333- effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)
333+ effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
334334 if h.d.afterDelayedKeyEffectAttempt != nil {
335335 h.d.afterDelayedKeyEffectAttempt(admitted)
336336 }
337337 if !admitted {
338338 return nil, nil, false
339339 }
340- if h.d.afterRoleEffectAdmitted != nil {
341- token := h.roleToken
340+ if h.d.afterAttachmentEffectAdmitted != nil {
341+ token := h.connectionToken
342342 token.effect = effect
343- h.d.afterRoleEffectAdmitted(token)
343+ h.d.afterAttachmentEffectAdmitted(token)
344344 }
345- sess, ok := localSession(h.roleToken.sess)
345+ sess, ok := localSession(h.connectionToken.sess)
346346 if !ok {
347347 effect.End()
348348 return nil, nil, false
@@ -357,7 +357,7 @@ func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool
357357 }
358358
359359 func (h daemonKeyHandler) Forward(data []byte) {
360- sess, effect, owned := h.acquireRoleEffect()
360+ sess, effect, owned := h.acquireAttachmentEffect()
361361 if sess == nil {
362362 return
363363 }
@@ -375,7 +375,7 @@ func (h daemonKeyHandler) Forward(data []byte) {
375375 }
376376
377377 func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
378- sess, effect, owned := h.acquireRoleEffect()
378+ sess, effect, owned := h.acquireAttachmentEffect()
379379 if sess == nil {
380380 return
381381 }
@@ -421,9 +421,9 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
421421 return
422422 }
423423 effect.bindActionEnd(h.d, "jump-attention")
424- token := h.roleToken
424+ token := h.connectionToken
425425 token.effect = effect
426- if err := h.d.jumpAttentionForRole(sess, h.ac, token); err != nil {
426+ if err := h.d.jumpAttentionForAttachment(sess, h.ac, token); err != nil {
427427 h.d.reportError(sess, err)
428428 }
429429 case keys.ActionToggleFloatingPane:
@@ -464,7 +464,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
464464 }
465465 }
466466
467-func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *roleEffectTicket) {
467+func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) {
468468 var err error
469469 if h.ac.proxied {
470470 err = h.d.focusDirProxied(sess, h.ac, dir)
M internal/usecase/daemon/move.go +16 -56
@@ -77,7 +77,6 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
7777 movedPane := admission.movedPane
7878 sourceClient := admission.sourceClient
7979 destinationClient := admission.destinationClient
80- sourceSnatched := admission.sourceSnatched
8180 sourceTabInitialGeneration := admission.sourceGeneration
8281 destinationTabInitialGeneration := admission.destinationGeneration
8382 finalSourceTab := admission.finalSourceTab
@@ -90,14 +89,14 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
9089 // the callback then validates the handoff before changing any topology.
9190 var handoffReq attachmentTransitionRequest
9291 var handoffParticipants attachmentTransitionParticipants
93- var frozen frozenRoleEffectGates
94- var sourceRolesFrozen bool
92+ var frozen frozenAttachmentEffectGates
93+ var sourceEffectsFrozen bool
9594 var handoffFrozen bool
9695
9796 if finalSourceTab && source != destination && sourceClient != nil {
9897 transport := sourceClient.transportSnapshot()
9998 sourceToken := source.attachmentToken(sourceClient, transport.transport)
100- if sourceToken.role != attachmentActive || sourceToken.transport.transport == nil {
99+ if sourceToken.ac == nil || sourceToken.transport.transport == nil {
101100 return errMovePaneInvalid
102101 }
103102 destinationTabIndex := moveTabIndex(destination, destinationTab)
@@ -106,32 +105,18 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
106105 }
107106 handoffReq = attachmentTransitionRequest{
108107 source: source, target: destination, next: sourceClient,
109- expectedRole: attachmentActive, targetRole: attachmentActive,
108+
110109 expectedTransport: sourceToken.transport, sourceToken: &sourceToken,
111- expectedSourceTab: sourceTab, activateTargetTab: true,
112- targetTabIndex: destinationTabIndex, ready: true,
110+ expectedSourceTab: sourceTab, targetTabIndex: destinationTabIndex, ready: true,
113111 }
114112 var err error
115113 handoffReq, handoffParticipants, err = d.snapshotAttachmentTransition(handoffReq)
116114 if err != nil {
117115 return err
118116 }
119- // Retiring source snatched clients is part of the same role gate set as
120- // the follower. Their exact membership is checked again in the commit.
121- d.mu.Lock()
122- source.mu.Lock()
123- for _, ac := range sourceSnatched {
124- handoffParticipants.clients = append(handoffParticipants.clients, ac)
125- tr := ac.transportSnapshot()
126- if tr.transport != nil {
127- handoffParticipants.interrupts = append(handoffParticipants.interrupts, roleTransportInterrupt{ac: ac, transport: tr})
128- }
129- }
130- source.mu.Unlock()
131- d.mu.Unlock()
132- frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: handoffParticipants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
133- if d.afterRoleEffectGateFrozen != nil {
134- d.afterRoleEffectGateFrozen("move-pane", ac)
117+ frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: handoffParticipants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
118+ if d.afterAttachmentEffectGateFrozen != nil {
119+ d.afterAttachmentEffectGateFrozen("move-pane", ac)
135120 }
136121 }}, handoffParticipants.clients...)
137122 if !frozen.acquired || !frozen.drained {
@@ -139,36 +124,12 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
139124 return errMovePaneInvalid
140125 }
141126 handoffFrozen = true
142- sourceRolesFrozen = true
143- handoffReq.roleEffectsFrozen = true
144- } else if finalSourceTab && source != destination && len(sourceSnatched) > 0 {
145- // A headless source may still have persistent snatched clients. Freeze
146- // their exact role gates before retiring the source registry.
147- d.mu.Lock()
148- source.mu.Lock()
149- participants := append([]*attachedClient(nil), sourceSnatched...)
150- interrupts := make([]roleTransportInterrupt, 0, len(sourceSnatched))
151- for _, ac := range sourceSnatched {
152- if transport := ac.transportSnapshot(); transport.transport != nil {
153- interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport})
154- }
155- }
156- source.mu.Unlock()
157- d.mu.Unlock()
158- frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
159- if d.afterRoleEffectGateFrozen != nil {
160- d.afterRoleEffectGateFrozen("move-pane", ac)
161- }
162- }}, participants...)
163- if !frozen.acquired || !frozen.drained {
164- frozen.unfreeze()
165- return errMovePaneInvalid
166- }
167- sourceRolesFrozen = true
127+ sourceEffectsFrozen = true
128+ handoffReq.attachmentEffectsFrozen = true
168129 }
169- if sourceRolesFrozen {
130+ if sourceEffectsFrozen {
170131 defer func() {
171- if sourceRolesFrozen {
132+ if sourceEffectsFrozen {
172133 frozen.unfreeze()
173134 }
174135 }()
@@ -182,11 +143,10 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
182143 destinationTab: destinationTab,
183144 movedPane: movedPane,
184145 sourceClient: sourceClient,
185- sourceSnatched: sourceSnatched,
186146 sourceGeneration: sourceTabInitialGeneration,
187147 destinationGeneration: destinationTabInitialGeneration,
188148 handoffFrozen: handoffFrozen,
189- frozenRoles: frozen,
149+ frozenEffects: frozen,
190150 handoffReq: handoffReq,
191151 }
192152 fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane)
@@ -210,8 +170,8 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
210170 sourceCleanupToken: commit.sourceCleanupToken,
211171 handoffResult: commit.handoffResult,
212172 syncCleanup: commit.syncCleanup,
213- frozenRoles: frozen,
214- rolesFrozen: sourceRolesFrozen,
173+ frozenEffects: frozen,
174+ effectsFrozen: sourceEffectsFrozen,
215175 unlockDispatch: unlockDispatch,
216176 reservation: reservation,
217177 sourceTabRemoved: commit.sourceTabRemoved,
@@ -225,7 +185,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
225185 }
226186 // Transfer cleanup ownership away from the admission defers only after the
227187 // complete postcommit plan exists.
228- sourceRolesFrozen = false
188+ sourceEffectsFrozen = false
229189 dispatchHeld = false
230190 reservationHeld = false
231191 postcommit.execute(d)
M internal/usecase/daemon/move_atomicity_test.go +8 -8
@@ -21,8 +21,8 @@ type moveCommitObservation struct {
2121 destinationFocus layout.PaneID
2222 ownerSession *session
2323 ownerTab *tab
24- sourceRole attachmentRole
25- destinationRole attachmentRole
24+ sourceRole bool
25+ destinationRole bool
2626 sourceClientSession *session
2727 }
2828
@@ -50,8 +50,8 @@ func readMoveCommitObservation(d *Daemon, source, destination *session, sourceTa
5050 destinationFocus: destinationTab.tree.Focus,
5151 ownerSession: nil,
5252 ownerTab: nil,
53- sourceRole: source.attachmentRoleLocked(sourceClient),
54- destinationRole: destination.attachmentRoleLocked(sourceClient),
53+ sourceRole: source.attachmentRegisteredLocked(sourceClient),
54+ destinationRole: destination.attachmentRegisteredLocked(sourceClient),
5555 sourceClientSession: sourceClient.currentSession(),
5656 }
5757 if owner != nil {
@@ -127,8 +127,8 @@ func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) {
127127 {name: "active tab and focus", read: func(o moveCommitObservation) bool {
128128 return o.sourceActive == sourceTab || o.sourceFocus != layout.PaneID("pane-1") || o.destinationActive != destinationTab || o.destinationFocus != layout.PaneID("pane-2")
129129 }},
130- {name: "attachment roles", read: func(o moveCommitObservation) bool {
131- return o.sourceRole == attachmentActive && o.destinationRole == attachmentDetached && o.sourceClientSession == source
130+ {name: "attachment membership", read: func(o moveCommitObservation) bool {
131+ return o.sourceRole != false || o.destinationRole != true || o.sourceClientSession != destination
132132 }},
133133 }
134134 started := make(chan string, len(readers))
@@ -176,7 +176,7 @@ func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) {
176176 require.Equal(t, layout.PaneID("pane-2"), observation.destinationFocus)
177177 require.Same(t, destination, observation.ownerSession)
178178 require.Same(t, destinationTab, observation.ownerTab)
179- require.Equal(t, attachmentDetached, observation.sourceRole)
180- require.Equal(t, attachmentActive, observation.destinationRole)
179+ require.Equal(t, false, observation.sourceRole)
180+ require.Equal(t, true, observation.destinationRole)
181181 require.Same(t, destination, observation.sourceClientSession)
182182 }
M internal/usecase/daemon/move_attachments.go +4 -28
@@ -1,30 +1,6 @@
11 package daemon
22
3-// frozenMoveAttachmentRetirement is proof that every source snatched client
4-// retired by a final move has had its exact role-effect gate frozen and drained.
5-// It is prepared while the source session lock is held, before topology writes.
6-type frozenMoveAttachmentRetirement struct {
7- clients []*attachedClient
8-}
9-
10-func sameMoveSnatchedLocked(sess *session, admitted []*attachedClient) bool {
11- return sess != nil && len(admitted) == 0
12-}
13-
14-func prepareFrozenMoveAttachmentRetirementLocked(sess *session, admitted []*attachedClient, frozen frozenRoleEffectGates) (frozenMoveAttachmentRetirement, bool) {
15- if !sameMoveSnatchedLocked(sess, admitted) {
16- return frozenMoveAttachmentRetirement{}, false
17- }
18- if len(admitted) == 0 {
19- return frozenMoveAttachmentRetirement{}, true
20- }
21- if !frozen.acquired || !frozen.drained {
22- return frozenMoveAttachmentRetirement{}, false
23- }
24- for _, ac := range admitted {
25- if !frozen.contains(ac) {
26- return frozenMoveAttachmentRetirement{}, false
27- }
28- }
29- return frozenMoveAttachmentRetirement{clients: append([]*attachedClient(nil), admitted...)}, true
30-}
3+// frozenMoveAttachmentRetirement records that the exact source attachment
4+// effect gate was frozen before a final move removes its session membership.
5+// Other registered attachments are independent and are never retired here.
6+type frozenMoveAttachmentRetirement struct{}
M internal/usecase/daemon/move_cleanup_test.go +10 -10
@@ -41,7 +41,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
4141 require.NotNil(t, rc)
4242 lease := rc.attachmentLease(client)
4343 epoch := rc.recordResizeRequestForLease(client.size, client, lease)
44- ticket, admitted := beginActiveLeaseEffect(source, client, lease)
44+ ticket, admitted := beginAttachmentLeaseEffect(source, client, lease)
4545 require.True(t, admitted)
4646 defer ticket.End()
4747
@@ -109,9 +109,9 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
109109 d.sessions[destination.id] = destination
110110 d.mu.Unlock()
111111
112- // Both clients carry state tied specifically to the pane. The displaced
113- // client was captured before the fence wait; the replacement is the only
114- // attachment that may be cleaned after commit.
112+ // Both clients carry state tied specifically to the pane. The existing
113+ // attachment and the concurrently registered attachment remain independent;
114+ // moving one exact attachment must not clean the other.
115115 displaced.overlays.copyMu.Lock()
116116 displaced.overlays.copyPane = moved
117117 displaced.overlays.copyMode = &scopy.Mode{}
@@ -166,20 +166,20 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
166166 require.NoError(t, awaitTestValue(t, moveDone, "move did not finish after source fence release"))
167167
168168 displaced.overlays.copyMu.Lock()
169- require.NotNil(t, displaced.overlays.copyMode, "displaced source client was incorrectly cleaned")
170- require.Same(t, moved, displaced.overlays.copyPane)
169+ require.Nil(t, displaced.overlays.copyMode, "moved attachment copy mode was not cleaned")
170+ require.Nil(t, displaced.overlays.copyPane)
171171 displaced.overlays.copyMu.Unlock()
172172 displaced.sendMu.Lock()
173173 _, displacedCaptured := displaced.captureFrames[moved]
174174 displaced.sendMu.Unlock()
175- require.True(t, displacedCaptured, "displaced source capture was incorrectly pruned")
175+ require.False(t, displacedCaptured, "moved attachment capture was not pruned")
176176
177177 replacement.overlays.copyMu.Lock()
178- require.Nil(t, replacement.overlays.copyMode, "current source client copy mode was not cleaned")
179- require.Nil(t, replacement.overlays.copyPane)
178+ require.NotNil(t, replacement.overlays.copyMode, "independent attachment copy mode was cleaned")
179+ require.Same(t, moved, replacement.overlays.copyPane)
180180 replacement.overlays.copyMu.Unlock()
181181 replacement.sendMu.Lock()
182182 _, replacementCaptured := replacement.captureFrames[moved]
183183 replacement.sendMu.Unlock()
184- require.False(t, replacementCaptured, "current source capture was not pruned")
184+ require.True(t, replacementCaptured, "independent attachment capture was pruned")
185185 }
M internal/usecase/daemon/move_gate_race_test.go +6 -6
@@ -74,7 +74,7 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
7474 killFrozen := make(chan struct{})
7575 releaseKill := make(chan struct{})
7676 var killFrozenOnce sync.Once
77- d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
77+ d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) {
7878 if action != "" {
7979 return
8080 }
@@ -83,7 +83,7 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
8383 }
8484 defer func() {
8585 d.afterMoveLifecycleReserved = nil
86- d.afterRoleEffectGateFrozen = nil
86+ d.afterAttachmentEffectGateFrozen = nil
8787 }()
8888
8989 moveDone := make(chan error, 1)
@@ -118,7 +118,7 @@ func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
118118 moveGateFrozen := make(chan struct{})
119119 releaseMove := make(chan struct{})
120120 var moveGateFrozenOnce sync.Once
121- d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {
121+ d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) {
122122 if action != "move-pane" {
123123 return
124124 }
@@ -127,14 +127,14 @@ func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
127127 }
128128 killSnapshotted := make(chan struct{})
129129 var killSnapshottedOnce sync.Once
130- d.afterRoleEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
130+ d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
131131 if action == "" {
132132 killSnapshottedOnce.Do(func() { close(killSnapshotted) })
133133 }
134134 }
135135 defer func() {
136- d.afterRoleEffectGateFrozen = nil
137- d.afterRoleEffectParticipantsSnapshotted = nil
136+ d.afterAttachmentEffectGateFrozen = nil
137+ d.afterAttachmentEffectParticipantsSnapshotted = nil
138138 }()
139139
140140 moveDone := make(chan error, 1)
M internal/usecase/daemon/move_pane_admission.go +8 -3
@@ -13,7 +13,6 @@ type movePaneAdmission struct {
1313 movedPane *pane
1414 sourceClient *attachedClient
1515 destinationClient *attachedClient
16- sourceSnatched []*attachedClient
1716 sourceGeneration uint64
1817 destinationGeneration uint64
1918 finalSourceTab bool
@@ -53,15 +52,21 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat
5352 return nil, errMoveStaleTarget
5453 }
5554
55+ sourceClient := req.Client
56+ if sourceClient == nil {
57+ attachments := source.snapshotAttachmentsLocked()
58+ if len(attachments) != 0 {
59+ sourceClient = attachments[0]
60+ }
61+ }
5662 return &movePaneAdmission{
5763 source: source,
5864 destination: destination,
5965 sourceTab: sourceTab,
6066 destinationTab: destinationTab,
6167 movedPane: movedPane,
62- sourceClient: req.Client,
68+ sourceClient: sourceClient,
6369 destinationClient: nil,
64- sourceSnatched: nil,
6570 sourceGeneration: sourceTab.layoutGeneration,
6671 destinationGeneration: destinationTab.layoutGeneration,
6772 finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&
M internal/usecase/daemon/move_pane_commit.go +4 -10
@@ -14,18 +14,17 @@ type movePaneCommit struct {
1414 sourceTab, destinationTab *tab
1515 movedPane *pane
1616 sourceClient *attachedClient
17- sourceSnatched []*attachedClient
1817 sourceGeneration uint64
1918 destinationGeneration uint64
2019 handoffFrozen bool
21- frozenRoles frozenRoleEffectGates
20+ frozenEffects frozenAttachmentEffectGates
2221 handoffReq attachmentTransitionRequest
2322 handoffPublication *attachmentPublication
2423 err error
2524
2625 handoffResult attachmentTransitionResult
2726 syncCleanup syncTimerCleanup
28- sourceCleanupToken attachmentRoleToken
27+ sourceCleanupToken attachmentConnectionToken
2928 sourceEmpty bool
3029 sourceTabRemoved bool
3130 retiredParked []parkedAttachmentRetirement
@@ -62,7 +61,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
6261 return false
6362 }
6463 if c.handoffFrozen {
65- if (c.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.sourceClient)) || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||
64+ if c.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.sourceClient) ||
6665 c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) ||
6766 c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab {
6867 return false
@@ -102,15 +101,10 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
102101
103102 sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1
104103 if sourceWillEmpty && c.source != c.destination {
105- if (c.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.sourceClient)) || !sameMoveSnatchedLocked(c.source, c.sourceSnatched) ||
104+ if (c.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.sourceClient)) ||
106105 (c.sourceClient != nil) != c.handoffFrozen {
107106 return false
108107 }
109- var retirementOK bool
110- retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.sourceSnatched, c.frozenRoles)
111- if !retirementOK {
112- return false
113- }
114108 }
115109
116110 // Everything above is fallible. Topology, owner, lifecycle registry, and
M internal/usecase/daemon/move_pane_postcommit.go +10 -17
@@ -27,11 +27,11 @@ type movePanePostcommitPlan struct {
2727 movedPane *pane
2828 movedPanes []*pane
2929 destinationClient *attachedClient
30- sourceCleanupToken attachmentRoleToken
30+ sourceCleanupToken attachmentConnectionToken
3131 handoffResult attachmentTransitionResult
3232 syncCleanup syncTimerCleanup
33- frozenRoles frozenRoleEffectGates
34- rolesFrozen bool
33+ frozenEffects frozenAttachmentEffectGates
34+ effectsFrozen bool
3535 unlockDispatch func()
3636 reservation *moveLifecycleReservation
3737 oldTabCancel context.CancelFunc
@@ -77,8 +77,8 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
7777 markSnapshotDirty(p.source)
7878 }
7979 p.syncCleanup.finish()
80- if p.rolesFrozen {
81- p.frozenRoles.unfreeze()
80+ if p.effectsFrozen {
81+ p.frozenEffects.unfreeze()
8282 }
8383 p.unlockDispatch()
8484 p.reservation.Release()
@@ -114,6 +114,7 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
114114 }
115115 if p.handoffResult.published.ac != nil {
116116 follower := p.handoffResult.published.ac
117+ p.destination.selectAttachmentTab(follower, domain.TabStableID(p.destinationTab.stableID))
117118 d.applyHostTheme(p.destination, follower, follower.getClientTheme(), false)
118119 follower.recordPreviousSession(p.source)
119120 d.deferAttachmentTransitionCleanups(p.handoffResult)
@@ -145,23 +146,15 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
145146 }
146147 }
147148
148-// retireEmptyMoveSessionLocked removes all source-owned attachment roles that
149-// were not transferred. It runs inside the move commit, after the moved pane
150-// has already acquired its destination owner, and performs no external work.
149+// retireEmptyMoveSessionLocked clears the empty source session after the exact
150+// attachment transfer has committed. It performs no external work.
151151 func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachmentRetirement) []detachedAttachmentSnapshot {
152152 if sess == nil || len(sess.attachments) != 0 {
153153 return nil
154154 }
155- retired := make([]detachedAttachmentSnapshot, 0, len(retirement.clients))
156- for _, ac := range retirement.clients {
157- retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: ac.transportSnapshot()})
158- sess.unregisterAttachmentLocked(ac)
159- ac.roleGeneration.Add(1)
160- ac.setSession(nil)
161- ac.invalidateFrozenRoleCapability()
162- }
155+ _ = retirement
163156 sess.tabs = nil
164- return retired
157+ return nil
165158 }
166159
167160 func moveSessionRetired(d *Daemon, sess *session) bool {
M internal/usecase/daemon/move_tab.go +27 -58
@@ -11,7 +11,6 @@ type moveTabAdmission struct {
1111 tab *tab
1212 sourceIndex int
1313 sourceClient *attachedClient
14- sourceSnatched []*attachedClient
1514 layoutGeneration uint64
1615 floatingState floatingState
1716 floatingPane *pane
@@ -28,13 +27,13 @@ type moveTabCommit struct {
2827 destination *session
2928 admission moveTabAdmission
3029 handoffFrozen bool
31- sourceRolesFrozen bool
32- frozenRoles frozenRoleEffectGates
30+ sourceEffectsFrozen bool
31+ frozenEffects frozenAttachmentEffectGates
3332 handoffReq attachmentTransitionRequest
3433 handoffPublication *attachmentPublication
3534 handoffResult attachmentTransitionResult
3635 syncCleanup syncTimerCleanup
37- sourceCleanupToken attachmentRoleToken
36+ sourceCleanupToken attachmentConnectionToken
3837 oldTabCancel context.CancelFunc
3938 sourceEmpty bool
4039 retiredParked []parkedAttachmentRetirement
@@ -46,27 +45,6 @@ type moveTabCommit struct {
4645 err error
4746 }
4847
49-func (d *Daemon) snapshotSnatchedRoleParticipants(sess *session, admitted []*attachedClient) ([]*attachedClient, []roleTransportInterrupt) {
50- if sess == nil || len(admitted) == 0 {
51- return nil, nil
52- }
53- d.mu.Lock()
54- defer d.mu.Unlock()
55- sess.mu.Lock()
56- defer sess.mu.Unlock()
57- clients := append([]*attachedClient(nil), admitted...)
58- interrupts := make([]roleTransportInterrupt, 0, len(clients))
59- for _, ac := range clients {
60- if ac == nil {
61- continue
62- }
63- if transport := ac.transportSnapshot(); transport.transport != nil {
64- interrupts = append(interrupts, roleTransportInterrupt{ac: ac, transport: transport})
65- }
66- }
67- return clients, interrupts
68-}
69-
7048 func (d *Daemon) moveTab(req moveTabRequest) (result error) {
7149 defer func() { result = normalizeMoveRejection(result) }()
7250 if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {
@@ -111,16 +89,16 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
11189 d.afterMoveTabSourceSnapshot()
11290 }
11391 commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}
114- var frozen frozenRoleEffectGates
92+ var frozen frozenAttachmentEffectGates
11593 if admission.finalSource && admission.sourceClient != nil {
11694 transport := admission.sourceClient.transportSnapshot()
11795 token := source.attachmentToken(admission.sourceClient, transport.transport)
118- if token.role != attachmentActive || token.transport.transport == nil {
96+ if token.transport.transport == nil {
11997 return errMovePaneInvalid
12098 }
12199 commit.handoffReq = attachmentTransitionRequest{
122100 source: source, target: destination, next: admission.sourceClient,
123- expectedRole: attachmentActive, targetRole: attachmentActive,
101+
124102 expectedTransport: token.transport, sourceToken: &token,
125103 expectedSourceTab: admission.tab, transferExpectedSourceTab: true, ready: true, action: "move-tab",
126104 }
@@ -129,12 +107,9 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
129107 if err != nil {
130108 return err
131109 }
132- snatchedClients, snatchedInterrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched)
133- participants.clients = append(participants.clients, snatchedClients...)
134- participants.interrupts = append(participants.interrupts, snatchedInterrupts...)
135- frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
136- if d.afterRoleEffectGateFrozen != nil {
137- d.afterRoleEffectGateFrozen("move-tab", ac)
110+ frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
111+ if d.afterAttachmentEffectGateFrozen != nil {
112+ d.afterAttachmentEffectGateFrozen("move-tab", ac)
138113 }
139114 }}, participants.clients...)
140115 if !frozen.acquired || !frozen.drained {
@@ -142,21 +117,13 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
142117 return errMovePaneInvalid
143118 }
144119 commit.handoffFrozen = true
145- commit.sourceRolesFrozen = true
146- commit.handoffReq.roleEffectsFrozen = true
147- } else if admission.finalSource && len(admission.sourceSnatched) > 0 {
148- clients, interrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched)
149- frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: interrupts, nonblocking: true}, clients...)
150- if !frozen.acquired || !frozen.drained {
151- frozen.unfreeze()
152- return errMovePaneInvalid
153- }
154- commit.sourceRolesFrozen = true
120+ commit.sourceEffectsFrozen = true
121+ commit.handoffReq.attachmentEffectsFrozen = true
155122 }
156- commit.frozenRoles = frozen
157- rolesFrozen := commit.sourceRolesFrozen
123+ commit.frozenEffects = frozen
124+ effectsFrozen := commit.sourceEffectsFrozen
158125 defer func() {
159- if rolesFrozen {
126+ if effectsFrozen {
160127 frozen.unfreeze()
161128 }
162129 }()
@@ -180,8 +147,8 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
180147 sourceCleanupToken: commit.sourceCleanupToken,
181148 handoffResult: commit.handoffResult,
182149 syncCleanup: commit.syncCleanup,
183- frozenRoles: frozen,
184- rolesFrozen: rolesFrozen,
150+ frozenEffects: frozen,
151+ effectsFrozen: effectsFrozen,
185152 unlockDispatch: unlockDispatch,
186153 reservation: reservation,
187154 oldTabCancel: commit.oldTabCancel,
@@ -194,7 +161,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
194161 destinationMetadata: commit.destMetadata,
195162 destinationMetadataValid: commit.destMetadataValid,
196163 }
197- rolesFrozen = false
164+ effectsFrozen = false
198165 dispatchHeld = false
199166 reservationHeld = false
200167 postcommit.execute(d)
@@ -239,9 +206,16 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
239206 panes = append(panes, moved.floating.pane)
240207 }
241208 idx := indexMoveTabLocked(source, moved)
209+ sourceClient := req.Client
210+ if sourceClient == nil {
211+ attachments := source.snapshotAttachmentsLocked()
212+ if len(attachments) != 0 {
213+ sourceClient = attachments[0]
214+ }
215+ }
242216 return &moveTabAdmission{
243217 tab: moved, sourceIndex: idx,
244- sourceClient: req.Client, sourceSnatched: nil,
218+ sourceClient: sourceClient,
245219 layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,
246220 floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,
247221 panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,
@@ -279,7 +253,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
279253 // precede moved.mu. The resize fences and ordered session locks keep topology
280254 // stable until the tab/pane checks below complete.
281255 if c.handoffFrozen {
282- if (c.admission.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.admission.sourceClient)) || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) {
256+ if c.admission.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.admission.sourceClient) {
283257 return false
284258 }
285259 publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq)
@@ -292,15 +266,10 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
292266 }
293267 var retirement frozenMoveAttachmentRetirement
294268 if c.admission.finalSource {
295- if (c.admission.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.admission.sourceClient)) || !sameMoveSnatchedLocked(c.source, c.admission.sourceSnatched) ||
269+ if (c.admission.sourceClient != nil && !attachmentRegisteredLocked(c.source, c.admission.sourceClient)) ||
296270 (c.admission.sourceClient != nil) != c.handoffFrozen {
297271 return false
298272 }
299- var retirementOK bool
300- retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.admission.sourceSnatched, c.frozenRoles)
301- if !retirementOK {
302- return false
303- }
304273 }
305274 if len(c.destination.tabs) == 0 || c.destination.tabs[0] != c.admission.destinationActive {
306275 return false
M internal/usecase/daemon/move_tab_test.go +6 -6
@@ -160,7 +160,7 @@ func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) {
160160 require.Nil(t, source.tabs)
161161 require.Same(t, destination, client.currentSession())
162162 require.Contains(t, destination.snapshotAttachments(), client)
163- require.Equal(t, attachmentActive, destination.attachmentRole(client))
163+ require.Equal(t, true, destination.attachmentRegistered(client))
164164 require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs)
165165 require.Same(t, moved, destination.tabs[testAttachmentTabIndex(destination)], "final-source follower activates the moved tab")
166166 require.True(t, rebased)
@@ -241,7 +241,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
241241
242242 source.layoutApplyMu.Lock()
243243 admitted := make(chan struct{})
244- d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {
244+ d.afterAttachmentEffectGateFrozen = func(action string, ac *attachedClient) {
245245 if action == "move-tab" && ac == client {
246246 close(admitted)
247247 }
@@ -262,7 +262,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
262262 require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return"))
263263 require.NotContains(t, destination.tabs, moved)
264264 require.Same(t, source, floating.ownerSnapshot().session)
265- d.afterRoleEffectGateFrozen = nil
265+ d.afterAttachmentEffectGateFrozen = nil
266266 })
267267 }
268268 }
@@ -487,12 +487,12 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.
487487 sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()
488488 destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()
489489 tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()
490- roleUnlocked := r.client.roleEffects.mu.TryLock()
491- roleStable := roleUnlocked && r.client.roleEffects.phase == roleEffectsStable
490+ roleUnlocked := r.client.attachmentEffects.mu.TryLock()
491+ roleStable := roleUnlocked && r.client.attachmentEffects.phase == attachmentEffectsStable
492492 r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&
493493 sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable
494494 if roleUnlocked {
495- r.client.roleEffects.mu.Unlock()
495+ r.client.attachmentEffects.mu.Unlock()
496496 }
497497 if tabFenceUnlocked {
498498 r.moved.layoutApplyMu.Unlock()
M internal/usecase/daemon/move_test.go +1 -1
@@ -116,7 +116,7 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) {
116116 require.Nil(t, source.tabs)
117117 require.Same(t, destination, client.currentSession())
118118 require.Contains(t, destination.snapshotAttachments(), client)
119- require.Equal(t, attachmentActive, destination.attachmentRole(client))
119+ require.Equal(t, true, destination.attachmentRegistered(client))
120120 require.Same(t, destinationTab, destination.tabs[testAttachmentTabIndex(destination)])
121121 require.True(t, rebased.Load(), "final-source follower must rebase output before its first paint")
122122 require.NoError(t, moved.ctx.Err(), "transferred pane process must remain alive")
R internal/usecase/daemon/navigation_role_handoff_test.go → internal/usecase/daemon/navigation_attachment_handoff_test.go +20 -21 renamed, 78% similar
@@ -16,31 +16,31 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
1616 tests := []struct {
1717 name string
1818 action string
19- run func(*Daemon, *session, *session, *attachedClient, *roleEffectTicket) error
19+ run func(*Daemon, *session, *session, *attachedClient, *attachmentEffectTicket) error
2020 check func(*testing.T, *Daemon, *session, *session)
2121 }{
2222 {
2323 name: "picker selection", action: "picker-select",
24- run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error {
24+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
2525 d.enterPicker(source, ac)
26- return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
26+ return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
2727 },
2828 },
2929 {
3030 name: "palette session", action: "palette-session",
31- run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error {
32- return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
31+ run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
32+ return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
3333 },
3434 },
3535 {
3636 name: "palette recent session", action: "palette-recent-session",
37- run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error {
37+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
3838 return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1)
3939 },
4040 },
4141 {
4242 name: "session overflow", action: "overflow-session",
43- run: func(d *Daemon, source, target *session, ac *attachedClient, effect *roleEffectTicket) error {
43+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
4444 source.mu.Lock()
4545 source.name = "alpha"
4646 source.mu.Unlock()
@@ -53,13 +53,13 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
5353 },
5454 {
5555 name: "stopped session resume", action: "stopped-session",
56- run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error {
56+ run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
5757 d.mu.Lock()
5858 delete(d.sessions, target.id)
5959 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionStopped}
6060 d.mu.Unlock()
6161 expectedCreatedAt := int64(9)
62- return d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
62+ return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
6363 },
6464 check: func(t *testing.T, d *Daemon, _, _ *session) {
6565 d.mu.Lock()
@@ -72,8 +72,8 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
7272 },
7373 {
7474 name: "transition prompt creation", action: "create-session",
75- run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *roleEffectTicket) error {
76- return d.createSessionAndSwitchForRole(effect.roleToken(), "created")
75+ run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffectTicket) error {
76+ return d.createSessionAndSwitchForAttachment(effect.connectionToken(), "created")
7777 },
7878 check: func(t *testing.T, d *Daemon, _, _ *session) {
7979 d.mu.Lock()
@@ -100,14 +100,14 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
100100 rc := d.attachCoordinator(source, nil, old, true)
101101 token := source.attachmentToken(old, old.transport())
102102 token.lease = rc.attachmentLease(old)
103- old.publishRoleCapability(token)
104- effect, admitted := old.beginRoleEffect(token)
103+ old.publishAttachmentCapability(token)
104+ effect, admitted := old.beginAttachmentEffect(token)
105105 require.True(t, admitted)
106106
107107 admissionEnded := make(chan struct{})
108108 releaseAction := make(chan struct{})
109109 var admissionEndedOnce sync.Once
110- d.afterActionRoleEffectEnded = func(action string) {
110+ d.afterActionAttachmentEffectEnded = func(action string) {
111111 if action == tt.action {
112112 admissionEndedOnce.Do(func() { close(admissionEnded) })
113113 <-releaseAction
@@ -123,7 +123,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
123123
124124 replacement, err := d.transitionAttachment(attachmentTransitionRequest{
125125 source: source, target: source, next: old,
126- expectedRole: attachmentActive, targetRole: attachmentActive,
126+
127127 expectedTransport: old.transportSnapshot(), ready: true,
128128 })
129129 require.NoError(t, err)
@@ -155,14 +155,14 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
155155 rc := d.attachCoordinator(source, nil, old, true)
156156 token := source.attachmentToken(old, old.transport())
157157 token.lease = rc.attachmentLease(old)
158- old.publishRoleCapability(token)
159- effect, admitted := old.beginRoleEffect(token)
158+ old.publishAttachmentCapability(token)
159+ effect, admitted := old.beginAttachmentEffect(token)
160160 require.True(t, admitted)
161161
162162 ended := make(chan struct{})
163163 release := make(chan struct{})
164164 var endedOnce sync.Once
165- d.afterActionRoleEffectEnded = func(action string) {
165+ d.afterActionAttachmentEffectEnded = func(action string) {
166166 if action == "picker-stopped" {
167167 endedOnce.Do(func() { close(ended) })
168168 <-release
@@ -171,7 +171,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
171171 expectedCreatedAt := int64(7)
172172 done := make(chan error, 1)
173173 go func() {
174- done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped")
174+ done <- d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped")
175175 }()
176176 select {
177177 case <-ended:
@@ -182,8 +182,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
182182 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
183183 next.initOverlays()
184184 replacement, err := d.transitionAttachment(attachmentTransitionRequest{
185- target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,
186- expectedTransport: next.transportSnapshot(), ready: true,
185+ target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true,
187186 })
188187 require.NoError(t, err)
189188 close(release)
R internal/usecase/daemon/navigation_role_incarnation_test.go → internal/usecase/daemon/navigation_attachment_incarnation_test.go +6 -6 renamed, 85% similar
@@ -18,7 +18,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
1818 rebind: func(t *testing.T, d *Daemon, source *session, ac *attachedClient) {
1919 result, err := d.transitionAttachment(attachmentTransitionRequest{
2020 source: source, target: source, next: ac,
21- expectedRole: attachmentActive, targetRole: attachmentActive,
21+
2222 expectedTransport: ac.transportSnapshot(), ready: true,
2323 })
2424 require.NoError(t, err)
@@ -31,7 +31,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
3131 ac.replaceTransport(&closeTrackingTransport{})
3232 current := source.attachmentToken(ac, ac.transport())
3333 current.lease = source.renderCoordinator().attachmentLease(ac)
34- ac.publishRoleCapability(current)
34+ ac.publishAttachmentCapability(current)
3535 },
3636 },
3737 }
@@ -50,13 +50,13 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
5050 rc := d.attachCoordinator(source, nil, ac, true)
5151 token := source.attachmentToken(ac, ac.transport())
5252 token.lease = rc.attachmentLease(ac)
53- ac.publishRoleCapability(token)
54- effect, admitted := ac.beginRoleEffect(token)
53+ ac.publishAttachmentCapability(token)
54+ effect, admitted := ac.beginAttachmentEffect(token)
5555 require.True(t, admitted)
5656
5757 ended := make(chan struct{})
5858 release := make(chan struct{})
59- d.afterActionRoleEffectEnded = func(action string) {
59+ d.afterActionAttachmentEffectEnded = func(action string) {
6060 if action == "test-handoff" {
6161 close(ended)
6262 <-release
@@ -64,7 +64,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
6464 }
6565 done := make(chan error, 1)
6666 go func() {
67- done <- d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff")
67+ done <- d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff")
6868 }()
6969 awaitTestCompletion(t, ended, "test handoff did not release its role ticket")
7070 tt.rebind(t, d, source, ac)
M internal/usecase/daemon/navigation_overflow_test.go +1 -1
@@ -130,7 +130,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
130130 }
131131
132132 for _, target := range []*session{charlie, echo} {
133- require.Equal(t, 1, testAttachmentTabIndex(target), "switch preserves the target active tab")
133+ require.Equal(t, 0, testAttachmentTabIndex(target), "new attachment starts at the deterministic first tab")
134134 target.tabs[1].mu.Lock()
135135 require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus")
136136 target.tabs[1].mu.Unlock()
M internal/usecase/daemon/notices_overlay_test.go +2 -2
@@ -434,7 +434,7 @@ func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) {
434434 func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T) {
435435 d, proxy, ac, _, handler := newProxyInputHarness(t)
436436 handler.enterPalette()
437- d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect)
437+ d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect)
438438
439439 history := d.notices.history()
440440 require.Len(t, history, 1)
@@ -444,7 +444,7 @@ func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T)
444444
445445 func TestProxyNoticeYankSendFailureDetachesAndArmsWarmExpiry(t *testing.T) {
446446 d, proxy, ac, _, handler := newProxyInputHarness(t)
447- handler.roleToken.effect.End()
447+ handler.connectionToken.effect.End()
448448 client, ok := ac.transport().(*proxyTestTransport)
449449 require.True(t, ok)
450450 client.sendFails.Store(true)
M internal/usecase/daemon/notify_test.go +3 -3
@@ -86,7 +86,7 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) {
8686 tr, _ := newCapturingTransport(t)
8787 replaced := make(chan *attachedClient, 1)
8888 go func() {
89- current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
89+ current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
9090 replaced <- current
9191 }()
9292
@@ -134,7 +134,7 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
134134 tt.setup(d, old, sess.id)
135135 }
136136 tr, _ := newCapturingTransport(t)
137- current, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
137+ current, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
138138 d.attachmentCleanupWg.Wait()
139139
140140 d.handleClientNotice(sess, old, tt.notice)
@@ -767,7 +767,7 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) {
767767 tr, _ := newCapturingTransport(t)
768768 replaced := make(chan *attachedClient, 1)
769769 go func() {
770- ac, _, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
770+ ac, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
771771 replaced <- ac
772772 }()
773773 close(releaseRoute)
M internal/usecase/daemon/overlay_runtime.go +3 -3
@@ -48,7 +48,7 @@ type overlayRuntime struct {
4848 promptMu sync.Mutex
4949 prompt *promptui.Model
5050 promptSubmit func(string) error
51- promptTransitionSubmit func(string, attachmentRoleToken) error
51+ promptTransitionSubmit func(string, attachmentConnectionToken) error
5252 promptPending []byte
5353
5454 resizeMu sync.Mutex
@@ -242,12 +242,12 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
242242 return active || prePublication
243243 }
244244
245-func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*roleEffectTicket) bool {
245+func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attachmentEffectTicket) bool {
246246 if rt == nil || rt.ac == nil {
247247 return false
248248 }
249249 ac := rt.ac
250- var effect *roleEffectTicket
250+ var effect *attachmentEffectTicket
251251 if len(effects) != 0 {
252252 effect = effects[0]
253253 }
M internal/usecase/daemon/palette.go +15 -15
@@ -174,7 +174,7 @@ func (d *Daemon) recordPaletteUse(code string) {
174174 d.paletteRecent = recent
175175 }
176176
177-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
177+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
178178 entry := ac.currentAttachmentSession()
179179 if entry == nil {
180180 return
@@ -189,7 +189,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
189189 var generation uint64
190190 var rawQuery string
191191 changed, cancel, execute := false, false, false
192- var effect *roleEffectTicket
192+ var effect *attachmentEffectTicket
193193 if len(effects) != 0 {
194194 effect = effects[0]
195195 }
@@ -305,7 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
305305 }
306306 var err error
307307 if effect != nil {
308- err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session")
308+ err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session")
309309 } else if local {
310310 err = d.switchToTarget(sess, ac, target)
311311 } else {
@@ -389,10 +389,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
389389 }
390390 case "back-session":
391391 if effect != nil {
392- err = d.backSessionForRole(effect.roleToken())
392+ err = d.backSessionForAttachment(effect.connectionToken())
393393 }
394394 case "detach":
395- if effect != nil && !d.clientGoneForRole(effect.roleToken(), true) {
395+ if effect != nil && !d.clientGoneForAttachment(effect.connectionToken(), true) {
396396 err = errAttachmentTransition
397397 }
398398 case "session-picker":
@@ -413,17 +413,17 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
413413 }
414414 return
415415 }
416- roleHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
417- if !roleHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
416+ attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
417+ if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
418418 return
419419 }
420420 sess.dispatchMu.Lock()
421421 err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)
422422 sess.dispatchMu.Unlock()
423- if roleHandoff {
423+ if attachmentHandoff {
424424 if current := ac.currentSession(); current != nil {
425425 currentToken := current.attachmentToken(ac, ac.transport())
426- fresh, admitted := ac.beginRoleEffect(currentToken)
426+ fresh, admitted := ac.beginAttachmentEffect(currentToken)
427427 if ac.closeExecutedPalette(generation, rawQuery) {
428428 d.invalidateRender(current, ac, true, "palette.go")
429429 }
@@ -495,7 +495,7 @@ type paletteExec struct {
495495 ac *attachedClient
496496 recent []recentSession
497497 actions daemonActionRunner
498- effect *roleEffectTicket
498+ effect *attachmentEffectTicket
499499 redrawClosedPalette bool
500500 }
501501
@@ -532,7 +532,7 @@ func (e paletteExec) CreateSession() error {
532532 if entry == nil {
533533 entry = e.sess
534534 }
535- e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentRoleToken) error {
535+ e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error {
536536 if token.ac == nil {
537537 sess, ok := e.localSession()
538538 if !ok {
@@ -540,7 +540,7 @@ func (e paletteExec) CreateSession() error {
540540 }
541541 return e.d.createSessionAndSwitch(sess, e.ac, name)
542542 }
543- return e.d.createSessionAndSwitchForRole(token, name)
543+ return e.d.createSessionAndSwitchForAttachment(token, name)
544544 })
545545 return nil
546546 }
@@ -639,7 +639,7 @@ func (e paletteExec) ToggleFloatingPane() error {
639639
640640 func (e paletteExec) BackSession() error {
641641 if e.effect != nil {
642- return e.d.backSessionForRole(e.effect.roleToken())
642+ return e.d.backSessionForAttachment(e.effect.connectionToken())
643643 }
644644 e.d.backSession(e.sess, e.ac)
645645 return nil
@@ -647,7 +647,7 @@ func (e paletteExec) BackSession() error {
647647
648648 func (e paletteExec) Detach() error {
649649 if e.effect != nil {
650- if !e.d.clientGoneForRole(e.effect.roleToken(), true) {
650+ if !e.d.clientGoneForAttachment(e.effect.connectionToken(), true) {
651651 return errAttachmentTransition
652652 }
653653 return nil
@@ -743,7 +743,7 @@ func (e paletteExec) JumpRecentSession(rank int) error {
743743 }
744744 var err error
745745 if e.effect != nil {
746- err = e.d.switchToTargetForRole(e.effect.roleToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")
746+ err = e.d.switchToTargetForAttachment(e.effect.connectionToken(), picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "palette-recent-session")
747747 } else {
748748 sess, ok := e.localSession()
749749 if !ok {
M internal/usecase/daemon/palette_test.go +11 -11
@@ -138,7 +138,7 @@ func TestRemotePaletteClosesBeforeOpeningLocalOverlay(t *testing.T) {
138138 t.Run(test.name, func(t *testing.T) {
139139 d, _, ac, link, handler := newProxyInputHarness(t)
140140 handler.enterPalette()
141- d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.roleToken.effect)
141+ d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.connectionToken.effect)
142142
143143 require.False(t, ac.overlays.paletteActive(), "stale remote palette remained active")
144144 require.True(t, test.active(ac.overlays), "replacement local overlay was not opened")
@@ -361,14 +361,14 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
361361
362362 d.handleInput(current, ac, []byte("\x1b "))
363363 awaitFrame(t, sends, ports.MsgOutput)
364- generation := ac.roleGeneration.Load()
364+ generation := ac.connectionGeneration.Load()
365365 d.handleInput(current, ac, []byte("stopped\r"))
366366
367367 resumed := ac.currentSession()
368368 require.Equal(t, "stopped", resumed.name)
369369 require.Equal(t, int64(42), resumed.createdAt)
370- require.Equal(t, attachmentActive, resumed.attachmentRole(ac))
371- require.Greater(t, ac.roleGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition")
370+ require.Equal(t, true, resumed.attachmentRegistered(ac))
371+ require.Greater(t, ac.connectionGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition")
372372 require.False(t, ac.overlays.paletteActive())
373373 firstPaint := awaitFrame(t, sends, ports.MsgOutput)
374374 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
@@ -486,7 +486,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
486486 invalidations := installPaletteInvalidationObserver(current)
487487 d.enterPalette(current, ac)
488488 d.handlePaletteInput(ac, []byte("BSK"))
489- d.afterActionRoleEffectEnded = func(action string) {
489+ d.afterActionAttachmentEffectEnded = func(action string) {
490490 if action == "back-session" {
491491 d.mu.Lock()
492492 delete(d.sessions, target.core().id)
@@ -495,7 +495,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
495495 }
496496
497497 token := current.attachmentToken(ac, ac.transport())
498- effect, admitted := ac.beginRoleEffect(token)
498+ effect, admitted := ac.beginAttachmentEffect(token)
499499 require.True(t, admitted)
500500 d.handlePaletteInput(ac, []byte("\r"), effect)
501501
@@ -505,7 +505,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
505505 require.True(t, invalidation.reset)
506506 }
507507
508-func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) {
508+func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) {
509509 p, release := newBlockingPTY(t)
510510 defer release()
511511 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
@@ -514,7 +514,7 @@ func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) {
514514 d.enterPalette(sess, ac)
515515 d.handlePaletteInput(ac, []byte("BSK"))
516516 // Make the attachment token detached while retaining currentSession. The
517- // no-op BSK command succeeds, but its post-execution beginRoleEffect is
517+ // no-op BSK command succeeds, but its post-execution beginAttachmentEffect is
518518 // deterministically denied.
519519 sess.mu.Lock()
520520 clearAttachmentsForTestLocked(sess)
@@ -671,7 +671,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
671671 require.True(t, ac.overlays.promptActive())
672672 require.Contains(t, string(promptOutput.Data), "Create session")
673673
674- generation := ac.roleGeneration.Load()
674+ generation := ac.connectionGeneration.Load()
675675 d.handleInput(sess, ac, []byte("scratch\r"))
676676 // The submit first paints the newly attached session while the prompt is
677677 // still open, then handlePromptInput closes the prompt and repaints the
@@ -692,8 +692,8 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
692692 require.Equal(t, "scratch", newSess.name)
693693 require.False(t, newSess.ephemeral)
694694 require.Contains(t, newSess.snapshotAttachments(), ac)
695- require.Equal(t, attachmentActive, newSess.attachmentRole(ac))
696- require.Greater(t, ac.roleGeneration.Load(), generation, "new-session handoff must publish through the attachment transition")
695+ require.Equal(t, true, newSess.attachmentRegistered(ac))
696+ require.Greater(t, ac.connectionGeneration.Load(), generation, "new-session handoff must publish through the attachment transition")
697697 require.Contains(t, string(finalOutput.Data), "scratch")
698698 require.NotContains(t, string(finalOutput.Data), "Create session")
699699 }
M internal/usecase/daemon/pane_actions.go +2 -2
@@ -356,7 +356,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
356356 return nil
357357 }
358358
359-func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *roleEffectTicket) error {
359+func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *attachmentEffectTicket) error {
360360 target := resolveDaemonActionTargetForAttachment(sess, ac)
361361 oldFocus := layout.PaneID("")
362362 if target.pane != nil {
@@ -382,7 +382,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
382382 return errNoNeighbor
383383 }
384384 if effect != nil {
385- return d.switchToTargetForRole(effect.roleToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
385+ return d.switchToTargetForAttachment(effect.connectionToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
386386 }
387387 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
388388 }
M internal/usecase/daemon/picker.go +47 -48
@@ -258,7 +258,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
258258 }
259259 }
260260
261-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
261+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
262262 sess := ac.currentAttachmentSession()
263263 if sess == nil {
264264 return
@@ -303,7 +303,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
303303 return
304304 }
305305 if result.action == 'x' {
306- var effect *roleEffectTicket
306+ var effect *attachmentEffectTicket
307307 if len(effects) != 0 {
308308 effect = effects[0]
309309 }
@@ -311,7 +311,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
311311 var err error
312312 if effect != nil {
313313 effect.bindActionEnd(d, "picker-delete")
314- err = d.killPickerTargetForRole(target, effect.roleToken())
314+ err = d.killPickerTargetForAttachment(target, effect.connectionToken())
315315 } else {
316316 err = d.killPickerTarget(target)
317317 }
@@ -320,7 +320,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
320320 }
321321 }
322322 if effect != nil && effect.ended.Load() {
323- fresh, admitted := ac.beginRoleEffect(effect.token)
323+ fresh, admitted := ac.beginAttachmentEffect(effect.token)
324324 if !admitted {
325325 return
326326 }
@@ -359,7 +359,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
359359 }
360360 var err error
361361 if len(effects) != 0 && effects[0] != nil {
362- err = d.switchToTargetForRole(effects[0].roleToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
362+ err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
363363 } else if local, ok := localSession(sess); ok {
364364 d.closePicker(ac)
365365 err = d.switchToTarget(local, ac, target)
@@ -581,12 +581,12 @@ type sessionHandoffGuard struct {
581581 closePicker bool
582582 }
583583
584-// switchActiveTargetForRole hands a frame-bound navigation request to the
584+// switchActiveTargetForAttachment hands a frame-bound navigation request to the
585585 // centralized transition. The transition releases admission at its freeze seam
586586 // and revalidates the exact initiating token before changing target focus or
587587 // attachment ownership.
588-func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error {
589- return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
588+func (d *Daemon) switchActiveTargetForAttachment(token attachmentConnectionToken, target picker.Target) error {
589+ return d.switchActiveTargetForAttachmentGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
590590 }
591591
592592 func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
@@ -611,15 +611,15 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence
611611 return fence
612612 }
613613
614-func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {
615- if token.sess == nil || token.ac == nil || token.role != attachmentActive {
614+func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
615+ if token.sess == nil || token.ac == nil || false {
616616 return nil
617617 }
618618 d.mu.Lock()
619619 targetSess, ok := localSession(d.sessions[target.Session])
620620 d.mu.Unlock()
621621 if !ok {
622- if !token.activeCurrent() {
622+ if !token.attachmentCurrent() {
623623 return nil
624624 }
625625 d.invalidateRender(token.sess, token.ac, true, "picker.go")
@@ -631,7 +631,7 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar
631631
632632 transition, err := d.transitionAttachment(attachmentTransitionRequest{
633633 source: token.sess, target: targetSess, next: token.ac,
634- expectedRole: attachmentActive, targetRole: attachmentActive,
634+
635635 expectedTransport: token.transport, sourceToken: &token, action: action,
636636 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
637637 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
@@ -639,13 +639,13 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar
639639 if err != nil {
640640 // Losing the exact source role is a benign stale action, not a notice for
641641 // whichever attachment replaced the initiator.
642- if !token.activeCurrent() {
642+ if !token.attachmentCurrent() {
643643 return nil
644644 }
645645 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
646646 }
647647 if guard.closePicker {
648- fresh, admitted := token.ac.beginRoleEffect(transition.published)
648+ fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
649649 if admitted {
650650 d.closePicker(token.ac)
651651 fresh.End()
@@ -665,34 +665,34 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
665665 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
666666 }
667667
668-// switchToTargetForRole is the sole client-originated navigation entry. The
668+// switchToTargetForAttachment is the sole client-originated navigation entry. The
669669 // intent captures the exact initiating capability before admission is released;
670670 // every active, stopped, and same-session target then uses transitionAttachment
671671 // for frozen, atomic source-token preflight.
672-func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {
673- if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.effect == nil {
672+func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
673+ if token.sess == nil || token.ac == nil || token.effect == nil {
674674 return nil
675675 }
676676 token.effect.bindActionEnd(d, action)
677677 token.effect.End()
678678 if target.RemoteKey != nil {
679- return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action)
679+ return d.switchToRemoteTargetForAttachment(token, target, *target.RemoteKey, guard, action)
680680 }
681681 if token.sess.isProxy() && !target.Stopped {
682- return d.switchActiveTargetForRoleGuarded(token, target, guard, action)
682+ return d.switchActiveTargetForAttachmentGuarded(token, target, guard, action)
683683 }
684684 sess, ok := localSession(token.sess)
685685 if !ok {
686686 return errAttachmentTransition
687687 }
688- return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action)
688+ return d.switchToTargetGuardedForAttachment(sess, token.ac, target, guard, &token, action)
689689 }
690690
691-// switchToRemoteTargetForRole performs dialing after the initiating effect has
691+// switchToRemoteTargetForAttachment performs dialing after the initiating effect has
692692 // ended and without role or architecture locks. transitionAttachment then
693693 // freezes and revalidates the exact source role and exact structured-key proxy
694694 // before publishing ownership.
695-func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, action string) error {
695+func (d *Daemon) switchToRemoteTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, action string) error {
696696 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" {
697697 return errAttachmentTransition
698698 }
@@ -710,7 +710,7 @@ func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target p
710710 ownsCandidate := !alreadyPublished && !constructionInFlight
711711 proxy, err := d.openProxySession(ctx, key, token.ac.size)
712712 if err != nil {
713- if !token.activeCurrent() {
713+ if !token.attachmentCurrent() {
714714 return errAttachmentTransition
715715 }
716716 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't connect to that remote session", err)
@@ -724,9 +724,9 @@ func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target p
724724
725725 transition, err := d.transitionAttachment(attachmentTransitionRequest{
726726 source: token.sess, target: proxy, next: token.ac,
727- expectedRole: attachmentActive, targetRole: attachmentActive,
727+
728728 expectedTransport: token.transport, sourceToken: &token, action: action,
729- preserveRole: proxy == token.sess, ready: true,
729+ preserveAttachment: proxy == token.sess, ready: true,
730730 })
731731 if err != nil {
732732 if ownsCandidate {
@@ -735,7 +735,7 @@ func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target p
735735 return errAttachmentTransition
736736 }
737737 if guard.closePicker {
738- fresh, admitted := token.ac.beginRoleEffect(transition.published)
738+ fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
739739 if admitted {
740740 d.closePicker(token.ac)
741741 fresh.End()
@@ -818,17 +818,17 @@ func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generatio
818818
819819 // switchToTargetGuarded is retained for daemon-internal and headless callers.
820820 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
821- return d.switchToTargetGuardedForRole(from, ac, target, guard, nil, "")
821+ return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")
822822 }
823823
824-func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) error {
824+func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) error {
825825 if target.Name != "" {
826826 ctx := d.serveCtx
827827 if ctx == nil {
828828 ctx = context.Background()
829829 }
830830 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
831- if sourceToken != nil && !sourceToken.activeCurrent() {
831+ if sourceToken != nil && !sourceToken.attachmentCurrent() {
832832 return errAttachmentTransition
833833 }
834834 d.invalidateRender(from, ac, true, "picker.go")
@@ -862,7 +862,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
862862 d.mu.Unlock()
863863
864864 if !switched {
865- if sourceToken != nil && !sourceToken.activeCurrent() {
865+ if sourceToken != nil && !sourceToken.attachmentCurrent() {
866866 return errAttachmentTransition
867867 }
868868 if guard.expectedSource != nil {
@@ -873,7 +873,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
873873 }
874874 if guard.closePicker {
875875 if sourceToken != nil {
876- fresh, admitted := ac.beginRoleEffect(transition.published)
876+ fresh, admitted := ac.beginAttachmentEffect(transition.published)
877877 if admitted {
878878 d.closePicker(ac)
879879 fresh.End()
@@ -885,7 +885,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
885885 d.deferAttachmentTransitionCleanups(transition)
886886 if targetSess == from {
887887 if sourceToken != nil {
888- fresh, admitted := ac.beginRoleEffect(transition.published)
888+ fresh, admitted := ac.beginAttachmentEffect(transition.published)
889889 if admitted {
890890 d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)
891891 d.invalidateRender(from, ac, true, "picker.go")
@@ -923,7 +923,7 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
923923
924924 // switchToActiveTargetLocked commits an active target handoff through the
925925 // centralized attachment transition. Caller holds d.mu.
926-func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentRoleToken, action string) (*session, attachmentTransitionResult, bool) {
926+func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) (*session, attachmentTransitionResult, bool) {
927927 if d.sessions[target.Session] != targetSess {
928928 return nil, attachmentTransitionResult{}, false
929929 }
@@ -943,9 +943,9 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
943943 d.mu.Unlock()
944944 transition, err := d.transitionAttachment(attachmentTransitionRequest{
945945 source: from, target: targetSess, next: ac,
946- expectedRole: attachmentActive, targetRole: attachmentActive,
946+
947947 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
948- activateTargetTab: true, targetTabIndex: target.TabIndex, preserveRole: true,
948+ activateTargetTab: true, targetTabIndex: target.TabIndex, preserveAttachment: true,
949949 })
950950 d.mu.Lock()
951951 return targetSess, transition, err == nil
@@ -981,11 +981,10 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
981981 expectedTransport = sourceToken.transport
982982 }
983983 transition, err := d.transitionAttachment(attachmentTransitionRequest{
984- source: from,
985- target: targetSess,
986- next: ac,
987- expectedRole: attachmentActive,
988- targetRole: attachmentActive,
984+ source: from,
985+ target: targetSess,
986+ next: ac,
987+
989988 expectedTransport: expectedTransport,
990989 sourceToken: sourceToken,
991990 action: action,
@@ -1007,7 +1006,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
10071006 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
10081007 // the handoff while d.mu is held. Creation failure leaves the source client
10091008 // and stopped record untouched.
1010-func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentRoleToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
1009+func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentConnectionToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
10111010 if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
10121011 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
10131012 }
@@ -1016,7 +1015,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
10161015 var targetSess *session
10171016 d.mu.Unlock()
10181017 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1019- source: from, next: ac, expectedRole: attachmentActive, targetRole: attachmentActive,
1018+ source: from, next: ac,
10201019 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
10211020 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
10221021 createTargetLocked: func() (*session, error) {
@@ -1065,7 +1064,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
10651064 d.mu.Unlock()
10661065 transition, err := d.transitionAttachment(attachmentTransitionRequest{
10671066 source: from, target: targetSess, next: ac,
1068- expectedRole: attachmentActive, targetRole: attachmentActive,
1067+
10691068 expectedTransport: ac.transportSnapshot(), ready: true,
10701069 })
10711070 d.mu.Lock()
@@ -1091,7 +1090,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
10911090 return nil
10921091 }
10931092 d.deferAttachmentTransitionCleanups(transition)
1094- return transition.displaced.ac
1093+ return nil
10951094 }
10961095
10971096 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
@@ -1116,7 +1115,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
11161115 return true
11171116 }
11181117
1119-func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentRoleToken) error {
1118+func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attachmentConnectionToken) error {
11201119 if token.ac == nil {
11211120 return d.killPickerTarget(target)
11221121 }
@@ -1127,9 +1126,9 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR
11271126 if target.Stopped {
11281127 token.effect.bindActionEnd(d, "picker-delete")
11291128 token.effect.End()
1130- frozen := freezeRoleEffectGates(token.ac)
1129+ frozen := freezeAttachmentEffectGates(token.ac)
11311130 defer frozen.unfreeze()
1132- if !d.sourceRoleTokenCurrentFrozen(token) {
1131+ if !d.sourceAttachmentTokenCurrentFrozen(token) {
11331132 return nil
11341133 }
11351134 return d.killPickerTarget(target)
@@ -1140,7 +1139,7 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR
11401139 if !ok {
11411140 return nil
11421141 }
1143- return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
1142+ return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
11441143 }
11451144
11461145 func (d *Daemon) killPickerTarget(target picker.Target) error {
M internal/usecase/daemon/picker_test.go +9 -10
@@ -993,7 +993,7 @@ func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) {
993993 require.NoError(t, err)
994994 require.Contains(t, string(displacedOutput.Data), "Session snatched")
995995 require.Same(t, sess2, ac2.currentSession())
996- require.Equal(t, attachmentSnatched, sess2.attachmentRole(ac2))
996+ require.Equal(t, true, sess2.attachmentRegistered(ac2))
997997
998998 movingFrame := awaitFrame(t, sends1, ports.MsgOutput)
999999 movingOutput, err := ports.UnmarshalOutput(movingFrame.Payload)
@@ -1120,11 +1120,10 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
11201120 switchErr := make(chan error, 1)
11211121 go func() {
11221122 result, err := d.transitionAttachment(attachmentTransitionRequest{
1123- source: sess1,
1124- target: sess2,
1125- next: ac,
1126- expectedRole: attachmentActive,
1127- targetRole: attachmentActive,
1123+ source: sess1,
1124+ target: sess2,
1125+ next: ac,
1126+
11281127 expectedTransport: ac.transportSnapshot(),
11291128 ready: true,
11301129 })
@@ -1139,7 +1138,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
11391138 case <-time.After(2 * time.Second):
11401139 t.Fatal("session switch waited for in-flight transport send")
11411140 }
1142- require.True(t, result.published.activeCurrent())
1141+ require.True(t, result.published.attachmentCurrent())
11431142 select {
11441143 case <-handoffAtSendMu:
11451144 t.Fatal("output rebase ran during architecture publication")
@@ -1688,7 +1687,7 @@ func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
16881687 }
16891688 }
16901689
1691-func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {
1690+func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {
16921691 d, current, ac, _, currentReleases := newManualTabSession(t, 1)
16931692 defer releaseAll(currentReleases)
16941693 current.id, current.name = "current", "z-current"
@@ -1715,8 +1714,8 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T)
17151714
17161715 token := current.attachmentToken(ac, ac.transport())
17171716 token.lease = current.renderCoordinator().attachmentLease(ac)
1718- ac.publishRoleCapability(token)
1719- effect, admitted := ac.beginRoleEffect(token)
1717+ ac.publishAttachmentCapability(token)
1718+ effect, admitted := ac.beginAttachmentEffect(token)
17201719 require.True(t, admitted)
17211720 d.handlePickerInput(ac, []byte("x"), effect)
17221721
M internal/usecase/daemon/prompt.go +5 -5
@@ -23,7 +23,7 @@ func (d *Daemon) enterPrompt(sess attachmentSession, ac *attachedClient, title,
2323 d.invalidateRender(sess, ac, true, "prompt.go")
2424 }
2525
26-func (d *Daemon) enterTransitionPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string, attachmentRoleToken) error) {
26+func (d *Daemon) enterTransitionPrompt(sess attachmentSession, ac *attachedClient, title, initial string, submit func(string, attachmentConnectionToken) error) {
2727 d.closePrompt(ac)
2828 ac.overlays.promptMu.Lock()
2929 ac.overlays.prompt = promptui.New(title, initial)
@@ -51,7 +51,7 @@ func promptValidationError(err error) bool {
5151 errors.Is(err, errProxyKillConfirmation)
5252 }
5353
54-func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {
54+func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
5555 sess := ac.currentAttachmentSession()
5656 if sess == nil {
5757 return
@@ -67,7 +67,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
6767 changed := false
6868 exit := false
6969 var submit func(string) error
70- var transitionSubmit func(string, attachmentRoleToken) error
70+ var transitionSubmit func(string, attachmentConnectionToken) error
7171 var submittedPrompt *promptui.Model
7272 var value string
7373
@@ -95,9 +95,9 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
9595 if submit != nil || transitionSubmit != nil {
9696 var err error
9797 if transitionSubmit != nil {
98- var token attachmentRoleToken
98+ var token attachmentConnectionToken
9999 if len(effects) != 0 && effects[0] != nil {
100- token = effects[0].roleToken()
100+ token = effects[0].connectionToken()
101101 }
102102 err = transitionSubmit(value, token)
103103 } else {
M internal/usecase/daemon/proxied_contract_test.go +5 -5
@@ -270,7 +270,7 @@ func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {
270270 ac.sendMu.Unlock()
271271
272272 token := sess.attachmentToken(ac, ac.transport())
273- require.False(t, d.handleActiveClientFrame(token, ports.Frame{
273+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{
274274 Type: ports.MsgOutputResetRequest,
275275 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
276276 }))
@@ -309,9 +309,9 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) {
309309
310310 token := sess.attachmentToken(ac, ac.transport())
311311 if tt.staleRole {
312- token.role = attachmentSnatched
312+ token.generation++
313313 }
314- require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgOutputResetRequest, Payload: tt.payload}))
314+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgOutputResetRequest, Payload: tt.payload}))
315315 ac.sendMu.Lock()
316316 acked := ac.output.acked
317317 ac.sendMu.Unlock()
@@ -331,11 +331,11 @@ func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) {
331331
332332 admitted := make(chan struct{})
333333 var once sync.Once
334- d.afterRoleEffectAdmitted = func(attachmentRoleToken) { once.Do(func() { close(admitted) }) }
334+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) { once.Do(func() { close(admitted) }) }
335335 ac.sendMu.Lock()
336336 done := make(chan bool, 1)
337337 go func() {
338- done <- d.handleActiveClientFrame(token, ports.Frame{
338+ done <- d.handleAttachmentClientFrame(token, ports.Frame{
339339 Type: ports.MsgOutputResetRequest,
340340 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
341341 })
M internal/usecase/daemon/proxy_input.go +18 -18
@@ -42,36 +42,36 @@ func (p *proxySession) sendInput(raw []byte) error {
4242 // proxy. Local UI actions never reach the link. Every remote-owned action is
4343 // sent using the exact bytes supplied by keys.Router.
4444 type proxyKeyHandler struct {
45- d *Daemon
46- proxy *proxySession
47- ac *attachedClient
48- roleToken attachmentRoleToken
45+ d *Daemon
46+ proxy *proxySession
47+ ac *attachedClient
48+ connectionToken attachmentConnectionToken
4949 }
5050
51-func (h proxyKeyHandler) acquireRoleEffect() (*roleEffectTicket, bool, bool) {
51+func (h proxyKeyHandler) acquireAttachmentEffect() (*attachmentEffectTicket, bool, bool) {
5252 if h.proxy == nil || h.ac == nil {
5353 return nil, false, false
5454 }
55- if h.roleToken.ac == nil {
55+ if h.connectionToken.ac == nil {
5656 return nil, false, false
5757 }
58- if h.roleToken.sess != h.proxy || h.roleToken.ac != h.ac {
58+ if h.connectionToken.sess != h.proxy || h.connectionToken.ac != h.ac {
5959 return nil, false, false
6060 }
61- if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {
61+ if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
6262 return effect, false, true
6363 }
64- effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)
64+ effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
6565 if h.d != nil && h.d.afterDelayedKeyEffectAttempt != nil {
6666 h.d.afterDelayedKeyEffectAttempt(admitted)
6767 }
6868 if !admitted {
6969 return nil, false, false
7070 }
71- if h.d != nil && h.d.afterRoleEffectAdmitted != nil {
72- token := h.roleToken
71+ if h.d != nil && h.d.afterAttachmentEffectAdmitted != nil {
72+ token := h.connectionToken
7373 token.effect = effect
74- h.d.afterRoleEffectAdmitted(token)
74+ h.d.afterAttachmentEffectAdmitted(token)
7575 }
7676 return effect, true, true
7777 }
@@ -86,7 +86,7 @@ func (h proxyKeyHandler) sendOwned(raw []byte) {
8686 }
8787
8888 func (h proxyKeyHandler) send(raw []byte) {
89- effect, owned, ok := h.acquireRoleEffect()
89+ effect, owned, ok := h.acquireAttachmentEffect()
9090 if !ok {
9191 return
9292 }
@@ -101,14 +101,14 @@ func (h proxyKeyHandler) Forward(raw []byte) { h.send(raw) }
101101 func (h proxyKeyHandler) Mouse(event mouse.Event) { h.send(event.Raw) }
102102
103103 func (h proxyKeyHandler) Action(action keys.Action, raw []byte) {
104- effect, owned, ok := h.acquireRoleEffect()
104+ effect, owned, ok := h.acquireAttachmentEffect()
105105 if !ok {
106106 return
107107 }
108108 if owned {
109109 defer effect.End()
110110 }
111- h.roleToken.effect = effect
111+ h.connectionToken.effect = effect
112112 switch action {
113113 case keys.ActionOpenPalette:
114114 h.enterPalette()
@@ -176,7 +176,7 @@ func (h proxyKeyHandler) enterPalette() {
176176 // navigation. A pending tab on the proxy remains remote-owned; without one,
177177 // the local daemon may move the client to another local session.
178178 func (h proxyKeyHandler) jumpLocalAttention() {
179- if h.d == nil || h.roleToken.ac == nil || h.roleToken.effect == nil {
179+ if h.d == nil || h.connectionToken.ac == nil || h.connectionToken.effect == nil {
180180 return
181181 }
182182 target, ok := h.d.oldestOtherSessionAttention(nil)
@@ -190,12 +190,12 @@ func (h proxyKeyHandler) jumpLocalAttention() {
190190 return
191191 }
192192
193- token := h.roleToken
193+ token := h.connectionToken
194194 token.effect.bindActionEnd(h.d, "proxy-jump-attention")
195195 token.effect.End()
196196 transition, err := h.d.transitionAttachment(attachmentTransitionRequest{
197197 source: h.proxy, target: targetSession, next: h.ac,
198- expectedRole: attachmentActive, targetRole: attachmentActive,
198+
199199 expectedTransport: token.transport, sourceToken: &token,
200200 action: "proxy-jump-attention", activateTargetTab: true,
201201 targetTabIndex: target.tabIndex, ready: true,
M internal/usecase/daemon/proxy_input_test.go +55 -55
@@ -35,11 +35,11 @@ func newProxyInputHarness(t *testing.T) (*Daemon, *proxySession, *attachedClient
3535 proxy.mu.Unlock()
3636 ac := &attachedClient{size: domain.Size{Cols: 80, Rows: 24}, output: newOutputStateStream()}
3737 ac.initOverlays()
38- token := roleEffectForTest(t, attachProxyInputRole(t, d, proxy, ac))
39- return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: token}
38+ token := attachmentEffectForTest(t, attachProxyInputRole(t, d, proxy, ac))
39+ return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: token}
4040 }
4141
42-func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *attachedClient) attachmentRoleToken {
42+func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *attachedClient) attachmentConnectionToken {
4343 t.Helper()
4444 clientTransport := newProxyTestTransport()
4545 ac.replaceTransport(clientTransport)
@@ -49,7 +49,7 @@ func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *atta
4949 d.mu.Unlock()
5050 transition, err := d.transitionAttachment(attachmentTransitionRequest{
5151 target: proxy, next: ac,
52- expectedRole: attachmentDetached, targetRole: attachmentActive,
52+
5353 expectedTransport: ac.transportSnapshot(), ready: true,
5454 })
5555 require.NoError(t, err)
@@ -57,9 +57,9 @@ func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *atta
5757 return transition.published
5858 }
5959
60-func roleEffectForTest(t *testing.T, token attachmentRoleToken) attachmentRoleToken {
60+func attachmentEffectForTest(t *testing.T, token attachmentConnectionToken) attachmentConnectionToken {
6161 t.Helper()
62- effect, admitted := token.ac.beginRoleEffect(token)
62+ effect, admitted := token.ac.beginAttachmentEffect(token)
6363 require.True(t, admitted)
6464 token.effect = effect
6565 t.Cleanup(effect.End)
@@ -151,22 +151,22 @@ func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) {
151151
152152 t.Run("retained escape reacquires its original role", func(t *testing.T) {
153153 d, proxy, ac, link, initial := newProxyInputHarness(t)
154- base := initial.roleToken
154+ base := initial.connectionToken
155155 base.effect.End()
156156 base.effect = nil
157157
158- firstEffect, admitted := ac.beginRoleEffect(base)
158+ firstEffect, admitted := ac.beginAttachmentEffect(base)
159159 require.True(t, admitted)
160160 first := base
161161 first.effect = firstEffect
162- ac.keys.RouteWithHandler([]byte{keys.ESC}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: first})
162+ ac.keys.RouteWithHandler([]byte{keys.ESC}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: first})
163163 firstEffect.End()
164164
165- secondEffect, admitted := ac.beginRoleEffect(base)
165+ secondEffect, admitted := ac.beginAttachmentEffect(base)
166166 require.True(t, admitted)
167167 second := base
168168 second.effect = secondEffect
169- ac.keys.RouteWithHandler([]byte{'j'}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: second})
169+ ac.keys.RouteWithHandler([]byte{'j'}, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: second})
170170 secondEffect.End()
171171
172172 requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'})
@@ -175,15 +175,15 @@ func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) {
175175
176176 func TestProxyInputLocalPaletteNeverForwards(t *testing.T) {
177177 d, _, ac, link, handler := newProxyInputHarness(t)
178- token := handler.roleToken
178+ token := handler.connectionToken
179179 handler.Action(keys.ActionOpenPalette, []byte{keys.ESC, ' '})
180180 require.True(t, ac.overlays.paletteActive())
181181 requireNoProxyFrame(t, link)
182182
183183 // Keyboard and mouse navigation are consumed by the local overlay before
184184 // either the proxy router or proxy mouse sender can observe them.
185- d.handleInputForRole(token, []byte("x"))
186- d.handleInputForRole(token, []byte("\x1b[<0;12;8M"))
185+ d.handleInputForAttachment(token, []byte("x"))
186+ d.handleInputForAttachment(token, []byte("\x1b[<0;12;8M"))
187187 requireNoProxyFrame(t, link)
188188 }
189189
@@ -259,7 +259,7 @@ func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) {
259259 t.Run("new session opens local transition prompt", func(t *testing.T) {
260260 d, proxy, ac, link, handler := newProxyInputHarness(t)
261261 handler.enterPalette()
262- d.handlePaletteInput(ac, []byte("CNS\r"), handler.roleToken.effect)
262+ d.handlePaletteInput(ac, []byte("CNS\r"), handler.connectionToken.effect)
263263
264264 require.False(t, ac.overlays.paletteActive())
265265 require.True(t, ac.overlays.promptActive())
@@ -276,7 +276,7 @@ func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) {
276276 for len(client.sent) > 0 {
277277 <-client.sent
278278 }
279- d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect)
279+ d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect)
280280
281281 require.False(t, ac.overlays.paletteActive())
282282 require.Same(t, proxy, ac.currentAttachmentSession())
@@ -522,7 +522,7 @@ func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *test
522522 payload, err := ports.MarshalCommandRequest(request)
523523 require.NoError(t, err)
524524 token := sess.attachmentToken(ac, ac.transport())
525- require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
525+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
526526 frame := awaitFrame(t, sends, ports.MsgCommandResult)
527527 result, err := ports.UnmarshalCommandResult(frame.Payload)
528528 require.NoError(t, err)
@@ -553,7 +553,7 @@ func TestProxyAttachedCommandRemoteErrorCreatesLocalNotice(t *testing.T) {
553553 handler.enterPalette()
554554 done := make(chan struct{})
555555 go func() {
556- d.handlePaletteInput(ac, []byte("NXT\r"), handler.roleToken.effect)
556+ d.handlePaletteInput(ac, []byte("NXT\r"), handler.connectionToken.effect)
557557 close(done)
558558 }()
559559 request := requireProxyCommandRequest(t, link)
@@ -623,10 +623,10 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
623623 d.attachCoordinator(source, nil, ac, true)
624624
625625 token := source.attachmentToken(ac, ac.transport())
626- effect, admitted := ac.beginRoleEffect(token)
626+ effect, admitted := ac.beginAttachmentEffect(token)
627627 require.True(t, admitted)
628628 token.effect = effect
629- err := d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
629+ err := d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
630630 require.NoError(t, err)
631631 require.True(t, daemonLockAvailable)
632632 require.True(t, sourceLockAvailable)
@@ -637,19 +637,19 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
637637
638638 wrong := domain.RemoteSessionKey{Host: "arch", Name: "other"}
639639 currentToken := attachmentToken(proxy, ac, ac.transport())
640- currentEffect, admitted := ac.beginRoleEffect(currentToken)
640+ currentEffect, admitted := ac.beginAttachmentEffect(currentToken)
641641 require.True(t, admitted)
642642 currentToken.effect = currentEffect
643- err = d.switchToTargetForRole(currentToken, picker.Target{Session: key.ID(), RemoteKey: &wrong, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
643+ err = d.switchToTargetForAttachment(currentToken, picker.Target{Session: key.ID(), RemoteKey: &wrong, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
644644 require.ErrorIs(t, err, errAttachmentTransition)
645645 require.Same(t, proxy, ac.currentAttachmentSession(), "mismatched structured key must not redirect attachment")
646646
647647 staleToken := attachmentToken(proxy, ac, ac.transport())
648- staleEffect, admitted := ac.beginRoleEffect(staleToken)
648+ staleEffect, admitted := ac.beginAttachmentEffect(staleToken)
649649 require.True(t, admitted)
650650 staleToken.effect = staleEffect
651- ac.roleGeneration.Add(1) // model a concurrent role replacement after selection
652- err = d.switchToTargetForRole(staleToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
651+ ac.connectionGeneration.Add(1) // model a concurrent role replacement after selection
652+ err = d.switchToTargetForAttachment(staleToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
653653 require.ErrorIs(t, err, errAttachmentTransition)
654654 require.Same(t, proxy, ac.currentAttachmentSession(), "stale initiating role must not republish attachment")
655655 }
@@ -676,7 +676,7 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T)
676676 require.Equal(t, key, *target.RemoteKey)
677677
678678 token := local.attachmentToken(ac, ac.transport())
679- effect, admitted := ac.beginRoleEffect(token)
679+ effect, admitted := ac.beginAttachmentEffect(token)
680680 require.True(t, admitted)
681681 d.handlePickerInput(ac, []byte("\r"), effect)
682682 proxy, ok := ac.currentAttachmentSession().(*proxySession)
@@ -702,17 +702,17 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
702702 require.Equal(t, local.name, target.Name)
703703 require.False(t, target.Stopped)
704704
705- require.NoError(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
705+ require.NoError(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
706706 require.Same(t, local, ac.currentSession())
707707 require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker")
708708
709709 d.remoteDialerFactory = portsmocks.NewMockRemoteDialerFactory(t)
710- localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
711- require.NoError(t, d.backSessionForRole(localToken))
710+ localToken := attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
711+ require.NoError(t, d.backSessionForAttachment(localToken))
712712 require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy")
713713
714- proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
715- require.NoError(t, d.switchToTargetForRole(proxyToken, target, sessionHandoffGuard{}, "picker-select"))
714+ proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
715+ require.NoError(t, d.switchToTargetForAttachment(proxyToken, target, sessionHandoffGuard{}, "picker-select"))
716716 require.Same(t, local, ac.currentSession())
717717 d.backSession(local, ac)
718718 require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity")
@@ -720,7 +720,7 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
720720
721721 func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) {
722722 d, proxy, ac, _, handler := newProxyInputHarness(t)
723- err := d.switchToTargetForRole(handler.roleToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select")
723+ err := d.switchToTargetForAttachment(handler.connectionToken, picker.Target{Session: "missing", TabIndex: 0}, sessionHandoffGuard{}, "picker-select")
724724 require.Error(t, err)
725725 require.Same(t, proxy, ac.currentAttachmentSession())
726726 }
@@ -737,15 +737,15 @@ func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) {
737737 model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
738738 target, selectable := model.Selected()
739739 require.True(t, selectable)
740- d.afterRoleEffectsFrozen = func() {
741- d.afterRoleEffectsFrozen = nil
740+ d.afterAttachmentEffectsFrozen = func() {
741+ d.afterAttachmentEffectsFrozen = nil
742742 local.mu.Lock()
743743 local.name = "replacement"
744744 local.createdAt++
745745 local.mu.Unlock()
746746 }
747747
748- require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
748+ require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
749749 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment")
750750 }
751751
@@ -762,14 +762,14 @@ func TestRemotePickerRejectsReplacedLocalIncarnation(t *testing.T) {
762762 model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, Incarnation: local.incarnation, TabID: domain.TabStableID(tb.stableID)})
763763 target, selectable := model.Selected()
764764 require.True(t, selectable)
765- d.afterRoleEffectsFrozen = func() {
766- d.afterRoleEffectsFrozen = nil
765+ d.afterAttachmentEffectsFrozen = func() {
766+ d.afterAttachmentEffectsFrozen = nil
767767 local.mu.Lock()
768768 local.incarnation = domain.IncarnationID{2}
769769 local.mu.Unlock()
770770 }
771771
772- require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
772+ require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
773773 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker incarnation must not redirect the attachment")
774774 }
775775
@@ -790,14 +790,14 @@ func TestRemotePickerRejectsReplacedLocalTab(t *testing.T) {
790790 target, selectable := model.Selected()
791791 require.True(t, selectable)
792792 require.Equal(t, domain.TabStableID(second.stableID), target.TabID)
793- d.afterRoleEffectsFrozen = func() {
794- d.afterRoleEffectsFrozen = nil
793+ d.afterAttachmentEffectsFrozen = func() {
794+ d.afterAttachmentEffectsFrozen = nil
795795 local.mu.Lock()
796796 local.tabs[0], local.tabs[1] = local.tabs[1], local.tabs[0]
797797 local.mu.Unlock()
798798 }
799799
800- require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))
800+ require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
801801 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker tab must not redirect the attachment")
802802 }
803803
@@ -812,14 +812,14 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
812812 d.remoteDialerFactory = newProxyConstructionFactory(remote)
813813 d.remoteTransportMode = ports.RemoteTransportUDP
814814
815- localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
816- require.NoError(t, d.switchToTargetForRole(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
815+ localToken := attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
816+ require.NoError(t, d.switchToTargetForAttachment(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
817817 proxy, ok := ac.currentAttachmentSession().(*proxySession)
818818 require.True(t, ok)
819819 t.Cleanup(func() { stopProxy(t, proxy) })
820820
821- proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
822- require.NoError(t, d.switchToTargetForRole(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
821+ proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
822+ require.NoError(t, d.switchToTargetForAttachment(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
823823 require.Same(t, local, ac.currentSession())
824824
825825 model := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
@@ -827,8 +827,8 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
827827 target, selectable := model.Selected()
828828 require.True(t, selectable)
829829 require.Empty(t, target.Name, "live proxy rows must route only by their structured remote key")
830- localToken = roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))
831- require.NoError(t, d.switchToTargetForRole(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
830+ localToken = attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
831+ require.NoError(t, d.switchToTargetForAttachment(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
832832 require.Same(t, proxy, ac.currentAttachmentSession())
833833 require.False(t, ac.overlays.pickerActive())
834834 }
@@ -856,7 +856,7 @@ func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) {
856856 }
857857 }
858858
859- _, _, ok := d.currentConnectionRole(ac, client)
859+ _, _, ok := d.currentAttachmentConnection(ac, client)
860860 require.False(t, ok)
861861 require.Less(t, attempts, unstableSnapshots)
862862 }
@@ -904,13 +904,13 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
904904 remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
905905 d.remoteDialerFactory = newProxyConstructionFactory(remote)
906906 d.remoteTransportMode = ports.RemoteTransportUDP
907- token := roleEffectForTest(t, local.attachmentToken(ac, client))
907+ token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
908908 snapshots := make(chan attachmentSession, 1)
909909 transitionErr := make(chan error, 1)
910910 d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
911911 d.afterConnectionSessionSnapshot = nil
912912 snapshots <- snapshot
913- transitionErr <- d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
913+ transitionErr <- d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
914914 }
915915
916916 loopDone := make(chan struct{})
@@ -951,8 +951,8 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T)
951951 }()
952952 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
953953
954- token := roleEffectForTest(t, local.attachmentToken(ac, client))
955- require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
954+ token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
955+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
956956 proxy, ok := ac.currentAttachmentSession().(*proxySession)
957957 require.True(t, ok)
958958 t.Cleanup(func() { stopProxy(t, proxy) })
@@ -980,7 +980,7 @@ func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) {
980980 }()
981981 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
982982
983- require.NoError(t, d.switchToTargetForRole(handler.roleToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
983+ require.NoError(t, d.switchToTargetForAttachment(handler.connectionToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
984984 require.Same(t, local, ac.currentSession())
985985 client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
986986 awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session")
@@ -1009,10 +1009,10 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
10091009 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
10101010
10111011 token := local.attachmentToken(ac, client)
1012- effect, admitted := ac.beginRoleEffect(token)
1012+ effect, admitted := ac.beginAttachmentEffect(token)
10131013 require.True(t, admitted)
10141014 token.effect = effect
1015- require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
1015+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
10161016 proxy, ok := ac.currentAttachmentSession().(*proxySession)
10171017 require.True(t, ok)
10181018 t.Cleanup(func() { stopProxy(t, proxy) })
M internal/usecase/daemon/proxy_kill.go +2 -2
@@ -47,12 +47,12 @@ func (d *Daemon) enterRemoteKillConfirmation(owner attachmentSession, ac *attach
4747 if unreachable {
4848 title = " Type " + key.Display() + " to kill (remote unreachable) "
4949 }
50- d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentRoleToken) error {
50+ d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentConnectionToken) error {
5151 if value != key.Display() {
5252 return errProxyKillConfirmation
5353 }
5454 if token.ac != nil {
55- if !token.activeCurrent() {
55+ if !token.attachmentCurrent() {
5656 return errAttachmentTransition
5757 }
5858 token.effect.bindActionEnd(d, "remote-picker-delete")
M internal/usecase/daemon/proxy_lifecycle.go +2 -2
@@ -39,8 +39,8 @@ func (t *proxyWarmTimer) stop() {
3939 // proxyAttachmentTransitionCommitted applies lifecycle policy only after the
4040 // attachment publication has released every architecture lock. Each helper
4141 // performs its own exact pointer/client revalidation before changing a timer.
42-func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveRole bool) {
43- if d == nil || preserveRole || source == target {
42+func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveAttachment bool) {
43+ if d == nil || preserveAttachment || source == target {
4444 return
4545 }
4646 if proxy, ok := source.(*proxySession); ok {
M internal/usecase/daemon/proxy_lifecycle_test.go +29 -28
@@ -186,7 +186,7 @@ func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
186186
187187 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
188188 source: local, target: proxy, next: ac,
189- expectedRole: attachmentActive, targetRole: attachmentActive,
189+
190190 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
191191 action: "lifecycle test", ready: true,
192192 })
@@ -198,7 +198,7 @@ func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
198198
199199 _, err = d.transitionAttachment(attachmentTransitionRequest{
200200 source: proxy, target: local, next: ac,
201- expectedRole: attachmentActive, targetRole: attachmentActive,
201+
202202 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
203203 action: "lifecycle test", ready: true,
204204 })
@@ -484,24 +484,25 @@ func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) {
484484 next.initOverlays()
485485 result, err := d.transitionAttachment(attachmentTransitionRequest{
486486 target: sess, next: next,
487- expectedRole: attachmentDetached, targetRole: attachmentActive,
487+
488488 expectedTransport: next.transportSnapshot(), ready: true,
489489 })
490490 require.NoError(t, err)
491- require.Nil(t, result.displaced.ac, "a proxied attachment must never enter the snatched role")
491+ require.NotNil(t, result.published.ac)
492492 require.Contains(t, sess.snapshotAttachments(), next)
493- require.Nil(t, old.currentAttachmentSession())
493+ require.Same(t, sess, old.currentAttachmentSession())
494494
495495 d.deferAttachmentTransitionCleanups(result)
496496 d.waitNotifies()
497- frame := awaitTestValue(t, oldTransport.sent, "replacement did not notify old transport")
498- detached, err := ports.UnmarshalDetached(frame.Payload)
499- require.NoError(t, err)
500- require.Equal(t, ports.ReasonReplaced, detached.Reason)
497+ select {
498+ case <-oldTransport.sent:
499+ t.Fatal("publishing an attachment displaced the existing connection")
500+ default:
501+ }
501502 select {
502503 case <-oldTransport.closed:
504+ t.Fatal("publishing an attachment closed the existing connection")
503505 default:
504- t.Fatal("terminal proxied replacement did not close its transport")
505506 }
506507 }
507508
@@ -552,7 +553,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
552553
553554 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
554555 source: local, target: proxy, next: ac,
555- expectedRole: attachmentActive, targetRole: attachmentActive,
556+
556557 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
557558 action: "replaced picker regression", ready: true,
558559 })
@@ -567,7 +568,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
567568
568569 _, err = d.transitionAttachment(attachmentTransitionRequest{
569570 source: proxy, target: local, next: ac,
570- expectedRole: attachmentActive, targetRole: attachmentActive,
571+
571572 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
572573 action: "replaced picker regression", ready: true,
573574 })
@@ -613,10 +614,10 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
613614 require.Same(t, local, ac.currentAttachmentSession(), "picker activation reattached the terminal proxy")
614615
615616 token := attachmentToken(local, ac, ac.transport())
616- effect, admitted := ac.beginRoleEffect(token)
617+ effect, admitted := ac.beginAttachmentEffect(token)
617618 require.True(t, admitted)
618619 token.effect = effect
619- err = d.switchToTargetForRole(token, cursor, sessionHandoffGuard{}, "replaced picker regression")
620+ err = d.switchToTargetForAttachment(token, cursor, sessionHandoffGuard{}, "replaced picker regression")
620621 require.Error(t, err)
621622 require.Same(t, local, ac.currentAttachmentSession(), "defensive activation reused the terminal proxy")
622623
@@ -1087,7 +1088,7 @@ func TestProxyKillConfirmationRequiresExactRemoteDisplay(t *testing.T) {
10871088 require.Equal(t, " Type work@arch to kill ", ac.overlays.prompt.Title())
10881089 submit := ac.overlays.promptTransitionSubmit
10891090 ac.overlays.promptMu.Unlock()
1090- require.Error(t, submit("work", attachmentRoleToken{}))
1091+ require.Error(t, submit("work", attachmentConnectionToken{}))
10911092 d.mu.Lock()
10921093 require.Same(t, proxy, d.sessions[proxy.id])
10931094 d.mu.Unlock()
@@ -1546,7 +1547,7 @@ func TestExpireWarmProxyIgnoresSupersededToken(t *testing.T) {
15461547 require.False(t, proxyRegistered(d, p))
15471548 }
15481549
1549-func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedClient, *closeTrackingTransport, *warmHookClock, attachmentRoleToken, *renderCoordinator) {
1550+func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedClient, *closeTrackingTransport, *warmHookClock, attachmentConnectionToken, *renderCoordinator) {
15501551 t.Helper()
15511552 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
15521553 clock := &warmHookClock{timers: make(chan *signalTimer, 4)}
@@ -1559,7 +1560,7 @@ func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedC
15591560 proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"})
15601561 transition, err := d.transitionAttachment(attachmentTransitionRequest{
15611562 source: local, target: proxy, next: ac,
1562- expectedRole: attachmentActive, targetRole: attachmentActive,
1563+
15631564 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
15641565 action: "proxy detach fixture", ready: true,
15651566 })
@@ -1570,21 +1571,21 @@ func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedC
15701571 func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) {
15711572 tests := []struct {
15721573 name string
1573- detach func(*Daemon, attachmentRoleToken, ports.Transport) bool
1574+ detach func(*Daemon, attachmentConnectionToken, ports.Transport) bool
15741575 }{
15751576 {
15761577 name: "explicit",
1577- detach: func(d *Daemon, token attachmentRoleToken, _ ports.Transport) bool {
1578- effect, admitted := token.ac.beginRoleEffect(token)
1578+ detach: func(d *Daemon, token attachmentConnectionToken, _ ports.Transport) bool {
1579+ effect, admitted := token.ac.beginAttachmentEffect(token)
15791580 require.True(t, admitted)
15801581 token.effect = effect
1581- return d.clientGoneForRole(token, true)
1582+ return d.clientGoneForAttachment(token, true)
15821583 },
15831584 },
15841585 {
15851586 name: "send error",
1586- detach: func(d *Daemon, token attachmentRoleToken, failed ports.Transport) bool {
1587- d.detachOnRoleSendError(token, failed)
1587+ detach: func(d *Daemon, token attachmentConnectionToken, failed ports.Transport) bool {
1588+ d.detachOnAttachmentSendError(token, failed)
15881589 return token.ac.currentAttachmentSession() == nil
15891590 },
15901591 },
@@ -1622,11 +1623,11 @@ func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) {
16221623 func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
16231624 tests := []struct {
16241625 name string
1625- stale func(*attachedClient, *attachmentRoleToken) *closeTrackingTransport
1626+ stale func(*attachedClient, *attachmentConnectionToken) *closeTrackingTransport
16261627 }{
16271628 {
16281629 name: "transport",
1629- stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport {
1630+ stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport {
16301631 current := &closeTrackingTransport{}
16311632 ac.replaceTransport(current)
16321633 return current
@@ -1634,8 +1635,8 @@ func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
16341635 },
16351636 {
16361637 name: "role generation",
1637- stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport {
1638- ac.roleGeneration.Add(1)
1638+ stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport {
1639+ ac.connectionGeneration.Add(1)
16391640 return nil
16401641 },
16411642 },
@@ -1644,7 +1645,7 @@ func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
16441645 t.Run(tt.name, func(t *testing.T) {
16451646 d, proxy, ac, failed, clock, token, _ := newProxyRoleDetachFixture(t)
16461647 current := tt.stale(ac, &token)
1647- d.detachOnRoleSendError(token, failed)
1648+ d.detachOnAttachmentSendError(token, failed)
16481649 require.Same(t, proxy, ac.currentAttachmentSession())
16491650 if current != nil {
16501651 require.False(t, current.Closed(), "stale transport error closed the rebound client")
M internal/usecase/daemon/proxy_link.go +4 -4
@@ -652,7 +652,7 @@ func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generat
652652 }
653653
654654 // handleProxySideEffect forwards a stateless remote effect to the exact local
655-// attachment that currently owns this proxy. Role admission and transport
655+// attachment that currently owns this proxy. Connection admission and transport
656656 // incarnation checks make a stale handoff a harmless drop rather than a send
657657 // to the client's next session.
658658 func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error {
@@ -672,10 +672,10 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p
672672 return nil
673673 }
674674 token := attachmentToken(p, ac, expected.transport)
675- if token.role != attachmentActive || token.sess != p {
675+ if token.sess != p {
676676 return nil
677677 }
678- ticket, admitted := ac.beginRoleEffect(token)
678+ ticket, admitted := ac.beginAttachmentEffect(token)
679679 if !admitted {
680680 return nil
681681 }
@@ -684,7 +684,7 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p
684684 return nil
685685 }
686686 frame := ac.output.sideEffect(out.Data, ac.echoAck.Load())
687- if err := ac.sendExpectedTransportForRole(expected, frame, ticket); err != nil {
687+ if err := ac.sendExpectedTransportForAttachment(expected, frame, ticket); err != nil {
688688 if errors.Is(err, errAttachmentTransition) {
689689 return nil
690690 }
M internal/usecase/daemon/proxy_output_test.go +2 -2
@@ -1031,10 +1031,10 @@ func TestProxySideEffectDropsStaleGenerationAndRole(t *testing.T) {
10311031 }
10321032
10331033 token := attachmentToken(proxy, ac, ac.transport())
1034- ticket, admitted := ac.beginRoleEffect(token)
1034+ ticket, admitted := ac.beginAttachmentEffect(token)
10351035 require.True(t, admitted)
10361036 ticket.End()
1037- ac.roleGeneration.Add(1)
1037+ ac.connectionGeneration.Add(1)
10381038 result, err = d.handleLinkFrame(proxy, 1, frame)
10391039 require.Equal(t, proxyLinkResume, result)
10401040 require.NoError(t, err)
M internal/usecase/daemon/proxy_render_test.go +2 -2
@@ -118,7 +118,7 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
118118
119119 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
120120 source: local, target: proxy, next: ac,
121- expectedRole: attachmentActive, targetRole: attachmentActive,
121+
122122 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
123123 action: "test transition", ready: true,
124124 })
@@ -129,7 +129,7 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
129129
130130 toLocal, err := d.transitionAttachment(attachmentTransitionRequest{
131131 source: proxy, target: local, next: ac,
132- expectedRole: attachmentActive, targetRole: attachmentActive,
132+
133133 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
134134 action: "test transition", ready: true,
135135 })
M internal/usecase/daemon/render.go +5 -5
@@ -258,9 +258,9 @@ func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid
258258 // paint owns attachment sendMu while it captures, composes, and emits; JSONL
259259 // observer I/O must therefore happen only after that ownership is released.
260260 type runtimeMarkBatch struct {
261- observer ports.RuntimeObserver
262- marks []ports.RuntimeMark
263- roleEffect *roleEffectTicket
261+ observer ports.RuntimeObserver
262+ marks []ports.RuntimeMark
263+ attachmentEffect *attachmentEffectTicket
264264 }
265265
266266 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
@@ -310,11 +310,11 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
310310 if lease != nil {
311311 token := attachmentToken(entry, ac, ac.transport())
312312 token.lease = lease
313- ticket, admitted := ac.beginRoleEffect(token)
313+ ticket, admitted := ac.beginAttachmentEffect(token)
314314 if !admitted {
315315 return
316316 }
317- marks.roleEffect = ticket
317+ marks.attachmentEffect = ticket
318318 defer ticket.End()
319319 }
320320 var tb *tab
M internal/usecase/daemon/render_pipeline.go +9 -9
@@ -532,13 +532,13 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
532532 sendTr = sendTransport.transport
533533 if ac.proxied {
534534 if sess, ok := localSession(entry); ok {
535- sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)
535+ sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.attachmentEffect)
536536 } else if proxy, ok := entry.(*proxySession); ok {
537537 meta, metaOK := proxy.sessionMetaSnapshot()
538538 if !metaOK {
539539 sendErr = errSessionMetaUnavailable
540540 } else {
541- sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.roleEffect)
541+ sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.attachmentEffect)
542542 }
543543 }
544544 }
@@ -552,7 +552,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
552552 if async, ok := sendTr.(ports.AsyncTransport); ok {
553553 send = async.SendAsync
554554 }
555- interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)
555+ interruptible := marks.attachmentEffect != nil && marks.attachmentEffect.beginTransportSend(sendTransport)
556556 if ac.proxied {
557557 sendErr = preparedScreen.send(send)
558558 } else {
@@ -560,9 +560,9 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
560560 }
561561 if interruptible {
562562 if sendErr != nil {
563- marks.roleEffect.reportTransportFailure(sendTransport)
563+ marks.attachmentEffect.reportTransportFailure(sendTransport)
564564 }
565- marks.roleEffect.endTransportSend()
565+ marks.attachmentEffect.endTransportSend()
566566 }
567567 }
568568 endEmit(uint64(len(data)), sendErr == nil)
@@ -611,7 +611,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
611611 // A transport failure may invalidate the role gate. Release this render's
612612 // admission first. Detachment freezes the gate and therefore cannot mutate
613613 // ownership until any enclosing admitted operation has also ended.
614- if marks.roleEffect == nil {
614+ if marks.attachmentEffect == nil {
615615 if sess, ok := localSession(entry); ok {
616616 d.detachOnSendError(sess, ac, sendTr)
617617 } else if proxy, ok := entry.(*proxySession); ok {
@@ -621,9 +621,9 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
621621 // Capture the exact admitted capability, including its coordinator
622622 // lease, before End permits a replacement publication. Reserve cleanup
623623 // accounting before End so terminal Wait cannot race a later Add.
624- token := marks.roleEffect.roleToken()
625- launchCleanup := d.reserveRoleSendErrorCleanup(token, sendTr)
626- marks.roleEffect.End()
624+ token := marks.attachmentEffect.connectionToken()
625+ launchCleanup := d.reserveAttachmentSendErrorCleanup(token, sendTr)
626+ marks.attachmentEffect.End()
627627 launchCleanup()
628628 }
629629 }
M internal/usecase/daemon/resize_publication.go +10 -12
@@ -11,7 +11,7 @@ type resizeCommitPublication struct {
1111 attachment *attachedClient
1212 lease *attachmentLease
1313 epoch uint64
14- role attachmentRoleToken
14+ connection attachmentConnectionToken
1515 coordinator *renderCoordinator
1616 observer ports.RuntimeObserver
1717 mark ports.RuntimeMark
@@ -25,20 +25,18 @@ func (p resizeCommitPublication) current() bool {
2525 return true
2626 }
2727 // Attached resize publication is admitted only through a live role ticket.
28- if p.role.effect == nil {
28+ if p.connection.effect == nil {
2929 return false
3030 }
31- if p.role.effect.ended.Load() || p.role.sess != p.session ||
32- p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||
33- p.role.generation != p.attachment.roleGeneration.Load() ||
34- !p.attachment.transportSnapshotCurrent(p.role.transport) ||
31+ if p.connection.effect.ended.Load() || p.connection.sess != p.session ||
32+ p.connection.ac != p.attachment || p.connection.lease != p.lease ||
33+ p.connection.generation != p.attachment.connectionGeneration.Load() ||
34+ !p.attachment.transportSnapshotCurrent(p.connection.transport) ||
3535 p.attachment.currentSession() != p.session || p.coordinator == nil {
3636 return false
3737 }
38- p.session.mu.Lock()
39- active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive
40- p.session.mu.Unlock()
41- return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
38+ registered := attachmentRegistered(p.session, p.attachment)
39+ return registered && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
4240 }
4341
4442 // emit performs observer work only after every resize-owner fence and
@@ -59,7 +57,7 @@ func (p resizeCommitPublication) emit() bool {
5957 // reservation with pane ownership. sendMu is acquired before resize fences, so
6058 // the canonical rule never acquires an attachment lock while a pane fence is
6159 // held. Observer callbacks run only after both lock families are released.
62-func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *roleEffectTicket, size domain.Size) bool {
60+func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *attachmentEffectTicket, size domain.Size) bool {
6361 if ac != nil && ticket == nil {
6462 return false
6563 }
@@ -87,7 +85,7 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *
8785 mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),
8886 }
8987 if ticket != nil {
90- publication.role = ticket.roleToken()
88+ publication.connection = ticket.connectionToken()
9189 publication.coordinator = sess.renderCoordinator()
9290 }
9391 if !publication.current() {
M internal/usecase/daemon/resume.go +6 -8
@@ -110,8 +110,7 @@ func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClie
110110 stillOwner := false
111111 if sess != nil {
112112 sess.mu.Lock()
113- role := sess.attachmentRoleLocked(ac)
114- stillOwner = role == attachmentActive || role == attachmentSnatched
113+ stillOwner = attachmentRegisteredLocked(sess, ac)
115114 sess.mu.Unlock()
116115 }
117116 if !stillOwner {
@@ -342,7 +341,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
342341 ac.resumeClaimToken = 0
343342 ac.resumeToken = token
344343 ac.parked = true
345- ac.roleGeneration.Add(1)
344+ ac.connectionGeneration.Add(1)
346345 sess := parked.sess
347346 if sess != nil {
348347 sess.mu.Lock()
@@ -369,7 +368,7 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
369368 parked.ac.clearPreviousSession()
370369 parked.ac.resumeToken = 0
371370 parked.ac.parked = false
372- parked.ac.roleGeneration.Add(1)
371+ parked.ac.connectionGeneration.Add(1)
373372 parked.ac.setSession(nil)
374373 return parkedAttachmentRetirement{
375374 parked: parked,
@@ -612,10 +611,9 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
612611 d.mu.Unlock()
613612 ac.sendMu.Unlock()
614613 transition, err := d.transitionAttachment(attachmentTransitionRequest{
615- target: sess,
616- next: ac,
617- expectedRole: attachmentDetached,
618- targetRole: attachmentActive,
614+ target: sess,
615+ next: ac,
616+
619617 expectedTransport: ac.transportSnapshot(),
620618 ready: false,
621619 })
M internal/usecase/daemon/resume_test.go +3 -3
@@ -883,7 +883,7 @@ func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) {
883883 token := ac.resumeToken
884884 require.NotZero(t, token)
885885
886- d.afterDetachRoleEffectsFrozen = func() {
886+ d.afterDetachAttachmentEffectsFrozen = func() {
887887 d.mu.Lock()
888888 d.closing = true
889889 d.mu.Unlock()
@@ -995,7 +995,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
995995 token := ac.resumeToken
996996 require.True(t, sess.detachIfCurrent(ac))
997997 require.True(t, d.parkAttachment(sess, ac))
998- generation := ac.roleGeneration.Load()
998+ generation := ac.connectionGeneration.Load()
999999
10001000 newTr := &closeTrackingTransport{}
10011001 resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})
@@ -1003,7 +1003,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
10031003 require.True(t, ok)
10041004 require.Same(t, sess, resumedSess)
10051005 require.Same(t, ac, resumedAC)
1006- require.Greater(t, ac.roleGeneration.Load(), generation, "resume must publish active ownership through the attachment transition")
1006+ require.Greater(t, ac.connectionGeneration.Load(), generation, "resume must publish active ownership through the attachment transition")
10071007
10081008 _ = ac.closeCapturedTransport(oldTr)
10091009 require.True(t, oldTr.Closed(), "old transport is closed")
M internal/usecase/daemon/session.go +65 -68
@@ -241,6 +241,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
241241 }
242242 }
243243
244+ if d.ptys == nil {
245+ return nil, errors.New("daemon: PTY factory is unavailable")
246+ }
244247 tbSize := contentSize(sz, proxied)
245248 var names []string
246249 if len(restoredTabNames) > 0 {
@@ -427,15 +430,14 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
427430 if err != nil {
428431 return err
429432 }
430- // The newly created target is registered before the role transition, but
433+ // The newly created target is registered before the attachment transition, but
431434 // remains headless. Freeze/drain must occur with no daemon/routing/session
432435 // lock held; the transition revalidates both lifecycles before publication.
433436 transition, err := d.transitionAttachment(attachmentTransitionRequest{
434- source: from,
435- target: newSess,
436- next: ac,
437- expectedRole: attachmentActive,
438- targetRole: attachmentActive,
437+ source: from,
438+ target: newSess,
439+ next: ac,
440+
439441 expectedTransport: ac.transportSnapshot(),
440442 ready: true,
441443 })
@@ -452,16 +454,16 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
452454 return nil
453455 }
454456
455-// createSessionAndSwitchForRole creates no target until the initiating role
457+// createSessionAndSwitchForAttachment creates no target until the initiating role
456458 // ticket has ended and the exact source capability is frozen and revalidated.
457-func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name string) error {
459+func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionToken, name string) error {
458460 if name == "" {
459461 return errSessionNameRequired
460462 }
461463 if err := domain.ValidateSessionName(name); err != nil {
462464 return err
463465 }
464- if token.sess == nil || token.ac == nil || token.effect == nil || token.role != attachmentActive {
466+ if token.sess == nil || token.ac == nil || token.effect == nil || false {
465467 return errAttachmentTransition
466468 }
467469 token.effect.bindActionEnd(d, "create-session")
@@ -470,7 +472,7 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
470472 var created *session
471473 transition, err := d.transitionAttachment(attachmentTransitionRequest{
472474 source: token.sess, next: token.ac,
473- expectedRole: attachmentActive, targetRole: attachmentActive,
475+
474476 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
475477 copySourceEnvironment: true, ready: true,
476478 createTargetLocked: func() (*session, error) {
@@ -708,9 +710,6 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
708710 go d.readPanePTY(p)
709711 }
710712
711-// attachClient makes ac the session's current client, displacing any prior one
712-// (which is notified with ReasonDetach and disconnected — its own conn handler
713-
714713 func (s *session) detachIfCurrent(ac *attachedClient) bool {
715714 s.mu.Lock()
716715 defer s.mu.Unlock()
@@ -722,7 +721,7 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
722721 return true
723722 }
724723
725-// detachIfCurrent publishes terminal role invalidation through the attachment
724+// detachIfCurrent publishes terminal attachment invalidation through the attachment
726725 // gate. Freeze/drain happens before routing or session locks are acquired.
727726 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
728727 return d.detachIfCurrentTransport(sess, ac, transportSnapshot{})
@@ -735,10 +734,10 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
735734 if sess == nil || ac == nil {
736735 return false
737736 }
738- frozen := freezeRoleEffectGates(ac)
737+ frozen := freezeAttachmentEffectGates(ac)
739738 defer frozen.unfreeze()
740- if d.afterDetachRoleEffectsFrozen != nil {
741- d.afterDetachRoleEffectsFrozen()
739+ if d.afterDetachAttachmentEffectsFrozen != nil {
740+ d.afterDetachAttachmentEffectsFrozen()
742741 }
743742 d.notices.routingMu.Lock()
744743 sess.mu.Lock()
@@ -748,9 +747,9 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
748747 }
749748 if current {
750749 sess.unregisterAttachmentLocked(ac)
751- ac.roleGeneration.Add(1)
750+ ac.connectionGeneration.Add(1)
752751 ac.setSession(nil)
753- ac.invalidateFrozenRoleCapability()
752+ ac.invalidateFrozenAttachmentCapability()
754753 }
755754 sess.mu.Unlock()
756755 d.notices.routingMu.Unlock()
@@ -765,10 +764,10 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
765764 if p == nil || ac == nil {
766765 return false
767766 }
768- frozen := freezeRoleEffectGates(ac)
767+ frozen := freezeAttachmentEffectGates(ac)
769768 defer frozen.unfreeze()
770- if d.afterDetachRoleEffectsFrozen != nil {
771- d.afterDetachRoleEffectsFrozen()
769+ if d.afterDetachAttachmentEffectsFrozen != nil {
770+ d.afterDetachAttachmentEffectsFrozen()
772771 }
773772 d.notices.routingMu.Lock()
774773 p.sessionCore.mu.Lock()
@@ -778,9 +777,9 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
778777 }
779778 if current {
780779 unregisterAttachmentSessionLocked(attachmentSession(p), ac)
781- ac.roleGeneration.Add(1)
780+ ac.connectionGeneration.Add(1)
782781 ac.setSession(nil)
783- ac.invalidateFrozenRoleCapability()
782+ ac.invalidateFrozenAttachmentCapability()
784783 }
785784 p.sessionCore.mu.Unlock()
786785 d.notices.routingMu.Unlock()
@@ -790,15 +789,15 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
790789 return current
791790 }
792791
793-// detachIfRoleCurrent invalidates only the exact role generation and transport
794-// whose admitted send failed. A queued stale error cannot detach a later role
795-// publication or a rebound transport on the same attachment object.
796-func (d *Daemon) detachIfRoleCurrent(token attachmentRoleToken) bool {
797- return d.detachIfRoleCurrentUntil(token, nil)
792+// detachIfAttachmentCurrent invalidates only the exact attachment generation and transport
793+// whose admitted send failed. A queued stale error cannot detach a later
794+// attachment publication or a rebound transport on the same attachment object.
795+func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool {
796+ return d.detachIfAttachmentCurrentUntil(token, nil)
798797 }
799798
800-func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() <-chan struct{}) bool {
801- if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {
799+func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {
800+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
802801 return false
803802 }
804803 // Attachments without the session's optional primary render lease still
@@ -813,13 +812,13 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
813812 }
814813 return false
815814 }
816- frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{done: done}, token.ac)
815+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
817816 defer frozen.unfreeze()
818817 if !frozen.acquired || !frozen.drained {
819818 return false
820819 }
821- if d.afterDetachRoleEffectsFrozen != nil {
822- d.afterDetachRoleEffectsFrozen()
820+ if d.afterDetachAttachmentEffectsFrozen != nil {
821+ d.afterDetachAttachmentEffectsFrozen()
823822 }
824823 d.mu.Lock()
825824 core := token.sess.core()
@@ -834,15 +833,14 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
834833 coordinator.mu.Lock()
835834 }
836835 req := attachmentTransitionRequest{
837- source: token.sess, next: token.ac, expectedRole: token.role,
838- expectedTransport: token.transport,
836+ source: token.sess, next: token.ac, expectedTransport: token.transport,
839837 }
840838 current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
841839 if current {
842840 unregisterAttachmentSessionLocked(token.sess, token.ac)
843- token.ac.roleGeneration.Add(1)
841+ token.ac.connectionGeneration.Add(1)
844842 token.ac.setSession(nil)
845- token.ac.invalidateFrozenRoleCapability()
843+ token.ac.invalidateFrozenAttachmentCapability()
846844 }
847845 if coordinator != nil {
848846 coordinator.mu.Unlock()
@@ -1132,33 +1130,33 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
11321130 }
11331131
11341132 type sessionKillAdmission struct {
1135- token attachmentRoleToken
1133+ token attachmentConnectionToken
11361134 action string
11371135 }
11381136
11391137 type sessionKillParticipants struct {
11401138 attachments map[*attachedClient]struct{}
11411139 transports map[*attachedClient]transportSnapshot
1142- roleGates []*attachedClient
1143- interrupts []roleTransportInterrupt
1140+ effectGates []*attachedClient
1141+ interrupts []attachmentTransportInterrupt
11441142 source *session
1145- sourceToken *attachmentRoleToken
1143+ sourceToken *attachmentConnectionToken
11461144 target *session
11471145 }
11481146
1149-func (d *Daemon) killSessionForRole(sess *session, reason uint8, purge bool, token attachmentRoleToken, action string) error {
1147+func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge bool, token attachmentConnectionToken, action string) error {
11501148 return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action})
11511149 }
11521150
1153-// snapshotSessionKillParticipants captures role membership under the canonical
1154-// architecture locks, but releases every lock before any role gate is frozen or
1151+// snapshotSessionKillParticipants captures attachment membership under the canonical
1152+// architecture locks, but releases every lock before any attachment gate is frozen or
11551153 // drained. The initiator is part of the same deduplicated global gate set even
11561154 // when it belongs to a different source session.
11571155 func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) {
11581156 snapshot := sessionKillParticipants{target: target}
11591157 if admission != nil {
11601158 token := admission.token
1161- if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {
1159+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
11621160 return sessionKillParticipants{}, false
11631161 }
11641162 var ok bool
@@ -1200,7 +1198,7 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
12001198 return
12011199 }
12021200 seen[ac] = struct{}{}
1203- snapshot.roleGates = append(snapshot.roleGates, ac)
1201+ snapshot.effectGates = append(snapshot.effectGates, ac)
12041202 }
12051203 if admission != nil {
12061204 add(admission.token.ac)
@@ -1210,14 +1208,14 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
12101208 }
12111209 for ac, transport := range snapshot.transports {
12121210 if transport.transport != nil {
1213- snapshot.interrupts = append(snapshot.interrupts, roleTransportInterrupt{ac: ac, transport: transport})
1211+ snapshot.interrupts = append(snapshot.interrupts, attachmentTransportInterrupt{ac: ac, transport: transport})
12141212 }
12151213 }
12161214 return snapshot, true
12171215 }
12181216
12191217 // sessionKillParticipantsCurrent repeats lifecycle, source-token, and exact
1220-// target-membership validation while every discovered role gate is frozen.
1218+// target-membership validation while every discovered attachment gate is frozen.
12211219 func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool {
12221220 d.mu.Lock()
12231221 defer d.mu.Unlock()
@@ -1263,8 +1261,7 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
12631261 }
12641262 token := *snapshot.sourceToken
12651263 req := attachmentTransitionRequest{
1266- source: token.sess, next: token.ac, expectedRole: token.role,
1267- expectedTransport: token.transport,
1264+ source: token.sess, next: token.ac, expectedTransport: token.transport,
12681265 }
12691266 return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
12701267 }
@@ -1374,7 +1371,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
13741371 if sess == nil {
13751372 return nil
13761373 }
1377- // Discover every role whose membership can be invalidated before freezing
1374+ // Discover every attachment whose membership can be invalidated before freezing
13781375 // any one of them. The common gate helper deduplicates and freezes the whole
13791376 // immutable identity set in the same global order used by transitions.
13801377 participants, ok := d.snapshotSessionKillParticipants(sess, admission)
@@ -1382,29 +1379,29 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
13821379 return nil
13831380 }
13841381 if admission != nil {
1385- d.endActionRoleEffect(admission.token.effect, admission.action)
1382+ d.endActionAttachmentEffect(admission.token.effect, admission.action)
13861383 }
13871384 action := ""
13881385 if admission != nil {
13891386 action = admission.action
13901387 }
1391- if d.afterRoleEffectParticipantsSnapshotted != nil {
1392- d.afterRoleEffectParticipantsSnapshotted(action, participants.roleGates)
1388+ if d.afterAttachmentEffectParticipantsSnapshotted != nil {
1389+ d.afterAttachmentEffectParticipantsSnapshotted(action, participants.effectGates)
13931390 }
13941391 var drainDone func() <-chan struct{}
1395- var drainDeadline *roleEffectDrainDeadline
1392+ var drainDeadline *attachmentEffectDrainDeadline
13961393 if deadline != nil {
13971394 drainDone = deadline.Done
13981395 } else {
1399- drainDeadline = newRoleEffectDrainDeadline(d.clock)
1396+ drainDeadline = newAttachmentEffectDrainDeadline(d.clock)
14001397 drainDone = drainDeadline.Done
14011398 defer drainDeadline.stop()
14021399 }
1403- frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
1404- if d.afterRoleEffectGateFrozen != nil {
1405- d.afterRoleEffectGateFrozen(action, ac)
1400+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
1401+ if d.afterAttachmentEffectGateFrozen != nil {
1402+ d.afterAttachmentEffectGateFrozen(action, ac)
14061403 }
1407- }}, participants.roleGates...)
1404+ }}, participants.effectGates...)
14081405 defer func() { frozen.unfreeze() }()
14091406 if !frozen.acquired {
14101407 // The acquisition helper has already rolled back exactly the gates it
@@ -1508,8 +1505,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
15081505 sess.snapshotMu.Unlock()
15091506 }
15101507 }
1511- // Revalidate the exact source capability, target lifecycle, and active plus
1512- // snatched memberships under canonical architecture locks immediately before
1508+ // Revalidate the exact source capability, target lifecycle, and attachment
1509+ // memberships under canonical architecture locks immediately before
15131510 // publication. A changed preflight aborts without mutating either lifecycle.
15141511 d.mu.Lock()
15151512 if d.sessions[sess.id] != sess || participants.source != nil && d.sessions[participants.source.id] != participants.source {
@@ -1534,7 +1531,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
15341531 }
15351532
15361533 // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held
1537- // here. Registry removal and every role invalidation are one publication
1534+ // here. Registry removal and every attachment invalidation are one publication
15381535 // while the complete immutable gate set remains frozen.
15391536 if !d.unregisterSessionLocked(sess) {
15401537 unlockCoordinators()
@@ -1546,15 +1543,15 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
15461543 d.clearBarScriptsForSession(sess.id)
15471544 d.purgeParkingForSessionLocked(sess)
15481545 parkedRetirements := d.purgeParkedForSessionLocked(sess)
1549- attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))
1546+ attachments := make([]detachedAttachmentSnapshot, 0, len(participants.effectGates))
15501547 capture := func(attached *attachedClient) {
15511548 if attached == nil {
15521549 return
15531550 }
15541551 attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})
1555- attached.roleGeneration.Add(1)
1552+ attached.connectionGeneration.Add(1)
15561553 attached.setSession(nil)
1557- attached.invalidateFrozenRoleCapability()
1554+ attached.invalidateFrozenAttachmentCapability()
15581555 }
15591556 for attached := range sess.attachments {
15601557 capture(attached)
@@ -1582,7 +1579,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
15821579 d.mu.Unlock()
15831580 d.finishParkedAttachmentRetirements(parkedRetirements)
15841581 frozen.unfreeze()
1585- frozen = frozenRoleEffectGates{}
1582+ frozen = frozenAttachmentEffectGates{}
15861583 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
15871584
15881585 var purgeErr error
M internal/usecase/daemon/session_back.go +4 -4
@@ -7,7 +7,7 @@ import (
77
88 // backSession toggles this attachment between its current session and the
99 // immediately preceding successfully activated session.
10-func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {
10+func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
1111 if d == nil || token.sess == nil || token.ac == nil {
1212 return nil
1313 }
@@ -21,7 +21,7 @@ func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {
2121 key := proxy.key
2222 pickerTarget.RemoteKey = &key
2323 }
24- return d.switchToTargetForRole(token, pickerTarget, sessionHandoffGuard{}, "back-session")
24+ return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session")
2525 }
2626
2727 func (d *Daemon) backSession(current *session, ac *attachedClient) {
@@ -35,14 +35,14 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
3535 return
3636 }
3737 token := attachmentToken(current, ac, ac.transport())
38- effect, admitted := ac.beginRoleEffect(token)
38+ effect, admitted := ac.beginAttachmentEffect(token)
3939 if !admitted {
4040 d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition))
4141 return
4242 }
4343 token.effect = effect
4444 defer effect.End()
45- if err := d.backSessionForRole(token); err != nil {
45+ if err := d.backSessionForAttachment(token); err != nil {
4646 d.reportError(current, err)
4747 }
4848 }
M internal/usecase/daemon/session_core.go +3 -3
@@ -28,9 +28,9 @@ type sessionCore struct {
2828 name string
2929 ephemeral bool
3030 caps sessionCapabilities
31- // attachments is the session-owned membership registry. Role transition
32- // metadata below is separate from membership; admission and view code use
33- // this collection as their membership source.
31+ // attachments is the session-owned membership registry. Connection
32+ // generation and effect state remain attachment-local; admission and view
33+ // code use this collection as their membership source.
3434 attachments map[*attachedClient]struct{}
3535 attachmentOrder map[*attachedClient]uint64
3636 nextAttachmentID uint64
M internal/usecase/daemon/session_core_test.go +2 -2
@@ -37,7 +37,7 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
3737 core.mu.Lock()
3838 require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu")
3939 core.registerAttachmentLocked(ac)
40- require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac))
40+ require.Equal(t, true, sess.attachmentRegisteredLocked(ac))
4141 sess.tabs = append(sess.tabs, &tab{name: "logs"})
4242 require.Len(t, sess.tabs, 2)
4343 core.mu.Unlock()
@@ -83,7 +83,7 @@ func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.
8383 t.Fatal("snapshotView completed while sessionCore.mu guarded local session state")
8484 case <-timer.C:
8585 }
86- require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac))
86+ require.Equal(t, true, sess.attachmentRegisteredLocked(ac))
8787 sess.core().mu.Unlock()
8888
8989 view := awaitTestValue(t, snapshotted, "snapshotView did not complete after sessionCore.mu was released")
M internal/usecase/daemon/session_meta.go +2 -2
@@ -78,7 +78,7 @@ func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta {
7878 // caller holds ac.sendMu, which serializes this snapshot shadow with every
7979 // output write. The shadow is committed only after the transport accepts the
8080 // metadata frame.
81-func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected transportSnapshot, ticket *roleEffectTicket) error {
81+func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected transportSnapshot, ticket *attachmentEffectTicket) error {
8282 if ac == nil || sess == nil || !ac.proxied {
8383 return nil
8484 }
@@ -91,7 +91,7 @@ func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected trans
9191
9292 // sendSessionMetaSnapshot sends an already captured metadata snapshot while
9393 // the caller holds ac.sendMu.
94-func (ac *attachedClient) sendSessionMetaSnapshot(meta ports.SessionMeta, expected transportSnapshot, ticket *roleEffectTicket) error {
94+func (ac *attachedClient) sendSessionMetaSnapshot(meta ports.SessionMeta, expected transportSnapshot, ticket *attachmentEffectTicket) error {
9595 if ac == nil || !ac.proxied {
9696 return nil
9797 }
M internal/usecase/daemon/session_meta_test.go +1 -1
@@ -197,7 +197,7 @@ func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) {
197197 require.NoError(t, err)
198198
199199 token := sess.attachmentToken(ac, ac.transport())
200- require.False(t, d.handleActiveClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
200+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
201201
202202 reply := awaitFrame(t, sends, ports.MsgCommandResult)
203203 result, err := ports.UnmarshalCommandResult(reply.Payload)
M internal/usecase/daemon/status_test.go +5 -5
@@ -541,7 +541,7 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) {
541541 }
542542 tr, _ := newCapturingTransport(t)
543543
544- ac, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
544+ ac, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
545545 require.NoError(t, err)
546546
547547 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
@@ -581,7 +581,7 @@ func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) {
581581 assertSessionColorScheme(t, sess, true)
582582
583583 tr, _ := newCapturingTransport(t)
584- _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
584+ _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
585585 require.NoError(t, err)
586586
587587 assertSessionColorSchemeUnknown(t, sess)
@@ -690,7 +690,7 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
690690 })
691691 tr, _ := newCapturingTransport(t)
692692
693- _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
693+ _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
694694 require.NoError(t, err)
695695
696696 var got []byte
@@ -740,7 +740,7 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
740740 require.True(t, sess.detachIfCurrent(ac))
741741
742742 tr, _ := newCapturingTransport(t)
743- newAC, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
743+ newAC, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
744744 require.NoError(t, err)
745745 d.applyTheme(sess, newAC, ports.Theme{
746746 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
@@ -800,7 +800,7 @@ func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
800800 d, sess, old, _ := newManualSessionWithPTYs(t, p)
801801 defer release()
802802 tr, _ := newCapturingTransport(t)
803- _, _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
803+ _, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
804804 require.NoError(t, err)
805805
806806 d.applyTheme(sess, old, ports.Theme{

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