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 (
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9 )9 )
10 10
11-type roleEffectDrainDeadline struct {11+type attachmentEffectDrainDeadline struct {
12 clock ports.Clock12 clock ports.Clock
13 done chan struct{}13 done chan struct{}
14 stopCh chan struct{}14 stopCh chan struct{}
@@ -18,13 +18,13 @@ type roleEffectDrainDeadline struct {
18 started atomic.Bool18 started atomic.Bool
19 }19 }
20 20
21-func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline {21+func newAttachmentEffectDrainDeadline(clock ports.Clock) *attachmentEffectDrainDeadline {
22- return &roleEffectDrainDeadline{22+ return &attachmentEffectDrainDeadline{
23 clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}),23 clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}),
24 }24 }
25 }25 }
26 26
27-func (d *roleEffectDrainDeadline) stop() {27+func (d *attachmentEffectDrainDeadline) stop() {
28 if d == nil {28 if d == nil {
29 return29 return
30 }30 }
@@ -40,7 +40,7 @@ func (d *roleEffectDrainDeadline) stop() {
40 })40 })
41 }41 }
42 42
43-func (d *roleEffectDrainDeadline) Done() <-chan struct{} {43+func (d *attachmentEffectDrainDeadline) Done() <-chan struct{} {
44 if d == nil {44 if d == nil {
45 return nil45 return nil
46 }46 }
@@ -60,71 +60,62 @@ func (d *roleEffectDrainDeadline) Done() <-chan struct{} {
60 return d.done60 return d.done
61 }61 }
62 62
63-type frozenRoleEffectGates struct {63+type frozenAttachmentEffectGates struct {
64 clients []*attachedClient64 clients []*attachedClient
65 interruptions map[*attachedClient]transportSnapshot65 interruptions map[*attachedClient]transportSnapshot
66 acquired bool66 acquired bool
67 drained bool67 drained bool
68 }68 }
69 69
70-func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {70+func (f frozenAttachmentEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool {
71 interrupted, ok := f.interruptions[ac]71 interrupted, ok := f.interruptions[ac]
72 return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation72 return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation
73 }73 }
74 74
75-func (f frozenRoleEffectGates) contains(target *attachedClient) bool {75+type attachmentTransportInterrupt struct {
76- for _, ac := range f.clients {
77- if ac == target {
78- return true
79- }
80- }
81- return false
82-}
83-
84-type roleTransportInterrupt struct {
85 ac *attachedClient76 ac *attachedClient
86 transport transportSnapshot77 transport transportSnapshot
87 }78 }
88 79
89-// roleEffectFreezeOptions controls one role-gate freeze operation. A freeze80+// attachmentEffectFreezeOptions controls one role-gate freeze operation. A freeze
90 // must be called without daemon, routing, session, coordinator, send, overlay,81 // must be called without daemon, routing, session, coordinator, send, overlay,
91 // router, or pane locks held.82 // router, or pane locks held.
92-type roleEffectFreezeOptions struct {83+type attachmentEffectFreezeOptions struct {
93- interrupts []roleTransportInterrupt84+ interrupts []attachmentTransportInterrupt
94 done func() <-chan struct{}85 done func() <-chan struct{}
95 afterFrozen func(*attachedClient)86 afterFrozen func(*attachedClient)
96 nonblocking bool87 nonblocking bool
97 }88 }
98 89
99-// freezeRoleEffectGates establishes transition priority in immutable attachment90+// freezeAttachmentEffectGates establishes transition priority in immutable attachment
100 // order, then drains earlier admissions.91 // order, then drains earlier admissions.
101-func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates {92+func freezeAttachmentEffectGates(clients ...*attachedClient) frozenAttachmentEffectGates {
102- return freezeRoleEffectGatesWith(roleEffectFreezeOptions{}, clients...)93+ return freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{}, clients...)
103 }94 }
104 95
105-// freezeRoleEffectGatesWith uses done as one overall bound for ordered96+// freezeAttachmentEffectGatesWith uses done as one overall bound for ordered
106 // acquisition and drain. Acquisition is all-or-nothing: if the bound expires97 // acquisition and drain. Acquisition is all-or-nothing: if the bound expires
107 // behind another owner, only gates acquired by this call are rolled back, in98 // behind another owner, only gates acquired by this call are rolled back, in
108 // reverse order. nonblocking is used by move reservations to avoid inverting99 // reverse order. nonblocking is used by move reservations to avoid inverting
109 // the move/teardown protocols.100 // the move/teardown protocols.
110-func freezeRoleEffectGatesWith(options roleEffectFreezeOptions, clients ...*attachedClient) frozenRoleEffectGates {101+func freezeAttachmentEffectGatesWith(options attachmentEffectFreezeOptions, clients ...*attachedClient) frozenAttachmentEffectGates {
111- ordered := orderedRoleEffectClients(clients)102+ ordered := orderedAttachmentEffectClients(clients)
112- frozen, acquired := acquireRoleEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)103+ frozen, acquired := acquireAttachmentEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)
113 if !acquired {104 if !acquired {
114- return frozenRoleEffectGates{}105+ return frozenAttachmentEffectGates{}
115 }106 }
116 107
117- interruptByClient := roleTransportInterruptsByClient(options.interrupts)108+ interruptByClient := attachmentTransportInterruptsByClient(options.interrupts)
118 frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient))109 frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient))
119- interruptFrozenRoleTransports(ordered, interruptByClient)110+ interruptFrozenAttachmentTransports(ordered, interruptByClient)
120 frozen.acquired = true111 frozen.acquired = true
121- frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, options.done)112+ frozen.drained = drainFrozenAttachmentEffects(ordered, interruptByClient, frozen.interruptions, options.done)
122 return frozen113 return frozen
123 }114 }
124 115
125-// orderedRoleEffectClients snapshots unique participants in immutable lock116+// orderedAttachmentEffectClients snapshots unique participants in immutable lock
126 // order. Assigning a previously unused order does not freeze or wait on a gate.117 // 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 {
128 unique := make(map[*attachedClient]struct{}, len(clients))119 unique := make(map[*attachedClient]struct{}, len(clients))
129 ordered := make([]*attachedClient, 0, len(clients))120 ordered := make([]*attachedClient, 0, len(clients))
130 for _, ac := range clients {121 for _, ac := range clients {
@@ -135,18 +126,18 @@ func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient {
135 continue126 continue
136 }127 }
137 unique[ac] = struct{}{}128 unique[ac] = struct{}{}
138- ac.roleEffects.immutableOrder()129+ ac.attachmentEffects.immutableOrder()
139 ordered = append(ordered, ac)130 ordered = append(ordered, ac)
140 }131 }
141 sort.Slice(ordered, func(i, j int) bool {132 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()
143 })134 })
144 return ordered135 return ordered
145 }136 }
146 137
147-// waitForRoleEffectChangeLocked requires g.mu and returns with g.mu held. A138+// waitForAttachmentEffectChangeLocked requires g.mu and returns with g.mu held. A
148 // true result means the overall deadline won and the caller must stop waiting.139 // 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 {
150 if done == nil {141 if done == nil {
151 g.cond.Wait()142 g.cond.Wait()
152 return false143 return false
@@ -167,28 +158,28 @@ func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{
167 }158 }
168 }159 }
169 160
170-// acquireRoleEffectGates establishes exclusive transition ownership in order.161+// acquireAttachmentEffectGates establishes exclusive transition ownership in order.
171 // It must run without architecture locks. Deadline rollback releases only this162 // It must run without architecture locks. Deadline rollback releases only this
172 // call's partial acquisition and does so in reverse order.163 // 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) {164+func acquireAttachmentEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (frozenAttachmentEffectGates, bool) {
174- frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))}165+ frozen := frozenAttachmentEffectGates{clients: make([]*attachedClient, 0, len(ordered))}
175 for _, ac := range ordered {166 for _, ac := range ordered {
176- g := &ac.roleEffects167+ g := &ac.attachmentEffects
177 g.mu.Lock()168 g.mu.Lock()
178 g.initLocked()169 g.initLocked()
179- for g.phase == roleEffectsFrozen {170+ for g.phase == attachmentEffectsFrozen {
180 if nonblocking {171 if nonblocking {
181 g.mu.Unlock()172 g.mu.Unlock()
182 frozen.unfreeze()173 frozen.unfreeze()
183- return frozenRoleEffectGates{}, false174+ return frozenAttachmentEffectGates{}, false
184 }175 }
185- if waitForRoleEffectChangeLocked(g, done) {176+ if waitForAttachmentEffectChangeLocked(g, done) {
186 g.mu.Unlock()177 g.mu.Unlock()
187 frozen.unfreeze()178 frozen.unfreeze()
188- return frozenRoleEffectGates{}, false179+ return frozenAttachmentEffectGates{}, false
189 }180 }
190 }181 }
191- g.phase = roleEffectsFrozen182+ g.phase = attachmentEffectsFrozen
192 g.mu.Unlock()183 g.mu.Unlock()
193 frozen.clients = append(frozen.clients, ac)184 frozen.clients = append(frozen.clients, ac)
194 if afterFrozen != nil {185 if afterFrozen != nil {
@@ -198,7 +189,7 @@ func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct
198 return frozen, true189 return frozen, true
199 }190 }
200 191
201-func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*attachedClient]transportSnapshot {192+func attachmentTransportInterruptsByClient(interrupts []attachmentTransportInterrupt) map[*attachedClient]transportSnapshot {
202 byClient := make(map[*attachedClient]transportSnapshot, len(interrupts))193 byClient := make(map[*attachedClient]transportSnapshot, len(interrupts))
203 for _, interrupt := range interrupts {194 for _, interrupt := range interrupts {
204 if interrupt.ac != nil && interrupt.transport.transport != nil {195 if interrupt.ac != nil && interrupt.transport.transport != nil {
@@ -208,20 +199,20 @@ func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*a
208 return byClient199 return byClient
209 }200 }
210 201
211-// interruptFrozenRoleTransports snapshots every exact in-flight link while all202+// interruptFrozenAttachmentTransports snapshots every exact in-flight link while all
212 // gates are frozen, then closes the complete set before any drain wait. One203 // gates are frozen, then closes the complete set before any drain wait. One
213 // uncooperative Send cannot prevent another participant from being interrupted.204 // uncooperative Send cannot prevent another participant from being interrupted.
214-func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) {205+func interruptFrozenAttachmentTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) {
215- toClose := make([]roleTransportInterrupt, 0, len(interruptByClient))206+ toClose := make([]attachmentTransportInterrupt, 0, len(interruptByClient))
216 for _, ac := range ordered {207 for _, ac := range ordered {
217 expected, mayInterrupt := interruptByClient[ac]208 expected, mayInterrupt := interruptByClient[ac]
218 if !mayInterrupt {209 if !mayInterrupt {
219 continue210 continue
220 }211 }
221- g := &ac.roleEffects212+ g := &ac.attachmentEffects
222 g.mu.Lock()213 g.mu.Lock()
223 if gateHasTransportEffectLocked(g, expected) {214 if gateHasTransportEffectLocked(g, expected) {
224- toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected})215+ toClose = append(toClose, attachmentTransportInterrupt{ac: ac, transport: expected})
225 }216 }
226 g.mu.Unlock()217 g.mu.Unlock()
227 }218 }
@@ -230,15 +221,15 @@ func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient
230 }221 }
231 }222 }
232 223
233-// drainFrozenRoleEffects waits for admissions made before the freeze. A closed224+// drainFrozenAttachmentEffects waits for admissions made before the freeze. A closed
234 // deadline permits terminal teardown to continue with the capability frozen;225 // deadline permits terminal teardown to continue with the capability frozen;
235 // late tickets can then retire without pinning the owner indefinitely.226 // 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 {
237 for _, ac := range ordered {228 for _, ac := range ordered {
238- g := &ac.roleEffects229+ g := &ac.attachmentEffects
239 g.mu.Lock()230 g.mu.Lock()
240 for g.inFlight != 0 {231 for g.inFlight != 0 {
241- if waitForRoleEffectChangeLocked(g, done) {232+ if waitForAttachmentEffectChangeLocked(g, done) {
242 g.mu.Unlock()233 g.mu.Unlock()
243 return false234 return false
244 }235 }
@@ -252,7 +243,7 @@ func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interr
252 return true243 return true
253 }244 }
254 245
255-func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool {246+func gateHasTransportEffectLocked(g *attachmentEffectGate, expected transportSnapshot) bool {
256 for _, active := range g.transportEffects {247 for _, active := range g.transportEffects {
257 if active.transport == expected.transport && active.incarnation == expected.incarnation {248 if active.transport == expected.transport && active.incarnation == expected.incarnation {
258 return true249 return true
@@ -261,12 +252,12 @@ func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot)
261 return false252 return false
262 }253 }
263 254
264-func (f frozenRoleEffectGates) unfreeze() {255+func (f frozenAttachmentEffectGates) unfreeze() {
265 for i := len(f.clients) - 1; i >= 0; i-- {256 for i := len(f.clients) - 1; i >= 0; i-- {
266- g := &f.clients[i].roleEffects257+ g := &f.clients[i].attachmentEffects
267 g.mu.Lock()258 g.mu.Lock()
268 g.initLocked()259 g.initLocked()
269- g.phase = roleEffectsStable260+ g.phase = attachmentEffectsStable
270 g.signalLocked()261 g.signalLocked()
271 g.mu.Unlock()262 g.mu.Unlock()
272 }263 }
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 (
7 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
8 )8 )
9 9
10-type roleEffectPhase uint810+type attachmentEffectPhase uint8
11 11
12 const (12 const (
13- roleEffectsStable roleEffectPhase = iota13+ attachmentEffectsStable attachmentEffectPhase = iota
14- roleEffectsFrozen14+ attachmentEffectsFrozen
15 )15 )
16 16
17-// roleCapability is the single attachment-local publication consumed by role17+// attachmentCapability is the single attachment-local publication consumed by
18 // effect admission. It deliberately contains no independently mutable state:18 // effect admission. It deliberately contains no independently mutable state:
19 // a ticket is admitted only when every field matches the frame's token.19 // a ticket is admitted only when every field matches the frame's token.
20-type roleCapability struct {20+type attachmentCapability struct {
21 sess attachmentSession21 sess attachmentSession
22- role attachmentRole
23 generation uint6422 generation uint64
24 transport transportSnapshot23 transport transportSnapshot
25 lease *attachmentLease24 lease *attachmentLease
26 }25 }
27 26
28-func capabilityFromToken(token attachmentRoleToken) roleCapability {27+func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
29- return roleCapability{28+ return attachmentCapability{
30 sess: token.sess,29 sess: token.sess,
31- role: token.role,
32 generation: token.generation,30 generation: token.generation,
33 transport: token.transport,31 transport: token.transport,
34 lease: token.lease,32 lease: token.lease,
35 }33 }
36 }34 }
37 35
38-func (c roleCapability) matches(token attachmentRoleToken) bool {36+func (c attachmentCapability) matches(token attachmentConnectionToken) bool {
39- return token.ac != nil && c.sess == token.sess && c.role == token.role &&37+ return token.ac != nil && c.sess == token.sess &&
40 c.generation == token.generation && c.transport.transport == token.transport.transport &&38 c.generation == token.generation && c.transport.transport == token.transport.transport &&
41 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease39 c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
42 }40 }
43 41
44-type roleEffectGate struct {42+type attachmentEffectGate struct {
45 mu sync.Mutex43 mu sync.Mutex
46 cond *sync.Cond44 cond *sync.Cond
47 changed chan struct{}45 changed chan struct{}
48- phase roleEffectPhase46+ phase attachmentEffectPhase
49- capability roleCapability47+ capability attachmentCapability
50 inFlight uint6448 inFlight uint64
51- transportEffects map[*roleEffectTicket]transportSnapshot49+ transportEffects map[*attachmentEffectTicket]transportSnapshot
52 failedTransport transportSnapshot50 failedTransport transportSnapshot
53 order atomic.Uint6451 order atomic.Uint64
54 }52 }
55 53
56-var nextRoleEffectGateOrder atomic.Uint6454+var nextAttachmentEffectGateOrder atomic.Uint64
57 55
58-func (g *roleEffectGate) initLocked() {56+func (g *attachmentEffectGate) initLocked() {
59 if g.cond == nil {57 if g.cond == nil {
60 g.cond = sync.NewCond(&g.mu)58 g.cond = sync.NewCond(&g.mu)
61 }59 }
@@ -64,25 +62,25 @@ func (g *roleEffectGate) initLocked() {
64 }62 }
65 }63 }
66 64
67-func (g *roleEffectGate) signalLocked() {65+func (g *attachmentEffectGate) signalLocked() {
68 g.initLocked()66 g.initLocked()
69 close(g.changed)67 close(g.changed)
70 g.changed = make(chan struct{})68 g.changed = make(chan struct{})
71 g.cond.Broadcast()69 g.cond.Broadcast()
72 }70 }
73 71
74-func (g *roleEffectGate) immutableOrder() uint64 {72+func (g *attachmentEffectGate) immutableOrder() uint64 {
75 if order := g.order.Load(); order != 0 {73 if order := g.order.Load(); order != 0 {
76 return order74 return order
77 }75 }
78- order := nextRoleEffectGateOrder.Add(1)76+ order := nextAttachmentEffectGateOrder.Add(1)
79 if g.order.CompareAndSwap(0, order) {77 if g.order.CompareAndSwap(0, order) {
80 return order78 return order
81 }79 }
82 return g.order.Load()80 return g.order.Load()
83 }81 }
84 82
85-func (g *roleEffectGate) inFlightCount() uint64 {83+func (g *attachmentEffectGate) inFlightCount() uint64 {
86 if g == nil {84 if g == nil {
87 return 085 return 0
88 }86 }
@@ -91,17 +89,17 @@ func (g *roleEffectGate) inFlightCount() uint64 {
91 return g.inFlight89 return g.inFlight
92 }90 }
93 91
94-// roleEffectTicket reserves the exact published capability until End. End is92+// attachmentEffectTicket reserves the exact published capability until End. End is
95 // idempotent so error paths can safely defer it before doing any observable I/O.93 // idempotent so error paths can safely defer it before doing any observable I/O.
96-type roleEffectTicket struct {94+type attachmentEffectTicket struct {
97- gate *roleEffectGate95+ gate *attachmentEffectGate
98- token attachmentRoleToken96+ token attachmentConnectionToken
99 actionDaemon *Daemon97 actionDaemon *Daemon
100 action string98 action string
101 ended atomic.Bool99 ended atomic.Bool
102 }100 }
103 101
104-func (t *roleEffectTicket) bindActionEnd(d *Daemon, action string) {102+func (t *attachmentEffectTicket) bindActionEnd(d *Daemon, action string) {
105 if t == nil || t.ended.Load() {103 if t == nil || t.ended.Load() {
106 return104 return
107 }105 }
@@ -109,16 +107,16 @@ func (t *roleEffectTicket) bindActionEnd(d *Daemon, action string) {
109 t.action = action107 t.action = action
110 }108 }
111 109
112-func (t *roleEffectTicket) roleToken() attachmentRoleToken {110+func (t *attachmentEffectTicket) connectionToken() attachmentConnectionToken {
113 if t == nil {111 if t == nil {
114- return attachmentRoleToken{}112+ return attachmentConnectionToken{}
115 }113 }
116 token := t.token114 token := t.token
117 token.effect = t115 token.effect = t
118 return token116 return token
119 }117 }
120 118
121-func (t *roleEffectTicket) End() {119+func (t *attachmentEffectTicket) End() {
122 if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) {120 if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) {
123 return121 return
124 }122 }
@@ -127,18 +125,18 @@ func (t *roleEffectTicket) End() {
127 g.initLocked()125 g.initLocked()
128 if g.inFlight == 0 {126 if g.inFlight == 0 {
129 g.mu.Unlock()127 g.mu.Unlock()
130- panic("daemon: role effect ticket ended without admission")128+ panic("daemon: attachment effect ticket ended without admission")
131 }129 }
132 delete(g.transportEffects, t)130 delete(g.transportEffects, t)
133 g.inFlight--131 g.inFlight--
134 g.signalLocked()132 g.signalLocked()
135 g.mu.Unlock()133 g.mu.Unlock()
136- if t.actionDaemon != nil && t.actionDaemon.afterActionRoleEffectEnded != nil {134+ if t.actionDaemon != nil && t.actionDaemon.afterActionAttachmentEffectEnded != nil {
137- t.actionDaemon.afterActionRoleEffectEnded(t.action)135+ t.actionDaemon.afterActionAttachmentEffectEnded(t.action)
138 }136 }
139 }137 }
140 138
141-func (d *Daemon) endActionRoleEffect(effect *roleEffectTicket, _ string) {139+func (d *Daemon) endActionAttachmentEffect(effect *attachmentEffectTicket, _ string) {
142 if effect != nil {140 if effect != nil {
143 effect.End()141 effect.End()
144 }142 }
@@ -146,8 +144,8 @@ func (d *Daemon) endActionRoleEffect(effect *roleEffectTicket, _ string) {
146 144
147 // beginTransportSend marks only the interval spent in an external transport145 // beginTransportSend marks only the interval spent in an external transport
148 // send as interruptible. A replacement may close this exact published link to146 // send as interruptible. A replacement may close this exact published link to
149-// release the send, but cannot retire an idle healthy snatched connection.147+// release the send without retiring an idle healthy attachment.
150-func (t *roleEffectTicket) beginTransportSend(expected transportSnapshot) bool {148+func (t *attachmentEffectTicket) beginTransportSend(expected transportSnapshot) bool {
151 if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil {149 if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil {
152 return false150 return false
153 }151 }
@@ -160,7 +158,7 @@ func (t *roleEffectTicket) beginTransportSend(expected transportSnapshot) bool {
160 return false158 return false
161 }159 }
162 if g.transportEffects == nil {160 if g.transportEffects == nil {
163- g.transportEffects = make(map[*roleEffectTicket]transportSnapshot)161+ g.transportEffects = make(map[*attachmentEffectTicket]transportSnapshot)
164 }162 }
165 g.transportEffects[t] = expected163 g.transportEffects[t] = expected
166 g.signalLocked()164 g.signalLocked()
@@ -168,7 +166,7 @@ func (t *roleEffectTicket) beginTransportSend(expected transportSnapshot) bool {
168 return true166 return true
169 }167 }
170 168
171-func (t *roleEffectTicket) reportTransportFailure(expected transportSnapshot) {169+func (t *attachmentEffectTicket) reportTransportFailure(expected transportSnapshot) {
172 if t == nil || t.gate == nil || expected.transport == nil {170 if t == nil || t.gate == nil || expected.transport == nil {
173 return171 return
174 }172 }
@@ -182,7 +180,7 @@ func (t *roleEffectTicket) reportTransportFailure(expected transportSnapshot) {
182 g.mu.Unlock()180 g.mu.Unlock()
183 }181 }
184 182
185-func (t *roleEffectTicket) endTransportSend() {183+func (t *attachmentEffectTicket) endTransportSend() {
186 if t == nil || t.gate == nil {184 if t == nil || t.gate == nil {
187 return185 return
188 }186 }
@@ -195,17 +193,17 @@ func (t *roleEffectTicket) endTransportSend() {
195 g.mu.Unlock()193 g.mu.Unlock()
196 }194 }
197 195
198-// beginRoleEffect is the sole role-bound effect admission point. The gate is196+// beginAttachmentEffect is the sole attachment-bound effect admission point. The gate is
199 // held only long enough to reserve the capability; the effect itself must run197 // held only long enough to reserve the capability; the effect itself must run
200 // without the gate mutex.198 // without the gate mutex.
201-func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) {199+func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken) (*attachmentEffectTicket, bool) {
202 if ac == nil || token.ac != ac {200 if ac == nil || token.ac != ac {
203 return nil, false201 return nil, false
204 }202 }
205- g := &ac.roleEffects203+ g := &ac.attachmentEffects
206 g.mu.Lock()204 g.mu.Lock()
207 g.initLocked()205 g.initLocked()
208- if g.phase != roleEffectsStable || !g.capability.matches(token) {206+ if g.phase != attachmentEffectsStable || !g.capability.matches(token) {
209 g.mu.Unlock()207 g.mu.Unlock()
210 return nil, false208 return nil, false
211 }209 }
@@ -213,30 +211,30 @@ func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffec
213 g.mu.Unlock()211 g.mu.Unlock()
214 captured := token212 captured := token
215 captured.effect = nil213 captured.effect = nil
216- return &roleEffectTicket{gate: g, token: captured}, true214+ return &attachmentEffectTicket{gate: g, token: captured}, true
217 }215 }
218 216
219-// beginCurrentRoleEffect waits out a transition that already froze ac, then217+// beginCurrentAttachmentEffect waits out a transition that already froze ac, then
220-// admits the role capability derived from the session registries. Handshakes218+// admits the capability derived from the session registry. Handshakes use it
221-// use it after Welcome has completed so a replacement blocked behind that send219+// after Welcome has completed so a replacement blocked behind that send can
222-// can publish before readiness, first paint, or the snatched reset panel.220+// publish before readiness or first paint.
223-func (ac *attachedClient) beginCurrentRoleEffect(sess *session, tr ports.Transport) (attachmentRoleToken, *roleEffectTicket, bool) {221+func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
224 if ac == nil || sess == nil || tr == nil {222 if ac == nil || sess == nil || tr == nil {
225- return attachmentRoleToken{}, nil, false223+ return attachmentConnectionToken{}, nil, false
226 }224 }
227 for {225 for {
228 token := sess.attachmentToken(ac, tr)226 token := sess.attachmentToken(ac, tr)
229- if token.role == attachmentDetached {227+ if token.ac == nil {
230 return token, nil, false228 return token, nil, false
231 }229 }
232- if ticket, admitted := ac.beginRoleEffect(token); admitted {230+ if ticket, admitted := ac.beginAttachmentEffect(token); admitted {
233 return token, ticket, true231 return token, ticket, true
234 }232 }
235 233
236- g := &ac.roleEffects234+ g := &ac.attachmentEffects
237 g.mu.Lock()235 g.mu.Lock()
238 g.initLocked()236 g.initLocked()
239- if g.phase == roleEffectsFrozen {237+ if g.phase == attachmentEffectsFrozen {
240 changed := g.changed238 changed := g.changed
241 g.mu.Unlock()239 g.mu.Unlock()
242 <-changed240 <-changed
@@ -244,19 +242,18 @@ func (ac *attachedClient) beginCurrentRoleEffect(sess *session, tr ports.Transpo
244 }242 }
245 g.mu.Unlock()243 g.mu.Unlock()
246 // Publication may have completed between token capture and admission.244 // Publication may have completed between token capture and admission.
247- // Re-read the registry and exact generation instead of using stale role245+ // Re-read the registry and exact generation instead of using stale authority.
248- // or lease authority.
249 }246 }
250 }247 }
251 248
252-// publishRoleCapability is used by direct/headless setup and by transition249+// publishAttachmentCapability is used by direct/headless setup and by transition
253-// publication while the gate is frozen. Production role changes publish only250+// publication while the gate is frozen. Production attachment changes publish only
254-// through publishFrozenRoleCapability.251+// through publishFrozenAttachmentCapability.
255-func (ac *attachedClient) publishRoleCapability(token attachmentRoleToken) {252+func (ac *attachedClient) publishAttachmentCapability(token attachmentConnectionToken) {
256 if ac == nil {253 if ac == nil {
257 return254 return
258 }255 }
259- g := &ac.roleEffects256+ g := &ac.attachmentEffects
260 g.mu.Lock()257 g.mu.Lock()
261 g.initLocked()258 g.initLocked()
262 g.capability = capabilityFromToken(token)259 g.capability = capabilityFromToken(token)
@@ -264,44 +261,44 @@ func (ac *attachedClient) publishRoleCapability(token attachmentRoleToken) {
264 g.mu.Unlock()261 g.mu.Unlock()
265 }262 }
266 263
267-// bootstrapRoleCapability supports direct/headless session construction. It264+// bootstrapAttachmentCapability supports direct/headless session construction. It
268 // never changes an existing or frozen production publication.265 // never changes an existing or frozen production publication.
269-func (ac *attachedClient) bootstrapRoleCapability(token attachmentRoleToken) {266+func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) {
270- if ac == nil || token.sess == nil || token.role == attachmentDetached {267+ if ac == nil || token.sess == nil || token.ac != ac {
271 return268 return
272 }269 }
273- g := &ac.roleEffects270+ g := &ac.attachmentEffects
274 g.mu.Lock()271 g.mu.Lock()
275 g.initLocked()272 g.initLocked()
276- if g.phase == roleEffectsStable && g.capability.sess == nil {273+ if g.phase == attachmentEffectsStable && g.capability.sess == nil {
277 g.capability = capabilityFromToken(token)274 g.capability = capabilityFromToken(token)
278 g.failedTransport = transportSnapshot{}275 g.failedTransport = transportSnapshot{}
279 }276 }
280 g.mu.Unlock()277 g.mu.Unlock()
281 }278 }
282 279
283-func (ac *attachedClient) publishFrozenRoleCapability(token attachmentRoleToken) {280+func (ac *attachedClient) publishFrozenAttachmentCapability(token attachmentConnectionToken) {
284- g := &ac.roleEffects281+ g := &ac.attachmentEffects
285 g.mu.Lock()282 g.mu.Lock()
286 g.initLocked()283 g.initLocked()
287- if g.phase != roleEffectsFrozen {284+ if g.phase != attachmentEffectsFrozen {
288 g.mu.Unlock()285 g.mu.Unlock()
289- panic("daemon: publishing role capability without transition freeze")286+ panic("daemon: publishing attachment capability without transition freeze")
290 }287 }
291 g.capability = capabilityFromToken(token)288 g.capability = capabilityFromToken(token)
292 g.failedTransport = transportSnapshot{}289 g.failedTransport = transportSnapshot{}
293 g.mu.Unlock()290 g.mu.Unlock()
294 }291 }
295 292
296-func (ac *attachedClient) invalidateFrozenRoleCapability() {293+func (ac *attachedClient) invalidateFrozenAttachmentCapability() {
297- g := &ac.roleEffects294+ g := &ac.attachmentEffects
298 g.mu.Lock()295 g.mu.Lock()
299 g.initLocked()296 g.initLocked()
300- if g.phase != roleEffectsFrozen {297+ if g.phase != attachmentEffectsFrozen {
301 g.mu.Unlock()298 g.mu.Unlock()
302- panic("daemon: invalidating role capability without transition freeze")299+ panic("daemon: invalidating attachment capability without transition freeze")
303 }300 }
304- g.capability = roleCapability{}301+ g.capability = attachmentCapability{}
305 g.failedTransport = transportSnapshot{}302 g.failedTransport = transportSnapshot{}
306 g.mu.Unlock()303 g.mu.Unlock()
307 }304 }
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 {
119 }119 }
120 }120 }
121 121
122-func startBlockedRoleSend(t *testing.T, token attachmentRoleToken) <-chan struct{} {122+func startBlockedRoleSend(t *testing.T, token attachmentConnectionToken) <-chan struct{} {
123 t.Helper()123 t.Helper()
124- ticket, admitted := token.ac.beginRoleEffect(token)124+ ticket, admitted := token.ac.beginAttachmentEffect(token)
125 require.True(t, admitted)125 require.True(t, admitted)
126 done := make(chan struct{})126 done := make(chan struct{})
127 go func() {127 go func() {
128 defer close(done)128 defer close(done)
129 defer ticket.End()129 defer ticket.End()
130- _ = token.ac.sendExpectedTransportForRole(token.transport, framePong(), ticket)130+ _ = token.ac.sendExpectedTransportForAttachment(token.transport, framePong(), ticket)
131 }()131 }()
132 return done132 return done
133 }133 }
134 134
135 func requireRoleGateRetired(t *testing.T, ac *attachedClient) {135 func requireRoleGateRetired(t *testing.T, ac *attachedClient) {
136 t.Helper()136 t.Helper()
137- ac.roleEffects.mu.Lock()137+ ac.attachmentEffects.mu.Lock()
138- defer ac.roleEffects.mu.Unlock()138+ defer ac.attachmentEffects.mu.Unlock()
139- require.Equal(t, roleEffectsStable, ac.roleEffects.phase)139+ require.Equal(t, attachmentEffectsStable, ac.attachmentEffects.phase)
140- require.Zero(t, ac.roleEffects.inFlight)140+ require.Zero(t, ac.attachmentEffects.inFlight)
141- require.Empty(t, ac.roleEffects.transportEffects)141+ require.Empty(t, ac.attachmentEffects.transportEffects)
142 }142 }
143 143
144-func roleGatePublicationSnapshot(ac *attachedClient) (roleEffectPhase, roleCapability) {144+func roleGatePublicationSnapshot(ac *attachedClient) (attachmentEffectPhase, attachmentCapability) {
145- ac.roleEffects.mu.Lock()145+ ac.attachmentEffects.mu.Lock()
146- defer ac.roleEffects.mu.Unlock()146+ defer ac.attachmentEffects.mu.Unlock()
147- return ac.roleEffects.phase, ac.roleEffects.capability147+ return ac.attachmentEffects.phase, ac.attachmentEffects.capability
148 }148 }
149 149
150 func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) {150 func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) {
@@ -168,21 +168,21 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
168 rc := d.attachCoordinator(sess, nil, first, true)168 rc := d.attachCoordinator(sess, nil, first, true)
169 firstToken := sess.attachmentToken(first, firstTransport)169 firstToken := sess.attachmentToken(first, firstTransport)
170 firstToken.lease = rc.attachmentLease(first)170 firstToken.lease = rc.attachmentLease(first)
171- first.publishRoleCapability(firstToken)171+ first.publishAttachmentCapability(firstToken)
172 blockedToken := sess.attachmentToken(blocked, blockedTransport)172 blockedToken := sess.attachmentToken(blocked, blockedTransport)
173- blocked.publishRoleCapability(blockedToken)173+ blocked.publishAttachmentCapability(blockedToken)
174 laterToken := sess.attachmentToken(later, laterTransport)174 laterToken := sess.attachmentToken(later, laterTransport)
175- later.publishRoleCapability(laterToken)175+ later.publishAttachmentCapability(laterToken)
176 176
177 // Fix the canonical order so teardown acquires first, waits behind the gate177 // Fix the canonical order so teardown acquires first, waits behind the gate
178 // owned by this test, and never reaches the still-stable later participant.178 // owned by this test, and never reaches the still-stable later participant.
179- first.roleEffects.immutableOrder()179+ first.attachmentEffects.immutableOrder()
180- blocked.roleEffects.immutableOrder()180+ blocked.attachmentEffects.immutableOrder()
181- later.roleEffects.immutableOrder()181+ later.attachmentEffects.immutableOrder()
182- require.Less(t, first.roleEffects.order.Load(), blocked.roleEffects.order.Load())182+ require.Less(t, first.attachmentEffects.order.Load(), blocked.attachmentEffects.order.Load())
183- require.Less(t, blocked.roleEffects.order.Load(), later.roleEffects.order.Load())183+ require.Less(t, blocked.attachmentEffects.order.Load(), later.attachmentEffects.order.Load())
184 184
185- blockedOwner := freezeRoleEffectGates(blocked)185+ blockedOwner := freezeAttachmentEffectGates(blocked)
186 blockedOwnerHeld := true186 blockedOwnerHeld := true
187 t.Cleanup(func() {187 t.Cleanup(func() {
188 if blockedOwnerHeld {188 if blockedOwnerHeld {
@@ -193,12 +193,12 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
193 firstPhase, firstCapability := roleGatePublicationSnapshot(first)193 firstPhase, firstCapability := roleGatePublicationSnapshot(first)
194 blockedPhase, blockedCapability := roleGatePublicationSnapshot(blocked)194 blockedPhase, blockedCapability := roleGatePublicationSnapshot(blocked)
195 laterPhase, laterCapability := roleGatePublicationSnapshot(later)195 laterPhase, laterCapability := roleGatePublicationSnapshot(later)
196- require.Equal(t, roleEffectsStable, firstPhase)196+ require.Equal(t, attachmentEffectsStable, firstPhase)
197- require.Equal(t, roleEffectsFrozen, blockedPhase)197+ require.Equal(t, attachmentEffectsFrozen, blockedPhase)
198- require.Equal(t, roleEffectsStable, laterPhase)198+ require.Equal(t, attachmentEffectsStable, laterPhase)
199- firstGeneration := first.roleGeneration.Load()199+ firstGeneration := first.connectionGeneration.Load()
200- blockedGeneration := blocked.roleGeneration.Load()200+ blockedGeneration := blocked.connectionGeneration.Load()
201- laterGeneration := later.roleGeneration.Load()201+ laterGeneration := later.connectionGeneration.Load()
202 firstSnapshot := first.transportSnapshot()202 firstSnapshot := first.transportSnapshot()
203 blockedSnapshot := blocked.transportSnapshot()203 blockedSnapshot := blocked.transportSnapshot()
204 laterSnapshot := later.transportSnapshot()204 laterSnapshot := later.transportSnapshot()
@@ -206,7 +206,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
206 clock := &signalClock{timers: make(chan *signalTimer, 1)}206 clock := &signalClock{timers: make(chan *signalTimer, 1)}
207 d.clock = clock207 d.clock = clock
208 frozenByKill := make(chan *attachedClient, 3)208 frozenByKill := make(chan *attachedClient, 3)
209- d.afterRoleEffectGateFrozen = func(_ string, ac *attachedClient) { frozenByKill <- ac }209+ d.afterAttachmentEffectGateFrozen = func(_ string, ac *attachedClient) { frozenByKill <- ac }
210 killDone := make(chan error, 1)210 killDone := make(chan error, 1)
211 go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()211 go func() { killDone <- d.killSession(sess, ports.ReasonSessionKilled, false) }()
212 212
@@ -233,9 +233,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
233 for _, ac := range []*attachedClient{first, blocked, later} {233 for _, ac := range []*attachedClient{first, blocked, later} {
234 require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership")234 require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership")
235 }235 }
236- require.Equal(t, firstGeneration, first.roleGeneration.Load())236+ require.Equal(t, firstGeneration, first.connectionGeneration.Load())
237- require.Equal(t, blockedGeneration, blocked.roleGeneration.Load())237+ require.Equal(t, blockedGeneration, blocked.connectionGeneration.Load())
238- require.Equal(t, laterGeneration, later.roleGeneration.Load())238+ require.Equal(t, laterGeneration, later.connectionGeneration.Load())
239 require.True(t, first.transportSnapshotCurrent(firstSnapshot))239 require.True(t, first.transportSnapshotCurrent(firstSnapshot))
240 require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot))240 require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot))
241 require.True(t, later.transportSnapshotCurrent(laterSnapshot))241 require.True(t, later.transportSnapshotCurrent(laterSnapshot))
@@ -246,21 +246,21 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
246 firstPhase, gotFirstCapability := roleGatePublicationSnapshot(first)246 firstPhase, gotFirstCapability := roleGatePublicationSnapshot(first)
247 blockedPhase, gotBlockedCapability := roleGatePublicationSnapshot(blocked)247 blockedPhase, gotBlockedCapability := roleGatePublicationSnapshot(blocked)
248 laterPhase, gotLaterCapability := roleGatePublicationSnapshot(later)248 laterPhase, gotLaterCapability := roleGatePublicationSnapshot(later)
249- require.Equal(t, roleEffectsStable, firstPhase, "partially acquired gate was not rolled back")249+ require.Equal(t, attachmentEffectsStable, firstPhase, "partially acquired gate was not rolled back")
250- require.Equal(t, roleEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition")250+ require.Equal(t, attachmentEffectsFrozen, blockedPhase, "teardown released a gate owned by another transition")
251- require.Equal(t, roleEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated")251+ require.Equal(t, attachmentEffectsStable, laterPhase, "unacquired later gate was frozen or invalidated")
252 require.Equal(t, firstCapability, gotFirstCapability)252 require.Equal(t, firstCapability, gotFirstCapability)
253 require.Equal(t, blockedCapability, gotBlockedCapability)253 require.Equal(t, blockedCapability, gotBlockedCapability)
254 require.Equal(t, laterCapability, gotLaterCapability)254 require.Equal(t, laterCapability, gotLaterCapability)
255 255
256 blockedOwner.unfreeze()256 blockedOwner.unfreeze()
257 blockedOwnerHeld = false257 blockedOwnerHeld = false
258- complete := freezeRoleEffectGates(first, blocked, later)258+ complete := freezeAttachmentEffectGates(first, blocked, later)
259 require.True(t, complete.acquired, "rolled-back gate set could not be acquired again")259 require.True(t, complete.acquired, "rolled-back gate set could not be acquired again")
260 require.True(t, complete.drained)260 require.True(t, complete.drained)
261 complete.unfreeze()261 complete.unfreeze()
262- for _, token := range []attachmentRoleToken{firstToken, blockedToken, laterToken} {262+ for _, token := range []attachmentConnectionToken{firstToken, blockedToken, laterToken} {
263- ticket, admitted := token.ac.beginRoleEffect(token)263+ ticket, admitted := token.ac.beginAttachmentEffect(token)
264 require.True(t, admitted, "pre-publication abort changed a role capability")264 require.True(t, admitted, "pre-publication abort changed a role capability")
265 ticket.End()265 ticket.End()
266 requireRoleGateRetired(t, token.ac)266 requireRoleGateRetired(t, token.ac)
@@ -272,14 +272,13 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
272 snatchedTransport := newTeardownBlockingTransport()272 snatchedTransport := newTeardownBlockingTransport()
273 snatched.replaceTransport(snatchedTransport)273 snatched.replaceTransport(snatchedTransport)
274 d.attachCoordinator(sess, nil, snatched, true)274 d.attachCoordinator(sess, nil, snatched, true)
275- snatched.publishRoleCapability(sess.attachmentToken(snatched, snatchedTransport))275+ snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))
276 276
277 activeTransport := newTeardownBlockingTransport()277 activeTransport := newTeardownBlockingTransport()
278 active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size}278 active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size}
279 active.initOverlays()279 active.initOverlays()
280 transition, err := d.transitionAttachment(attachmentTransitionRequest{280 transition, err := d.transitionAttachment(attachmentTransitionRequest{
281- target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive,281+ target: sess, next: active, expectedTransport: active.transportSnapshot(), ready: true,
282- expectedTransport: active.transportSnapshot(), ready: true,
283 })282 })
284 require.NoError(t, err)283 require.NoError(t, err)
285 defer d.deferAttachmentTransitionCleanups(transition)284 defer d.deferAttachmentTransitionCleanups(transition)
@@ -329,13 +328,13 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test
329 stale := newTeardownBlockingTransport()328 stale := newTeardownBlockingTransport()
330 ac.replaceTransport(stale)329 ac.replaceTransport(stale)
331 d.attachCoordinator(sess, nil, ac, true)330 d.attachCoordinator(sess, nil, ac, true)
332- ac.publishRoleCapability(sess.attachmentToken(ac, stale))331+ ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))
333 332
334 token := sess.attachmentToken(ac, stale)333 token := sess.attachmentToken(ac, stale)
335 sendDone := startBlockedRoleSend(t, token)334 sendDone := startBlockedRoleSend(t, token)
336 awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block")335 awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block")
337 fresh := &closeTrackingTransport{}336 fresh := &closeTrackingTransport{}
338- d.afterRoleEffectParticipantsSnapshotted = func(string, []*attachedClient) {337+ d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) {
339 ac.replaceTransport(fresh)338 ac.replaceTransport(fresh)
340 }339 }
341 340
@@ -358,9 +357,9 @@ func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T
358 transport := &closeTrackingTransport{}357 transport := &closeTrackingTransport{}
359 active.replaceTransport(transport)358 active.replaceTransport(transport)
360 d.attachCoordinator(sess, nil, active, true)359 d.attachCoordinator(sess, nil, active, true)
361- active.publishRoleCapability(sess.attachmentToken(active, transport))360+ active.publishAttachmentCapability(sess.attachmentToken(active, transport))
362 361
363- owner := freezeRoleEffectGates(active)362+ owner := freezeAttachmentEffectGates(active)
364 require.True(t, owner.acquired)363 require.True(t, owner.acquired)
365 defer owner.unfreeze()364 defer owner.unfreeze()
366 365
@@ -389,7 +388,7 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
389 blocked := newTeardownBlockingTransport()388 blocked := newTeardownBlockingTransport()
390 active.replaceTransport(blocked)389 active.replaceTransport(blocked)
391 d.attachCoordinator(sess, nil, active, true)390 d.attachCoordinator(sess, nil, active, true)
392- active.publishRoleCapability(sess.attachmentToken(active, blocked))391+ active.publishAttachmentCapability(sess.attachmentToken(active, blocked))
393 392
394 token := sess.attachmentToken(active, blocked)393 token := sess.attachmentToken(active, blocked)
395 sendDone := startBlockedRoleSend(t, token)394 sendDone := startBlockedRoleSend(t, token)
@@ -436,19 +435,19 @@ func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing
436 rc := d.attachCoordinator(sess, nil, original, true)435 rc := d.attachCoordinator(sess, nil, original, true)
437 originalToken := sess.attachmentToken(original, failedTransport)436 originalToken := sess.attachmentToken(original, failedTransport)
438 originalToken.lease = rc.attachmentLease(original)437 originalToken.lease = rc.attachmentLease(original)
439- original.publishRoleCapability(originalToken)438+ original.publishAttachmentCapability(originalToken)
440 439
441 cleanupStarted := make(chan struct{})440 cleanupStarted := make(chan struct{})
442 releaseCleanup := make(chan struct{})441 releaseCleanup := make(chan struct{})
443 cleanupDone := make(chan struct{})442 cleanupDone := make(chan struct{})
444- d.beforeRoleSendErrorCleanup = func(token attachmentRoleToken) {443+ d.beforeAttachmentSendErrorCleanup = func(token attachmentConnectionToken) {
445 require.Equal(t, originalToken.generation, token.generation)444 require.Equal(t, originalToken.generation, token.generation)
446 require.Equal(t, originalToken.transport, token.transport)445 require.Equal(t, originalToken.transport, token.transport)
447 require.Same(t, originalToken.lease, token.lease)446 require.Same(t, originalToken.lease, token.lease)
448 close(cleanupStarted)447 close(cleanupStarted)
449 <-releaseCleanup448 <-releaseCleanup
450 }449 }
451- d.afterRoleSendErrorCleanup = func() { close(cleanupDone) }450+ d.afterAttachmentSendErrorCleanup = func() { close(cleanupDone) }
452 451
453 d.paint(sess, original, true, originalToken.lease)452 d.paint(sess, original, true, originalToken.lease)
454 awaitTestCompletion(t, cleanupStarted, "render failure cleanup did not retain the captured role token")453 awaitTestCompletion(t, cleanupStarted, "render failure cleanup did not retain the captured role token")
@@ -463,17 +462,15 @@ func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing
463 replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: original.size}462 replacement := &attachedClient{tr: replacementTransport, output: newOutputStateStream(), size: original.size}
464 replacement.initOverlays()463 replacement.initOverlays()
465 displaced, err := d.transitionAttachment(attachmentTransitionRequest{464 displaced, err := d.transitionAttachment(attachmentTransitionRequest{
466- target: sess, next: replacement, expectedRole: attachmentDetached, targetRole: attachmentActive,465+ target: sess, next: replacement, expectedTransport: replacement.transportSnapshot(), ready: true,
467- expectedTransport: replacement.transportSnapshot(), ready: true,
468 })466 })
469 require.NoError(t, err)467 require.NoError(t, err)
470 468
471 resumed, err := d.transitionAttachment(attachmentTransitionRequest{469 resumed, err := d.transitionAttachment(attachmentTransitionRequest{
472- target: sess, next: original, expectedRole: attachmentSnatched, targetRole: attachmentActive,470+ target: sess, next: original, expectedTransport: original.transportSnapshot(), ready: true,
473- expectedTransport: original.transportSnapshot(), ready: true,
474 })471 })
475 require.NoError(t, err)472 require.NoError(t, err)
476- resumedGeneration := original.roleGeneration.Load()473+ resumedGeneration := original.connectionGeneration.Load()
477 resumedLease := resumed.published.lease474 resumedLease := resumed.published.lease
478 require.NotSame(t, originalToken.lease, resumedLease)475 require.NotSame(t, originalToken.lease, resumedLease)
479 476
@@ -484,8 +481,8 @@ func TestRenderSendFailureCleanupRejectsReplacedAndResumedIncarnation(t *testing
484 sess.mu.Lock()481 sess.mu.Lock()
485 require.Contains(t, sess.snapshotAttachmentsLocked(), original, "stale render cleanup detached the resumed incarnation")482 require.Contains(t, sess.snapshotAttachmentsLocked(), original, "stale render cleanup detached the resumed incarnation")
486 sess.mu.Unlock()483 sess.mu.Unlock()
487- require.Equal(t, resumedGeneration, original.roleGeneration.Load())484+ require.Equal(t, resumedGeneration, original.connectionGeneration.Load())
488- require.True(t, resumed.published.activeCurrent())485+ require.True(t, resumed.published.attachmentCurrent())
489 require.False(t, failedTransport.Closed(), "stale cleanup closed the resumed incarnation's transport")486 require.False(t, failedTransport.Closed(), "stale cleanup closed the resumed incarnation's transport")
490 487
491 d.deferAttachmentTransitionCleanups(displaced)488 d.deferAttachmentTransitionCleanups(displaced)
@@ -500,16 +497,16 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing
500 rc := d.attachCoordinator(sess, nil, ac, true)497 rc := d.attachCoordinator(sess, nil, ac, true)
501 token := sess.attachmentToken(ac, transport)498 token := sess.attachmentToken(ac, transport)
502 token.lease = rc.attachmentLease(ac)499 token.lease = rc.attachmentLease(ac)
503- ac.publishRoleCapability(token)500+ ac.publishAttachmentCapability(token)
504 501
505 clock := &signalClock{timers: make(chan *signalTimer, 1)}502 clock := &signalClock{timers: make(chan *signalTimer, 1)}
506 d.clock = clock503 d.clock = clock
507- frozen := freezeRoleEffectGates(ac)504+ frozen := freezeAttachmentEffectGates(ac)
508 defer frozen.unfreeze()505 defer frozen.unfreeze()
509 cleanupDone := make(chan struct{})506 cleanupDone := make(chan struct{})
510- d.afterRoleSendErrorCleanup = func() { close(cleanupDone) }507+ d.afterAttachmentSendErrorCleanup = func() { close(cleanupDone) }
511 508
512- launchCleanup := d.reserveRoleSendErrorCleanup(token, transport)509+ launchCleanup := d.reserveAttachmentSendErrorCleanup(token, transport)
513 launchCleanup()510 launchCleanup()
514 deadline := awaitTestValue(t, clock.timers, "render cleanup did not arm its deadline")511 deadline := awaitTestValue(t, clock.timers, "render cleanup did not arm its deadline")
515 require.Equal(t, detachNotifyTimeout, deadline.duration)512 require.Equal(t, detachNotifyTimeout, deadline.duration)
@@ -523,7 +520,7 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileRoleGateIsBusy(t *testing
523 require.False(t, transport.Closed())520 require.False(t, transport.Closed())
524 }521 }
525 522
526-func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t *testing.T) {523+func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t *testing.T) {
527 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})524 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
528 oldTransport := newBlockedRenderReplacementTransport(d, sess)525 oldTransport := newBlockedRenderReplacementTransport(d, sess)
529 old.replaceTransport(oldTransport)526 old.replaceTransport(oldTransport)
@@ -531,7 +528,7 @@ func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t
531 lease := rc.attachmentLease(old)528 lease := rc.attachmentLease(old)
532 token := sess.attachmentToken(old, oldTransport)529 token := sess.attachmentToken(old, oldTransport)
533 token.lease = lease530 token.lease = lease
534- old.publishRoleCapability(token)531+ old.publishAttachmentCapability(token)
535 532
536 paintDone := make(chan struct{})533 paintDone := make(chan struct{})
537 go func() {534 go func() {
@@ -547,8 +544,7 @@ func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t
547 transitionErr := make(chan error, 1)544 transitionErr := make(chan error, 1)
548 go func() {545 go func() {
549 result, err := d.transitionAttachment(attachmentTransitionRequest{546 result, err := d.transitionAttachment(attachmentTransitionRequest{
550- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,547+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
551- expectedTransport: next.transportSnapshot(), ready: true,
552 })548 })
553 transitionDone <- result549 transitionDone <- result
554 transitionErr <- err550 transitionErr <- err
@@ -561,32 +557,34 @@ func TestRoleEffectGateReplacementInterruptsBlockedOldRenderBeforePublication(t
561 t.Fatal("replacement publication remained blocked behind the old render send")557 t.Fatal("replacement publication remained blocked behind the old render send")
562 }558 }
563 require.NoError(t, <-transitionErr)559 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())
565 select {567 select {
566 case <-paintDone:568 case <-paintDone:
567 case <-time.After(time.Second):569 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")
569 }571 }
570- require.Contains(t, sess.snapshotAttachments(), next)
571- require.False(t, newTransport.Closed(), "replacement transport was affected by old-link interruption")
572-
573 d.deferAttachmentTransitionCleanups(result)572 d.deferAttachmentTransitionCleanups(result)
574 d.attachmentCleanupWg.Wait()573 d.attachmentCleanupWg.Wait()
575- require.Nil(t, old.transport(), "the unhealthy old link was not retired")574+ require.Same(t, oldTransport, old.transport(), "unrelated attachment link was retired")
576- require.Contains(t, sess.snapshotAttachments(), next)
577 require.False(t, newTransport.Closed())575 require.False(t, newTransport.Closed())
578 }576 }
579 577
580-func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) {578+func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *testing.T) {
581 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})579 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
582 oldTransport := newDatagramTestTransport()580 oldTransport := newDatagramTestTransport()
583 old.replaceTransport(oldTransport)581 old.replaceTransport(oldTransport)
584 rc := d.attachCoordinator(sess, nil, old, true)582 rc := d.attachCoordinator(sess, nil, old, true)
585 token := sess.attachmentToken(old, oldTransport)583 token := sess.attachmentToken(old, oldTransport)
586 token.lease = rc.attachmentLease(old)584 token.lease = rc.attachmentLease(old)
587- old.publishRoleCapability(token)585+ old.publishAttachmentCapability(token)
588 586
589- ticket, ok := old.beginRoleEffect(token)587+ ticket, ok := old.beginAttachmentEffect(token)
590 require.True(t, ok)588 require.True(t, ok)
591 589
592 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}590 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
@@ -594,22 +592,18 @@ func TestRoleEffectGateAdmittedEffectCompletesBeforeConflictingTransition(t *tes
594 done := make(chan error, 1)592 done := make(chan error, 1)
595 go func() {593 go func() {
596 _, err := d.transitionAttachment(attachmentTransitionRequest{594 _, err := d.transitionAttachment(attachmentTransitionRequest{
597- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,595+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
598- expectedTransport: next.transportSnapshot(), ready: true,
599 })596 })
600 done <- err597 done <- err
601 }()598 }()
602 599
603- select {600+ // Adding an independent attachment does not wait on another connection's
604- case err := <-done:601+ // effect gate.
605- t.Fatalf("transition committed before admitted effect ended: %v", err)
606- case <-time.After(20 * time.Millisecond):
607- }
608- ticket.End()
609 require.NoError(t, <-done)602 require.NoError(t, <-done)
603+ ticket.End()
610 }604 }
611 605
612-func TestRoleEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T) {606+func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T) {
613 tests := []struct {607 tests := []struct {
614 name string608 name string
615 setup func(*Daemon, *session, *attachedClient, *transactionalResizePTY)609 setup func(*Daemon, *session, *attachedClient, *transactionalResizePTY)
@@ -695,13 +689,13 @@ func TestRoleEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T
695 689
696 admitted := make(chan struct{})690 admitted := make(chan struct{})
697 release := make(chan struct{})691 release := make(chan struct{})
698- d.afterRoleEffectAdmitted = func(attachmentRoleToken) {692+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {
699 close(admitted)693 close(admitted)
700 <-release694 <-release
701 }695 }
702 effectDone := make(chan struct{})696 effectDone := make(chan struct{})
703 go func() {697 go func() {
704- d.handleActiveClientFrame(token, tt.frame)698+ d.handleAttachmentClientFrame(token, tt.frame)
705 close(effectDone)699 close(effectDone)
706 }()700 }()
707 <-admitted701 <-admitted
@@ -711,19 +705,13 @@ func TestRoleEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *testing.T
711 transitionDone := make(chan error, 1)705 transitionDone := make(chan error, 1)
712 go func() {706 go func() {
713 _, err := d.transitionAttachment(attachmentTransitionRequest{707 _, err := d.transitionAttachment(attachmentTransitionRequest{
714- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,708+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
715- expectedTransport: next.transportSnapshot(), ready: true,
716 })709 })
717 transitionDone <- err710 transitionDone <- err
718 }()711 }()
719- select {712+ require.NoError(t, <-transitionDone)
720- case err := <-transitionDone:
721- t.Fatalf("transition overtook admitted effect: %v", err)
722- case <-time.After(20 * time.Millisecond):
723- }
724 close(release)713 close(release)
725 <-effectDone714 <-effectDone
726- require.NoError(t, <-transitionDone)
727 tt.assert(t, old, pty)715 tt.assert(t, old, pty)
728 })716 })
729 }717 }
@@ -741,13 +729,13 @@ func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T)
741 rc := d.attachCoordinator(sess, nil, old, true)729 rc := d.attachCoordinator(sess, nil, old, true)
742 token := sess.attachmentToken(old, oldTransport)730 token := sess.attachmentToken(old, oldTransport)
743 token.lease = rc.attachmentLease(old)731 token.lease = rc.attachmentLease(old)
744- old.publishRoleCapability(token)732+ old.publishAttachmentCapability(token)
745 733
746 admissionEnded := make(chan struct{})734 admissionEnded := make(chan struct{})
747 releaseAction := make(chan struct{})735 releaseAction := make(chan struct{})
748 release := releaseTestGate(t, releaseAction)736 release := releaseTestGate(t, releaseAction)
749 var admissionEndedOnce sync.Once737 var admissionEndedOnce sync.Once
750- d.afterActionRoleEffectEnded = func(action string) {738+ d.afterActionAttachmentEffectEnded = func(action string) {
751 if action != "jump-attention" {739 if action != "jump-attention" {
752 return740 return
753 }741 }
@@ -756,7 +744,7 @@ func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T)
756 }744 }
757 actionDone := make(chan struct{})745 actionDone := make(chan struct{})
758 go func() {746 go func() {
759- d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))747+ d.handleAttachmentClientFrame(token, frameInput([]byte("\x1ba")))
760 close(actionDone)748 close(actionDone)
761 }()749 }()
762 <-admissionEnded750 <-admissionEnded
@@ -764,8 +752,7 @@ func TestJumpAttentionDoesNotMutateSourceAfterInitiatorReplacement(t *testing.T)
764 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}752 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
765 next.initOverlays()753 next.initOverlays()
766 result, err := d.transitionAttachment(attachmentTransitionRequest{754 result, err := d.transitionAttachment(attachmentTransitionRequest{
767- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,755+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
768- expectedTransport: next.transportSnapshot(), ready: true,
769 })756 })
770 require.NoError(t, err)757 require.NoError(t, err)
771 sess.mu.Lock()758 sess.mu.Lock()
@@ -797,9 +784,9 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
797 rc := d.attachCoordinator(source, nil, ac, true)784 rc := d.attachCoordinator(source, nil, ac, true)
798 token := source.attachmentToken(ac, ac.transport())785 token := source.attachmentToken(ac, ac.transport())
799 token.lease = rc.attachmentLease(ac)786 token.lease = rc.attachmentLease(ac)
800- ac.publishRoleCapability(token)787+ ac.publishAttachmentCapability(token)
801 788
802- d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))789+ d.handleAttachmentClientFrame(token, frameInput([]byte("\x1ba")))
803 790
804 require.Same(t, target, ac.currentSession())791 require.Same(t, target, ac.currentSession())
805 require.Equal(t, 1, testAttachmentTabIndex(target))792 require.Equal(t, 1, testAttachmentTabIndex(target))
@@ -825,13 +812,13 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T
825 rc := d.attachCoordinator(source, nil, old, true)812 rc := d.attachCoordinator(source, nil, old, true)
826 token := source.attachmentToken(old, oldTransport)813 token := source.attachmentToken(old, oldTransport)
827 token.lease = rc.attachmentLease(old)814 token.lease = rc.attachmentLease(old)
828- old.publishRoleCapability(token)815+ old.publishAttachmentCapability(token)
829 816
830 admissionEnded := make(chan struct{})817 admissionEnded := make(chan struct{})
831 releaseAction := make(chan struct{})818 releaseAction := make(chan struct{})
832 release := releaseTestGate(t, releaseAction)819 release := releaseTestGate(t, releaseAction)
833 var admissionEndedOnce sync.Once820 var admissionEndedOnce sync.Once
834- d.afterActionRoleEffectEnded = func(action string) {821+ d.afterActionAttachmentEffectEnded = func(action string) {
835 if action == "jump-attention" {822 if action == "jump-attention" {
836 admissionEndedOnce.Do(func() { close(admissionEnded) })823 admissionEndedOnce.Do(func() { close(admissionEnded) })
837 <-releaseAction824 <-releaseAction
@@ -839,7 +826,7 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T
839 }826 }
840 actionDone := make(chan struct{})827 actionDone := make(chan struct{})
841 go func() {828 go func() {
842- d.handleActiveClientFrame(token, frameInput([]byte("\x1ba")))829+ d.handleAttachmentClientFrame(token, frameInput([]byte("\x1ba")))
843 close(actionDone)830 close(actionDone)
844 }()831 }()
845 <-admissionEnded832 <-admissionEnded
@@ -847,8 +834,7 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T
847 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}834 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
848 next.initOverlays()835 next.initOverlays()
849 result, err := d.transitionAttachment(attachmentTransitionRequest{836 result, err := d.transitionAttachment(attachmentTransitionRequest{
850- target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,837+ target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true,
851- expectedTransport: next.transportSnapshot(), ready: true,
852 })838 })
853 require.NoError(t, err)839 require.NoError(t, err)
854 release()840 release()
@@ -872,13 +858,13 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
872 rc := d.attachCoordinator(sess, nil, old, true)858 rc := d.attachCoordinator(sess, nil, old, true)
873 token := sess.attachmentToken(old, oldTransport)859 token := sess.attachmentToken(old, oldTransport)
874 token.lease = rc.attachmentLease(old)860 token.lease = rc.attachmentLease(old)
875- old.publishRoleCapability(token)861+ old.publishAttachmentCapability(token)
876 862
877 admissionEnded := make(chan struct{})863 admissionEnded := make(chan struct{})
878 releaseAction := make(chan struct{})864 releaseAction := make(chan struct{})
879 release := releaseTestGate(t, releaseAction)865 release := releaseTestGate(t, releaseAction)
880 var admissionEndedOnce sync.Once866 var admissionEndedOnce sync.Once
881- d.afterActionRoleEffectEnded = func(action string) {867+ d.afterActionAttachmentEffectEnded = func(action string) {
882 if action != "picker-delete" {868 if action != "picker-delete" {
883 return869 return
884 }870 }
@@ -887,7 +873,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
887 }873 }
888 actionDone := make(chan struct{})874 actionDone := make(chan struct{})
889 go func() {875 go func() {
890- d.handleActiveClientFrame(token, frameInput([]byte("x")))876+ d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
891 close(actionDone)877 close(actionDone)
892 }()878 }()
893 <-admissionEnded879 <-admissionEnded
@@ -895,8 +881,7 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
895 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}881 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
896 next.initOverlays()882 next.initOverlays()
897 result, err := d.transitionAttachment(attachmentTransitionRequest{883 result, err := d.transitionAttachment(attachmentTransitionRequest{
898- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,884+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
899- expectedTransport: next.transportSnapshot(), ready: true,
900 })885 })
901 require.NoError(t, err)886 require.NoError(t, err)
902 release()887 release()
@@ -933,13 +918,13 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi
933 rc := d.attachCoordinator(source, nil, old, true)918 rc := d.attachCoordinator(source, nil, old, true)
934 token := source.attachmentToken(old, oldTransport)919 token := source.attachmentToken(old, oldTransport)
935 token.lease = rc.attachmentLease(old)920 token.lease = rc.attachmentLease(old)
936- old.publishRoleCapability(token)921+ old.publishAttachmentCapability(token)
937 922
938 admissionEnded := make(chan struct{})923 admissionEnded := make(chan struct{})
939 releaseAction := make(chan struct{})924 releaseAction := make(chan struct{})
940 release := releaseTestGate(t, releaseAction)925 release := releaseTestGate(t, releaseAction)
941 var admissionEndedOnce sync.Once926 var admissionEndedOnce sync.Once
942- d.afterActionRoleEffectEnded = func(action string) {927+ d.afterActionAttachmentEffectEnded = func(action string) {
943 if action == "picker-delete" {928 if action == "picker-delete" {
944 admissionEndedOnce.Do(func() { close(admissionEnded) })929 admissionEndedOnce.Do(func() { close(admissionEnded) })
945 <-releaseAction930 <-releaseAction
@@ -947,7 +932,7 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi
947 }932 }
948 actionDone := make(chan struct{})933 actionDone := make(chan struct{})
949 go func() {934 go func() {
950- d.handleActiveClientFrame(token, frameInput([]byte("x")))935+ d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
951 close(actionDone)936 close(actionDone)
952 }()937 }()
953 <-admissionEnded938 <-admissionEnded
@@ -960,8 +945,7 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi
960 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}945 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
961 next.initOverlays()946 next.initOverlays()
962 result, err := d.transitionAttachment(attachmentTransitionRequest{947 result, err := d.transitionAttachment(attachmentTransitionRequest{
963- target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,948+ target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true,
964- expectedTransport: next.transportSnapshot(), ready: true,
965 })949 })
966 require.NoError(t, err)950 require.NoError(t, err)
967 release()951 release()
@@ -981,9 +965,9 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
981 rc := d.attachCoordinator(sess, nil, ac, true)965 rc := d.attachCoordinator(sess, nil, ac, true)
982 token := sess.attachmentToken(ac, ac.transport())966 token := sess.attachmentToken(ac, ac.transport())
983 token.lease = rc.attachmentLease(ac)967 token.lease = rc.attachmentLease(ac)
984- ac.publishRoleCapability(token)968+ ac.publishAttachmentCapability(token)
985 969
986- d.handleActiveClientFrame(token, frameInput([]byte("x")))970+ d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
987 971
988 d.mu.Lock()972 d.mu.Lock()
989 _, registered := d.sessions[sess.id]973 _, registered := d.sessions[sess.id]
@@ -1003,13 +987,13 @@ func TestDelayedKeyCallbackAdmittedBeforeReplacementCompletesFirst(t *testing.T)
1003 rc := d.attachCoordinator(sess, nil, old, true)987 rc := d.attachCoordinator(sess, nil, old, true)
1004 token := sess.attachmentToken(old, oldTransport)988 token := sess.attachmentToken(old, oldTransport)
1005 token.lease = rc.attachmentLease(old)989 token.lease = rc.attachmentLease(old)
1006- old.publishRoleCapability(token)990+ old.publishAttachmentCapability(token)
1007 991
1008- d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC}))992+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC}))
1009 timer := <-clock.timers993 timer := <-clock.timers
1010 admitted := make(chan struct{})994 admitted := make(chan struct{})
1011 release := make(chan struct{})995 release := make(chan struct{})
1012- d.afterRoleEffectAdmitted = func(attachmentRoleToken) {996+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {
1013 close(admitted)997 close(admitted)
1014 <-release998 <-release
1015 }999 }
@@ -1025,8 +1009,7 @@ func TestDelayedKeyCallbackAdmittedBeforeReplacementCompletesFirst(t *testing.T)
1025 transitionDone := make(chan error, 1)1009 transitionDone := make(chan error, 1)
1026 go func() {1010 go func() {
1027 _, err := d.transitionAttachment(attachmentTransitionRequest{1011 _, err := d.transitionAttachment(attachmentTransitionRequest{
1028- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,1012+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
1029- expectedTransport: next.transportSnapshot(), ready: true,
1030 })1013 })
1031 transitionDone <- err1014 transitionDone <- err
1032 }()1015 }()
@@ -1052,15 +1035,14 @@ func TestDelayedKeyCallbackAfterReplacementDropsStaleWork(t *testing.T) {
1052 rc := d.attachCoordinator(sess, nil, old, true)1035 rc := d.attachCoordinator(sess, nil, old, true)
1053 token := sess.attachmentToken(old, oldTransport)1036 token := sess.attachmentToken(old, oldTransport)
1054 token.lease = rc.attachmentLease(old)1037 token.lease = rc.attachmentLease(old)
1055- old.publishRoleCapability(token)1038+ old.publishAttachmentCapability(token)
1056 1039
1057- d.handleActiveClientFrame(token, frameInput([]byte{keys.ESC}))1040+ d.handleAttachmentClientFrame(token, frameInput([]byte{keys.ESC}))
1058 timer := <-clock.timers1041 timer := <-clock.timers
1059 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}1042 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
1060 next.initOverlays()1043 next.initOverlays()
1061 _, err := d.transitionAttachment(attachmentTransitionRequest{1044 _, err := d.transitionAttachment(attachmentTransitionRequest{
1062- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,1045+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
1063- expectedTransport: next.transportSnapshot(), ready: true,
1064 })1046 })
1065 require.NoError(t, err)1047 require.NoError(t, err)
1066 1048
@@ -1075,7 +1057,7 @@ func TestDelayedKeyCallbackAfterReplacementDropsStaleWork(t *testing.T) {
1075 }1057 }
1076 }1058 }
1077 1059
1078-func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) {1060+func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T) {
1079 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})1061 d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1080 oldTransport := newDatagramTestTransport()1062 oldTransport := newDatagramTestTransport()
1081 old.replaceTransport(oldTransport)1063 old.replaceTransport(oldTransport)
@@ -1085,7 +1067,7 @@ func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T)
1085 1067
1086 admitted := make(chan struct{})1068 admitted := make(chan struct{})
1087 release := make(chan struct{})1069 release := make(chan struct{})
1088- d.afterRoleEffectAdmitted = func(attachmentRoleToken) {1070+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {
1089 close(admitted)1071 close(admitted)
1090 <-release1072 <-release
1091 }1073 }
@@ -1098,22 +1080,18 @@ func TestRoleEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *testing.T)
1098 transitionDone := make(chan error, 1)1080 transitionDone := make(chan error, 1)
1099 go func() {1081 go func() {
1100 _, err := d.transitionAttachment(attachmentTransitionRequest{1082 _, err := d.transitionAttachment(attachmentTransitionRequest{
1101- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,1083+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
1102- expectedTransport: next.transportSnapshot(), ready: true,
1103 })1084 })
1104 transitionDone <- err1085 transitionDone <- err
1105 }()1086 }()
1106- select {1087+ // An independent attachment publication does not wait on this connection's
1107- case err := <-transitionDone:1088+ // admitted first paint.
1108- t.Fatalf("replacement overtook admitted first paint: %v", err)1089+ require.NoError(t, <-transitionDone)
1109- case <-time.After(20 * time.Millisecond):
1110- }
1111 close(release)1090 close(release)
1112 require.True(t, <-paintDone)1091 require.True(t, <-paintDone)
1113- require.NoError(t, <-transitionDone)
1114 }1092 }
1115 1093
1116-func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) {1094+func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) {
1117 d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})1095 d, first, a, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1118 aTransport := newDatagramTestTransport()1096 aTransport := newDatagramTestTransport()
1119 a.replaceTransport(aTransport)1097 a.replaceTransport(aTransport)
@@ -1137,7 +1115,7 @@ func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T)
1137 <-start1115 <-start
1138 _, err := d.transitionAttachment(attachmentTransitionRequest{1116 _, err := d.transitionAttachment(attachmentTransitionRequest{
1139 source: first, target: second, next: a,1117 source: first, target: second, next: a,
1140- expectedRole: attachmentActive, targetRole: attachmentActive,1118+
1141 expectedTransport: a.transportSnapshot(), ready: true,1119 expectedTransport: a.transportSnapshot(), ready: true,
1142 })1120 })
1143 results <- err1121 results <- err
@@ -1146,7 +1124,7 @@ func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T)
1146 <-start1124 <-start
1147 _, err := d.transitionAttachment(attachmentTransitionRequest{1125 _, err := d.transitionAttachment(attachmentTransitionRequest{
1148 source: second, target: first, next: b,1126 source: second, target: first, next: b,
1149- expectedRole: attachmentActive, targetRole: attachmentActive,1127+
1150 expectedTransport: b.transportSnapshot(), ready: true,1128 expectedTransport: b.transportSnapshot(), ready: true,
1151 })1129 })
1152 results <- err1130 results <- err
@@ -1168,7 +1146,7 @@ func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T)
1168 successes++1146 successes++
1169 }1147 }
1170 }1148 }
1171- require.Equal(t, 1, successes)1149+ require.Equal(t, 2, successes)
1172 }1150 }
1173 1151
1174 func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {1152 func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
@@ -1192,16 +1170,16 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
1192 // Give B the earlier immutable identity. The reverse transition therefore1170 // Give B the earlier immutable identity. The reverse transition therefore
1193 // freezes B then A, while the old picker-delete path froze A separately1171 // freezes B then A, while the old picker-delete path froze A separately
1194 // before discovering and freezing B.1172 // before discovering and freezing B.
1195- b.roleEffects.immutableOrder()1173+ b.attachmentEffects.immutableOrder()
1196- a.roleEffects.immutableOrder()1174+ a.attachmentEffects.immutableOrder()
1197- require.Less(t, b.roleEffects.order.Load(), a.roleEffects.order.Load())1175+ require.Less(t, b.attachmentEffects.order.Load(), a.attachmentEffects.order.Load())
1198 1176
1199 token := source.attachmentToken(a, aTransport)1177 token := source.attachmentToken(a, aTransport)
1200 token.lease = aCoordinator.attachmentLease(a)1178 token.lease = aCoordinator.attachmentLease(a)
1201- a.publishRoleCapability(token)1179+ a.publishAttachmentCapability(token)
1202- effect, admitted := a.beginRoleEffect(token)1180+ effect, admitted := a.beginAttachmentEffect(token)
1203 require.True(t, admitted)1181 require.True(t, admitted)
1204- token = effect.roleToken()1182+ token = effect.connectionToken()
1205 1183
1206 deleteDiscovered := make(chan []*attachedClient, 1)1184 deleteDiscovered := make(chan []*attachedClient, 1)
1207 transitionDiscovered := make(chan []*attachedClient, 1)1185 transitionDiscovered := make(chan []*attachedClient, 1)
@@ -1209,7 +1187,7 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
1209 releaseTransition := make(chan struct{})1187 releaseTransition := make(chan struct{})
1210 transitionBFrozen := make(chan struct{})1188 transitionBFrozen := make(chan struct{})
1211 deleteFrozen := make(chan *attachedClient, 2)1189 deleteFrozen := make(chan *attachedClient, 2)
1212- d.afterRoleEffectParticipantsSnapshotted = func(action string, participants []*attachedClient) {1190+ d.afterAttachmentEffectParticipantsSnapshotted = func(action string, participants []*attachedClient) {
1213 snapshot := append([]*attachedClient(nil), participants...)1191 snapshot := append([]*attachedClient(nil), participants...)
1214 switch action {1192 switch action {
1215 case "picker-delete":1193 case "picker-delete":
@@ -1220,7 +1198,7 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
1220 <-releaseTransition1198 <-releaseTransition
1221 }1199 }
1222 }1200 }
1223- d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {1201+ d.afterAttachmentEffectGateFrozen = func(action string, ac *attachedClient) {
1224 switch action {1202 switch action {
1225 case "reverse-delete-transition":1203 case "reverse-delete-transition":
1226 if ac == b {1204 if ac == b {
@@ -1233,7 +1211,7 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
1233 1211
1234 deleteDone := make(chan error, 1)1212 deleteDone := make(chan error, 1)
1235 go func() {1213 go func() {
1236- deleteDone <- d.killPickerTargetForRole(picker.Target{Session: target.id}, token)1214+ deleteDone <- d.killPickerTargetForAttachment(picker.Target{Session: target.id}, token)
1237 }()1215 }()
1238 deleteParticipants := <-deleteDiscovered1216 deleteParticipants := <-deleteDiscovered
1239 require.ElementsMatch(t, []*attachedClient{a, b}, deleteParticipants)1217 require.ElementsMatch(t, []*attachedClient{a, b}, deleteParticipants)
@@ -1242,7 +1220,7 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
1242 go func() {1220 go func() {
1243 result, err := d.transitionAttachment(attachmentTransitionRequest{1221 result, err := d.transitionAttachment(attachmentTransitionRequest{
1244 source: target, target: source, next: b,1222 source: target, target: source, next: b,
1245- expectedRole: attachmentActive, targetRole: attachmentActive,1223+
1246 expectedTransport: b.transportSnapshot(), action: "reverse-delete-transition", ready: true,1224 expectedTransport: b.transportSnapshot(), action: "reverse-delete-transition", ready: true,
1247 })1225 })
1248 if err == nil {1226 if err == nil {
@@ -1289,40 +1267,23 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) {
1289 registered := d.sessions[target.id]1267 registered := d.sessions[target.id]
1290 d.mu.Unlock()1268 d.mu.Unlock()
1291 require.Same(t, target, registered, "stale picker deletion mutated its target lifecycle")1269 require.Same(t, target, registered, "stale picker deletion mutated its target lifecycle")
1292- require.Equal(t, attachmentSnatched, source.attachmentRole(a))1270+ require.Equal(t, true, source.attachmentRegistered(a))
1293- require.Equal(t, attachmentActive, source.attachmentRole(b))1271+ require.Equal(t, true, source.attachmentRegistered(b))
1294 }1272 }
1295 1273
1296-func TestRoleEffectGateFrozenTransitionRejectsLateEffect(t *testing.T) {1274+func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) {
1297- d, sess, old, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})1275+ d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
1298- oldTransport := newDatagramTestTransport()1276+ transport := newDatagramTestTransport()
1299- old.replaceTransport(oldTransport)1277+ ac.replaceTransport(transport)
1300- rc := d.attachCoordinator(sess, nil, old, true)1278+ rc := d.attachCoordinator(sess, nil, ac, true)
1301- token := sess.attachmentToken(old, oldTransport)1279+ token := sess.attachmentToken(ac, transport)
1302- token.lease = rc.attachmentLease(old)1280+ token.lease = rc.attachmentLease(ac)
1303- old.publishRoleCapability(token)1281+ ac.publishAttachmentCapability(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
1323 1282
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)
1325 require.False(t, ok, "a frozen capability must admit no new effect")1287 require.False(t, ok, "a frozen capability must admit no new effect")
1326- close(release)1288+ frozen.unfreeze()
1327- require.NoError(t, <-done)
1328 }1289 }
M internal/usecase/daemon/attachment_parking_test.go +4 -4
@@ -103,7 +103,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
103 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)103 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
104 require.NoError(t, err)104 require.NoError(t, err)
105 oldToken := ac.resumeToken105 oldToken := ac.resumeToken
106- oldGeneration := ac.roleGeneration.Load()106+ oldGeneration := ac.connectionGeneration.Load()
107 d.clientGone(sess, ac, oldTransport, false)107 d.clientGone(sess, ac, oldTransport, false)
108 108
109 newTransport := &closeTrackingTransport{}109 newTransport := &closeTrackingTransport{}
@@ -113,7 +113,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
113 require.Same(t, sess, resumed)113 require.Same(t, sess, resumed)
114 require.Same(t, ac, same)114 require.Same(t, ac, same)
115 require.NotEqual(t, oldToken, ac.resumeToken)115 require.NotEqual(t, oldToken, ac.resumeToken)
116- require.Greater(t, ac.roleGeneration.Load(), oldGeneration)116+ require.Greater(t, ac.connectionGeneration.Load(), oldGeneration)
117 require.True(t, d.commitResumeClaim(ac))117 require.True(t, d.commitResumeClaim(ac))
118 d.mu.Lock()118 d.mu.Lock()
119 _, oldRetained := d.parked[oldToken]119 _, oldRetained := d.parked[oldToken]
@@ -262,7 +262,7 @@ func TestHandleHelloAllowsMultipleAttachments(t *testing.T) {
262 secondTransport := &closeTrackingTransport{}262 secondTransport := &closeTrackingTransport{}
263 _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport)263 _, second, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), secondTransport)
264 require.NoError(t, err)264 require.NoError(t, err)
265- require.Equal(t, attachmentActive, sess.attachmentRole(first))265+ require.Equal(t, true, sess.attachmentRegistered(first))
266- require.Equal(t, attachmentActive, sess.attachmentRole(second))266+ require.Equal(t, true, sess.attachmentRegistered(second))
267 require.NotSame(t, first, second)267 require.NotSame(t, first, second)
268 }268 }
M internal/usecase/daemon/attachment_registry.go +14 -0
@@ -159,6 +159,12 @@ func (c *sessionCore) snapshotAttachmentsLocked() []*attachedClient {
159 for ac := range c.attachments {159 for ac := range c.attachments {
160 out = append(out, ac)160 out = append(out, ac)
161 }161 }
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+ })
162 return out168 return out
163 }169 }
164 170
@@ -199,6 +205,14 @@ func attachmentRegistered(entry attachmentSession, ac *attachedClient) bool {
199 return attachmentRegisteredLocked(entry, ac)205 return attachmentRegisteredLocked(entry, ac)
200 }206 }
201 207
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+
202 func (s *session) attachmentViewsTabLocked(tb *tab) bool {216 func (s *session) attachmentViewsTabLocked(tb *tab) bool {
203 if s == nil || tb == nil {217 if s == nil || tb == nil {
204 return false218 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 {
23 source attachmentSession23 source attachmentSession
24 target attachmentSession24 target attachmentSession
25 next *attachedClient25 next *attachedClient
26- expectedRole attachmentRole
27- targetRole attachmentRole
28 expectedTransport transportSnapshot26 expectedTransport transportSnapshot
29- sourceToken *attachmentRoleToken27+ sourceToken *attachmentConnectionToken
30 action string28 action string
31 activateTargetTab bool29 activateTargetTab bool
32 targetTabIndex int30 targetTabIndex int
33 copySourceEnvironment bool31 copySourceEnvironment bool
34- // preserveRole commits navigation state without replacing attachment32+ // preserveAttachment commits attachment-local navigation state without
35- // ownership. It is used for same-session picker selections after the exact33+ // changing session membership. The initiating connection token remains the
36- // initiating capability has been frozen and revalidated.34+ // exact authority for this mutation.
37- preserveRole bool35+ preserveAttachment bool
38- expectedSourceTab *tab36+ expectedSourceTab *tab
39 // transferExpectedSourceTab permits an installed visible floating pane only37 // transferExpectedSourceTab permits an installed visible floating pane only
40- // when the caller atomically transfers this exact tab under its own tab and38+ // when the caller atomically transfers this exact tab under its fences.
41- // floating-generation fences. Ordinary navigation still rejects visibility.39+ transferExpectedSourceTab bool
42- transferExpectedSourceTab bool40+ createTargetLocked func() (*session, error)
43- createTargetLocked func() (*session, error)41+ ready bool
44- ready bool42+ expectedTargetLifecycle *attachmentLifecycleFence
45- expectedTargetCurrent *attachedClient43+ preflighted bool
46- expectedTargetTransport transportSnapshot44+ attachmentEffectsFrozen bool
47- expectedTargetLifecycle *attachmentLifecycleFence
48- preflighted bool
49- roleEffectsFrozen bool
50- expectedTargetTransportInterrupted bool
51 }45 }
52 46
53-func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source attachmentSession, req attachmentTransitionRequest) bool {47+func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source attachmentSession, req attachmentTransitionRequest) bool {
54- return token.sess == source && token.ac == req.next && token.role == req.expectedRole &&48+ return token.sess == source && token.ac == req.next &&
49+ token.generation == req.next.connectionGeneration.Load() &&
55 token.transport.transport == req.expectedTransport.transport &&50 token.transport.transport == req.expectedTransport.transport &&
56 token.transport.incarnation == req.expectedTransport.incarnation51 token.transport.incarnation == req.expectedTransport.incarnation
57 }52 }
58 53
59-// transitionSourceTokenCurrentLocked requires source.mu and, for an active54+// transitionSourceTokenCurrentLocked requires source.core().mu and, when a
60-// source, sourceCoordinator.mu. This is the canonical handoff preflight: every55+// lease exists, sourceCoordinator.mu. It is the canonical exact-connection
61-// client-originated navigation, delete, or create intent is bound to one exact56+// handoff check for client-originated navigation and lifecycle mutations.
62-// role generation, transport incarnation, and coordinator lease.57+func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
63-func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {58+ if token.sess != source || token.ac != req.next ||
64- if token.sess != source || token.ac != req.next || token.role != req.expectedRole ||59+ token.generation != req.next.connectionGeneration.Load() ||
65- token.generation != req.next.roleGeneration.Load() || attachmentSessionRoleLocked(source, req.next) != token.role ||60+ token.sess == nil || token.ac.currentAttachmentSession() != source ||
66- req.next.currentAttachmentSession() != source || !req.next.transportSnapshotCurrent(token.transport) {61+ !attachmentRegisteredLocked(source, req.next) ||
62+ !req.next.transportSnapshotCurrent(token.transport) {
67 return false63 return false
68 }64 }
69- if token.role != attachmentActive {65+ if token.lease == nil {
70 return true66 return true
71 }67 }
72- return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next &&68+ return sourceCoordinator != nil && token.lease.attachment == req.next &&
73 sourceCoordinator.leaseCurrentLocked(token.lease, true)69 sourceCoordinator.leaseCurrentLocked(token.lease, true)
74 }70 }
75 71
76 type attachmentTransitionResult struct {72 type attachmentTransitionResult struct {
77- published attachmentRoleToken73+ published attachmentConnectionToken
78- displaced attachmentRoleToken
79 cleanups []renderLifecycleCleanup74 cleanups []renderLifecycleCleanup
80 }75 }
81 76
82 type attachmentTransitionParticipants struct {77 type attachmentTransitionParticipants struct {
83 clients []*attachedClient78 clients []*attachedClient
84- interrupts []roleTransportInterrupt79+ interrupts []attachmentTransportInterrupt
85 }80 }
86 81
87-// snapshotAttachmentTransition discovers the exact participants while d.mu is82+// snapshotAttachmentTransition discovers the exact connection participant
88-// held. It releases every architecture lock before any role effect is ended or83+// while d.mu is held, then releases architecture locks before effect gates are
89-// any gate is frozen and drained.84+// frozen and drained. Existing target attachments are never displaced.
90 func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionRequest, attachmentTransitionParticipants, error) {85 func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionRequest, attachmentTransitionParticipants, error) {
91 d.mu.Lock()86 d.mu.Lock()
92 defer d.mu.Unlock()87 defer d.mu.Unlock()
@@ -107,48 +102,41 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
107 d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target {102 d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target {
108 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition103 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
109 }104 }
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
114 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {105 if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
115 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition106 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
116 }107 }
117 req.preflighted = true108 req.preflighted = true
118- participants := attachmentTransitionParticipants{clients: []*attachedClient{req.next}}109+ return req, attachmentTransitionParticipants{clients: []*attachedClient{req.next}}, nil
119- // Every attachment joins without displacing another connection.
120- return req, participants, nil
121 }110 }
122 111
123-// freezeAttachmentTransition drains the snapshotted participants without any112+// freezeAttachmentTransition drains the snapshotted connection without any
124-// architecture lock held. The returned guard remains frozen through atomic113+// architecture lock held. The returned guard remains frozen through publication.
125-// publication and must be released by the caller.114+func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenAttachmentEffectGates, bool, error) {
126-func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenRoleEffectGates, bool, error) {115+ if d.afterAttachmentEffectParticipantsSnapshotted != nil {
127- if d.afterRoleEffectParticipantsSnapshotted != nil {116+ d.afterAttachmentEffectParticipantsSnapshotted(req.action, participants.clients)
128- d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients)
129 }117 }
130- frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, afterFrozen: func(ac *attachedClient) {118+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, afterFrozen: func(ac *attachedClient) {
131- if d.afterRoleEffectGateFrozen != nil {119+ if d.afterAttachmentEffectGateFrozen != nil {
132- d.afterRoleEffectGateFrozen(req.action, ac)120+ d.afterAttachmentEffectGateFrozen(req.action, ac)
133 }121 }
134 }}, participants.clients...)122 }}, participants.clients...)
135 if !frozen.acquired || !frozen.drained {123 if !frozen.acquired || !frozen.drained {
136 return frozen, false, errAttachmentTransition124 return frozen, false, errAttachmentTransition
137 }125 }
138- interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport)126+ interrupted := frozen.interrupted(nil, transportSnapshot{})
139- if d.afterRoleEffectsFrozen != nil {127+ if d.afterAttachmentEffectsFrozen != nil {
140- d.afterRoleEffectsFrozen()128+ d.afterAttachmentEffectsFrozen()
141 }129 }
142 return frozen, interrupted, nil130 return frozen, interrupted, nil
143 }131 }
144 132
145 // publishAttachmentTransition revalidates the frozen source, creates an133 // 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.
147 func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) {135 func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
148 d.mu.Lock()136 d.mu.Lock()
149 defer d.mu.Unlock()137 defer d.mu.Unlock()
150 138
151- req.roleEffectsFrozen = true139+ req.attachmentEffectsFrozen = true
152 if req.target == nil {140 if req.target == nil {
153 if !d.transitionSourcePreflightLocked(req) {141 if !d.transitionSourcePreflightLocked(req) {
154 return attachmentTransitionResult{}, errAttachmentTransition142 return attachmentTransitionResult{}, errAttachmentTransition
@@ -158,7 +146,6 @@ func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (a
158 if err != nil {146 if err != nil {
159 return attachmentTransitionResult{}, err147 return attachmentTransitionResult{}, err
160 }148 }
161- req.expectedTargetCurrent = nil
162 }149 }
163 return d.transitionAttachmentLocked(req)150 return d.transitionAttachmentLocked(req)
164 }151 }
@@ -169,24 +156,20 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
169 return attachmentTransitionResult{}, err156 return attachmentTransitionResult{}, err
170 }157 }
171 if req.sourceToken != nil {158 if req.sourceToken != nil {
172- d.endActionRoleEffect(req.sourceToken.effect, req.action)159+ d.endActionAttachmentEffect(req.sourceToken.effect, req.action)
173 }160 }
174 161
175- frozen, interrupted, err := d.freezeAttachmentTransition(req, participants)162+ frozen, _, err := d.freezeAttachmentTransition(req, participants)
176 defer frozen.unfreeze()163 defer frozen.unfreeze()
177 if err != nil {164 if err != nil {
178 return attachmentTransitionResult{}, err165 return attachmentTransitionResult{}, err
179 }166 }
180- req.expectedTargetTransportInterrupted = interrupted
181 167
182 result, err := d.publishAttachmentTransition(req)168 result, err := d.publishAttachmentTransition(req)
183 if err != nil {169 if err != nil {
184 return result, err170 return result, err
185 }171 }
186- // Warm-proxy timers are external clock operations. Apply them only after172+ d.proxyAttachmentTransitionCommitted(req.source, req.target, req.next, req.preserveAttachment)
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)
190 return result, nil173 return result, nil
191 }174 }
192 175
M internal/usecase/daemon/attachment_transition_prelocked_test.go +16 -17
@@ -23,23 +23,22 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure
23 d.sessions[source.id] = source23 d.sessions[source.id] = source
24 d.sessions[target.id] = target24 d.sessions[target.id] = target
25 25
26- frozen := freezeRoleEffectGates(next, old)26+ frozen := freezeAttachmentEffectGates(next, old)
27 require.True(t, frozen.acquired)27 require.True(t, frozen.acquired)
28 require.True(t, frozen.drained)28 require.True(t, frozen.drained)
29 defer frozen.unfreeze()29 defer frozen.unfreeze()
30 30
31 req := attachmentTransitionRequest{31 req := attachmentTransitionRequest{
32- source: source,32+ source: source,
33- target: target,33+ target: target,
34- next: next,34+ next: next,
35- expectedRole: attachmentActive,35+
36- targetRole: attachmentActive,36+ expectedTransport: next.transportSnapshot(),
37- expectedTransport: next.transportSnapshot(),37+
38- expectedTargetCurrent: old,38+ preflighted: true,
39- preflighted: true,39+ attachmentEffectsFrozen: true,
40- roleEffectsFrozen: true,40+ activateTargetTab: true,
41- activateTargetTab: true,41+ targetTabIndex: 1,
42- targetTabIndex: 1,
43 }42 }
44 43
45 d.mu.Lock()44 d.mu.Lock()
@@ -52,11 +51,11 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure
52 51
53 require.ErrorIs(t, err, errAttachmentTransition)52 require.ErrorIs(t, err, errAttachmentTransition)
54 require.Nil(t, publication)53 require.Nil(t, publication)
55- require.Equal(t, attachmentActive, source.attachmentRole(next))54+ require.Equal(t, true, source.attachmentRegistered(next))
56- require.Equal(t, attachmentDetached, target.attachmentRole(next))55+ require.Equal(t, false, target.attachmentRegistered(next))
57- require.Equal(t, attachmentActive, target.attachmentRole(old))56+ require.Equal(t, true, target.attachmentRegistered(old))
58 require.Same(t, source, next.currentSession())57 require.Same(t, source, next.currentSession())
59- require.Equal(t, uint64(0), next.roleGeneration.Load())58+ require.Equal(t, uint64(0), next.connectionGeneration.Load())
60- require.Equal(t, uint64(0), old.roleGeneration.Load())59+ require.Equal(t, uint64(0), old.connectionGeneration.Load())
61 require.Equal(t, 0, testAttachmentTabIndex(target))60 require.Equal(t, 0, testAttachmentTabIndex(target))
62 }61 }
M internal/usecase/daemon/attachment_transition_publish.go +68 -111
@@ -1,14 +1,10 @@
1 package daemon1 package daemon
2 2
3-import (3+import "github.com/bnema/vev/internal/domain"
4- "slices"
5 4
6- "github.com/bnema/vev/internal/domain"5+// transitionSourcePreflightLocked validates an initiating connection while its
7-)6+// effect gate is frozen and d.mu is held. No target is created before this
8-7+// succeeds.
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.
12 func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {8 func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {
13 if req.sourceToken == nil || req.source == nil || req.source.core() == nil || d.sessions[req.source.core().id] != req.source {9 if req.sourceToken == nil || req.source == nil || req.source.core() == nil || d.sessions[req.source.core().id] != req.source {
14 return false10 return false
@@ -18,19 +14,18 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest
18 req.source.core().mu.Lock()14 req.source.core().mu.Lock()
19 defer req.source.core().mu.Unlock()15 defer req.source.core().mu.Unlock()
20 coordinator := req.source.core().coordinator.Load()16 coordinator := req.source.core().coordinator.Load()
21- if coordinator == nil {17+ if coordinator != nil {
22- return false18+ coordinator.mu.Lock()
19+ defer coordinator.mu.Unlock()
23 }20 }
24- coordinator.mu.Lock()
25- defer coordinator.mu.Unlock()
26 if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {21 if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {
27 return false22 return false
28 }23 }
29 return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)24 return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)
30 }25 }
31 26
32-func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {27+func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool {
33- if token.sess == nil || token.ac == nil || token.role != attachmentActive {28+ if token.sess == nil || token.ac == nil {
34 return false29 return false
35 }30 }
36 d.mu.Lock()31 d.mu.Lock()
@@ -43,15 +38,11 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool {
43 token.sess.core().mu.Lock()38 token.sess.core().mu.Lock()
44 defer token.sess.core().mu.Unlock()39 defer token.sess.core().mu.Unlock()
45 coordinator := token.sess.core().coordinator.Load()40 coordinator := token.sess.core().coordinator.Load()
46- if coordinator == nil {41+ if coordinator != nil {
47- return false42+ coordinator.mu.Lock()
48- }43+ defer coordinator.mu.Unlock()
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,
54 }44 }
45+ req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport}
55 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)46 return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
56 }47 }
57 48
@@ -64,7 +55,7 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect
64 return true55 return true
65 }56 }
66 local, ok := localSession(source)57 local, ok := localSession(source)
67- if !ok || !slices.Contains(local.tabs, expected) {58+ if !ok || !containsTab(local.tabs, expected) {
68 return false59 return false
69 }60 }
70 expected.mu.Lock()61 expected.mu.Lock()
@@ -72,23 +63,28 @@ func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expect
72 return transferExpected || expected.floating.state != floatingVisible63 return transferExpected || expected.floating.state != floatingVisible
73 }64 }
74 65
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+
75 // transitionAttachmentLocked is the publication half of transitionAttachment.75 // 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.
77 func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {77 func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
78 d.notices.routingMu.Lock()78 d.notices.routingMu.Lock()
79 defer d.notices.routingMu.Unlock()79 defer d.notices.routingMu.Unlock()
80 return d.transitionAttachmentRoutedLocked(req)80 return d.transitionAttachmentRoutedLocked(req)
81 }81 }
82 82
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.
86 type attachmentPublication struct {83 type attachmentPublication struct {
87 req attachmentTransitionRequest84 req attachmentTransitionRequest
88 source attachmentSession85 source attachmentSession
89- old *attachedClient
90- sourceCoordinator *renderCoordinator
91 targetCoordinator *renderCoordinator86 targetCoordinator *renderCoordinator
87+ sourceCoordinator *renderCoordinator
92 releaseCoordinators func()88 releaseCoordinators func()
93 nextGeneration uint6489 nextGeneration uint64
94 }90 }
@@ -115,11 +111,9 @@ func attachmentLifecycleCurrentLocked(entry attachmentSession, fence *attachment
115 domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID111 domain.TabStableID(target.tabs[fence.tabIndex].stableID) == fence.tabID
116 }112 }
117 113
118-// validateAttachmentTransitionPrelocked performs every fallible transition114+// validateAttachmentTransitionPrelocked performs every fallible check before
119-// check without acquiring d.mu, notices.routingMu, or either session lock.115+// the non-failing membership and lease publication. Caller holds d.mu,
120-// The caller holds those locks in that order, with session locks ordered by ID.116+// notices.routingMu, and ordered source/target session locks.
121-// On success the returned validation retains the ordered coordinator locks
122-// through publishAttachmentTransitionPrelocked.
123 func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionRequest) (*attachmentPublication, error) {117 func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionRequest) (*attachmentPublication, error) {
124 source := req.source118 source := req.source
125 if source == nil {119 if source == nil {
@@ -130,51 +124,43 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
130 }124 }
131 sourceCore := source.core()125 sourceCore := source.core()
132 targetCore := req.target.core()126 targetCore := req.target.core()
133- if sourceCore == nil || targetCore == nil ||127+ if sourceCore == nil || targetCore == nil || !req.preflighted || !req.attachmentEffectsFrozen ||
134- !req.preflighted || !req.roleEffectsFrozen ||128+ req.next == nil || d.closing || d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target ||
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 ||
138 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||129 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
139- req.preserveRole && req.sourceToken == nil {130+ req.preserveAttachment && req.sourceToken == nil {
140 return nil, errAttachmentTransition131 return nil, errAttachmentTransition
141 }132 }
142 133
143- var targetCoordinator *renderCoordinator
144- if req.targetRole == attachmentActive {
145- targetCoordinator = d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
146- }
147 sourceCoordinator := sourceCore.coordinator.Load()134 sourceCoordinator := sourceCore.coordinator.Load()
148- if attachmentSessionRoleLocked(source, req.next) != req.expectedRole ||135+ targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
149- !req.next.transportSnapshotCurrent(req.expectedTransport) {136+ if req.sourceToken != nil && !transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) {
150 return nil, errAttachmentTransition137 return nil, errAttachmentTransition
151 }138 }
152-139+ if source != req.target && !attachmentRegisteredLocked(source, req.next) {
153- old := (*attachedClient)(nil)140+ return nil, errAttachmentTransition
154- if req.expectedTargetCurrent != nil {141+ }
142+ if !req.preserveAttachment && source != req.target && attachmentRegisteredLocked(req.target, req.next) {
155 return nil, errAttachmentTransition143 return nil, errAttachmentTransition
156 }144 }
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+
157 publication := &attachmentPublication{154 publication := &attachmentPublication{
158- req: req, source: source, old: old,155+ req: req, source: source,
159 sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,156 sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator,
160 releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),157 releaseCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator),
161 }158 }
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- }
173 return publication, nil159 return publication, nil
174 }160 }
175 161
176-// applyTargetStateLocked commits navigation metadata before role ownership, as162+// applyTargetStateLocked commits attachment-local navigation metadata and
177-// part of the same locked publication. It reports role-preserving transitions.163+// shared environment state as part of the same publication.
178 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {164 func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool {
179 req := publication.req165 req := publication.req
180 if req.activateTargetTab {166 if req.activateTargetTab {
@@ -190,70 +176,52 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool
190 target.env = copyEnvironment(source.env)176 target.env = copyEnvironment(source.env)
191 }177 }
192 }178 }
193- return req.preserveRole179+ return req.preserveAttachment
194 }180 }
195 181
196-// publishAttachmentOwnershipLocked atomically changes session membership,182+// publishAttachmentOwnershipLocked adds/removes only the exact attachment.
197-// generations, and currentSession while all role gates remain frozen.183+// Existing target attachments and their independent views/transports remain
184+// untouched.
198 func publishAttachmentOwnershipLocked(publication *attachmentPublication) {185 func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
199 req := publication.req186 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)
203 if publication.source != req.target {187 if publication.source != req.target {
204 unregisterAttachmentSessionLocked(publication.source, req.next)188 unregisterAttachmentSessionLocked(publication.source, req.next)
205 }189 }
206- publication.nextGeneration = req.next.roleGeneration.Add(1)190+ registerAttachmentSessionLocked(req.target, req.next)
191+ publication.nextGeneration = req.next.connectionGeneration.Add(1)
207 req.next.setSession(req.target)192 req.next.setSession(req.target)
208 }193 }
209 194
210-// buildAttachmentPostcommitPlanLocked binds coordinator lifecycle cleanup and
211-// constructs the exact published tokens after ownership has committed.
212 func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) attachmentTransitionResult {195 func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) attachmentTransitionResult {
213 req := publication.req196 req := publication.req
214 result := attachmentTransitionResult{}197 result := attachmentTransitionResult{}
215- if publication.source != req.target && req.expectedRole == attachmentActive && publication.sourceCoordinator != nil {198+ if publication.source != req.target && publication.sourceCoordinator != nil {
216 result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next))199 result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next))
217 }200 }
218 var lease *attachmentLease201 var lease *attachmentLease
219- if publication.targetCoordinator != nil {202+ if publication.targetCoordinator != nil && !req.preserveAttachment {
220- var cleanup renderLifecycleCleanup
221 if publication.targetCoordinator.lease == nil {203 if publication.targetCoordinator.lease == nil {
204+ var cleanup renderLifecycleCleanup
222 cleanup, lease = publication.targetCoordinator.beginReplaceLocked(nil, req.next, req.ready)205 cleanup, lease = publication.targetCoordinator.beginReplaceLocked(nil, req.next, req.ready)
223 result.cleanups = append(result.cleanups, cleanup)206 result.cleanups = append(result.cleanups, cleanup)
224- } else {207+ } else if publication.targetCoordinator.lease.active && publication.targetCoordinator.lease.attachment == req.next {
225- // Secondary attachments use membership admission; the coordinator's208+ lease = publication.targetCoordinator.lease
226- // optional primary lease is not reused by another attachment.
227- lease = nil
228 }209 }
229 }210 }
230- result.published = attachmentRoleToken{211+ result.published = attachmentConnectionToken{
231- sess: req.target,212+ sess: req.target, ac: req.next,
232- ac: req.next,
233- role: req.targetRole,
234 generation: publication.nextGeneration,213 generation: publication.nextGeneration,
235- transport: req.expectedTransport,214+ transport: req.expectedTransport, lease: lease,
236- lease: lease,215+ rebase: publication.source != req.target,
237- rebase: publication.source != req.target && req.expectedRole == attachmentActive,
238 }216 }
239-217+ if req.attachmentEffectsFrozen {
240- // Membership, currentSession, lease, generation, and both exact admission218+ req.next.publishFrozenAttachmentCapability(result.published)
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- }
248 }219 }
249 return result220 return result
250 }221 }
251 222
252-// publishAttachmentTransitionPrelocked performs only non-failing metadata,
253-// ownership, generation, and coordinator-lease publication. The caller retains
254-// every lock required by validateAttachmentTransitionPrelocked.
255 func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {223 func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {
256- if publication.req.preserveRole {224+ if publication.req.preserveAttachment {
257 d.applyTargetStateLocked(publication)225 d.applyTargetStateLocked(publication)
258 return attachmentTransitionResult{published: *publication.req.sourceToken}226 return attachmentTransitionResult{published: *publication.req.sourceToken}
259 }227 }
@@ -262,9 +230,6 @@ func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPub
262 return buildAttachmentPostcommitPlanLocked(publication)230 return buildAttachmentPostcommitPlanLocked(publication)
263 }231 }
264 232
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.
268 func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {233 func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
269 source := req.source234 source := req.source
270 if source == nil {235 if source == nil {
@@ -284,15 +249,7 @@ func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionReques
284 return d.publishAttachmentTransitionPrelocked(publication), nil249 return d.publishAttachmentTransitionPrelocked(publication), nil
285 }250 }
286 251
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-
294 // lockAttachmentSessions gives every two-session transition one stable order.252 // lockAttachmentSessions gives every two-session transition one stable order.
295-// Session IDs are immutable and unique while their lifecycles are registered.
296 func lockAttachmentSessions(a, b attachmentSession) func() {253 func lockAttachmentSessions(a, b attachmentSession) func() {
297 aCore := attachmentSessionCore(a)254 aCore := attachmentSessionCore(a)
298 bCore := attachmentSessionCore(b)255 bCore := attachmentSessionCore(b)
@@ -316,7 +273,7 @@ func lockAttachmentSessions(a, b attachmentSession) func() {
316 }273 }
317 274
318 // lockAttachmentCoordinators follows the same immutable session-ID order as275 // 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.
320 func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() {277 func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() {
321 if aCoordinator == nil && bCoordinator == nil {278 if aCoordinator == nil && bCoordinator == nil {
322 return func() {}279 return func() {}
M internal/usecase/daemon/attention.go +3 -3
@@ -50,10 +50,10 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
50 // happened — it also covers "no target exists", which is routine and not an50 // happened — it also covers "no target exists", which is routine and not an
51 // error. Only a failure to reach a target that does exist is a genuine error.51 // error. Only a failure to reach a target that does exist is a genuine error.
52 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {52 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
53- return d.jumpAttentionForRole(sess, ac, attachmentRoleToken{})53+ return d.jumpAttentionForAttachment(sess, ac, attachmentConnectionToken{})
54 }54 }
55 55
56-func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token attachmentRoleToken) error {56+func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, token attachmentConnectionToken) error {
57 if sess == nil || ac == nil {57 if sess == nil || ac == nil {
58 return nil58 return nil
59 }59 }
@@ -73,7 +73,7 @@ func (d *Daemon) jumpAttentionForRole(sess *session, ac *attachedClient, token a
73 if token.ac == nil {73 if token.ac == nil {
74 return d.switchToTarget(sess, ac, pickerTarget)74 return d.switchToTarget(sess, ac, pickerTarget)
75 }75 }
76- return d.switchActiveTargetForRole(token, pickerTarget)76+ return d.switchActiveTargetForAttachment(token, pickerTarget)
77 }77 }
78 78
79 func oldestAttentionTab(sess *session) (int, bool) {79 func oldestAttentionTab(sess *session) (int, bool) {
M internal/usecase/daemon/client.go +34 -35
@@ -42,14 +42,14 @@ type attachedClient struct {
42 overlays *overlayRuntime42 overlays *overlayRuntime
43 overlayOnce sync.Once43 overlayOnce sync.Once
44 clientID [16]byte44 clientID [16]byte
45- // roleGeneration is the wire-facing capability generation. roleEffects is45+ // connectionGeneration is the wire-facing capability generation. attachmentEffects is
46- // the linearization gate for every role-bound observable operation. A role46+ // the linearization gate for every attachment-bound observable operation. A role
47 // transition freezes and drains this gate before changing either generation47 // transition freezes and drains this gate before changing either generation
48 // or the session/coordinator registries.48 // or the session/coordinator registries.
49- roleGeneration atomic.Uint6449+ connectionGeneration atomic.Uint64
50- roleEffects roleEffectGate50+ attachmentEffects attachmentEffectGate
51- resumeCapable bool51+ resumeCapable bool
52- resumeToken uint6452+ resumeToken uint64
53 // resumeClaimToken is non-zero only between a parked resume claim and its53 // resumeClaimToken is non-zero only between a parked resume claim and its
54 // successful Welcome. It lets a failed pre-claim handshake restore the old54 // successful Welcome. It lets a failed pre-claim handshake restore the old
55 // credential instead of consuming it.55 // credential instead of consuming it.
@@ -352,7 +352,7 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po
352 // attachment's current transport incarnation and admits ticket's interruptible352 // attachment's current transport incarnation and admits ticket's interruptible
353 // transport send. The caller must already hold ac.sendMu and, on success, owns353 // transport send. The caller must already hold ac.sendMu and, on success, owns
354 // both the send and its matching ticket.endTransportSend.354 // 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 {
356 if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {356 if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {
357 return errTransportReplaced357 return errTransportReplaced
358 }358 }
@@ -362,10 +362,10 @@ func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSna
362 return nil362 return nil
363 }363 }
364 364
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 {
366 ac.sendMu.Lock()366 ac.sendMu.Lock()
367 defer ac.sendMu.Unlock()367 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,
369 // including a replaced transport, as a lost attachment transition.369 // including a replaced transport, as a lost attachment transition.
370 if ticket == nil {370 if ticket == nil {
371 return errAttachmentTransition371 return errAttachmentTransition
@@ -523,15 +523,14 @@ type attachClientOptions struct {
523 proxied bool523 proxied bool
524 }524 }
525 525
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) {
527 d.mu.Lock()527 d.mu.Lock()
528 ac := d.prepareAttachedClientLocked(tr, sz, opts)528 ac := d.prepareAttachedClientLocked(tr, sz, opts)
529 d.mu.Unlock()529 d.mu.Unlock()
530 result, err := d.transitionAttachment(attachmentTransitionRequest{530 result, err := d.transitionAttachment(attachmentTransitionRequest{
531- target: sess,531+ target: sess,
532- next: ac,532+ next: ac,
533- expectedRole: attachmentDetached,533+
534- targetRole: attachmentActive,
535 expectedTransport: ac.transportSnapshot(),534 expectedTransport: ac.transportSnapshot(),
536 ready: false,535 ready: false,
537 })536 })
@@ -539,11 +538,11 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
539 if tr != nil {538 if tr != nil {
540 _ = tr.Close()539 _ = tr.Close()
541 }540 }
542- return nil, nil, err541+ return nil, err
543 }542 }
544 d.finishAttachedClient(sess, ac, opts)543 d.finishAttachedClient(sess, ac, opts)
545 d.deferAttachmentTransitionCleanups(result)544 d.deferAttachmentTransitionCleanups(result)
546- return ac, result.displaced.ac, nil545+ return ac, nil
547 }546 }
548 547
549 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {548 func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
@@ -553,7 +552,7 @@ func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts at
553 }552 }
554 553
555 // prepareAttachedClientLocked allocates one detached attachment. Caller holds554 // prepareAttachedClientLocked allocates one detached attachment. Caller holds
556-// d.mu only to allocate its resume token; role publication happens later after555+// d.mu only to allocate its resume token; attachment publication happens later after
557 // the caller releases every architecture lock.556 // the caller releases every architecture lock.
558 func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient {557 func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient {
559 resumeToken := uint64(0)558 resumeToken := uint64(0)
@@ -664,20 +663,20 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) {
664 }663 }
665 664
666 // firstPaintForTransition is the only active post-transition paint entry665 // firstPaintForTransition is the only active post-transition paint entry
667-// point. It rejects a stale role capability before rebasing output and carries666+// point. It rejects a stale attachment capability before rebasing output and carries
668 // the exact coordinator lease through every resize and render effect.667 // the exact coordinator lease through every resize and render effect.
669-func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {668+func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
670- if !token.activeCurrent() {669+ if !token.attachmentCurrent() {
671 return false670 return false
672 }671 }
673- ticket, admitted := token.ac.beginRoleEffect(token)672+ ticket, admitted := token.ac.beginAttachmentEffect(token)
674 if !admitted {673 if !admitted {
675 return false674 return false
676 }675 }
677 token.effect = ticket676 token.effect = ticket
678- defer token.endRoleEffect()677+ defer token.endAttachmentEffect()
679- if d.afterRoleEffectAdmitted != nil {678+ if d.afterAttachmentEffectAdmitted != nil {
680- d.afterRoleEffectAdmitted(token)679+ d.afterAttachmentEffectAdmitted(token)
681 }680 }
682 if token.rebase {681 if token.rebase {
683 if d.beforeFirstPaintSendWait != nil {682 if d.beforeFirstPaintSendWait != nil {
@@ -708,7 +707,7 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
708 // firstPaint guarantees the freshly attached client sees the full screen: if707 // firstPaint guarantees the freshly attached client sees the full screen: if
709 // the tab size differs from the client's it resizes first, then immediately708 // the tab size differs from the client's it resizes first, then immediately
710 // emits a full redraw. It is retained for direct test/headless setup; active709 // 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.
712 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {711 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
713 d.firstPaintWithLease(sess, ac, clientSize, nil)712 d.firstPaintWithLease(sess, ac, clientSize, nil)
714 }713 }
@@ -775,17 +774,17 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
775 d.invalidateRender(sess, ac, true, "client.go")774 d.invalidateRender(sess, ac, true, "client.go")
776 }775 }
777 776
778-func (d *Daemon) applyThemeForRole(token attachmentRoleToken, msg ports.Theme) bool {777+func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) bool {
779- if !token.activeEffect() {778+ if !token.attachmentEffectCurrent() {
780 return false779 return false
781 }780 }
782 clientTheme := themeFromMessage(msg)781 clientTheme := themeFromMessage(msg)
783 token.ac.setClientTheme(clientTheme)782 token.ac.setClientTheme(clientTheme)
784 sess, ok := localSession(token.sess)783 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) {
786 return false785 return false
787 }786 }
788- if !token.activeEffect() {787+ if !token.attachmentEffectCurrent() {
789 return false788 return false
790 }789 }
791 if rc := token.sess.core().coordinator.Load(); rc != nil {790 if rc := token.sess.core().coordinator.Load(); rc != nil {
@@ -811,23 +810,23 @@ func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice po
811 token := sess.attachmentToken(ac, tr)810 token := sess.attachmentToken(ac, tr)
812 if token.lease == nil {811 if token.lease == nil {
813 // Direct test/headless callers retain pointer-based routing. Production812 // 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.
815 d.handleClientNoticeDirect(sess, ac, notice)814 d.handleClientNoticeDirect(sess, ac, notice)
816 return815 return
817 }816 }
818- d.handleClientNoticeForRole(token, notice)817+ d.handleClientNoticeForAttachment(token, notice)
819 }818 }
820 819
821-func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice ports.ClientNotice) {820+func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken, notice ports.ClientNotice) {
822 d.notices.routingMu.Lock()821 d.notices.routingMu.Lock()
823- if !token.activeEffect() {822+ if !token.attachmentEffectCurrent() {
824 d.notices.routingMu.Unlock()823 d.notices.routingMu.Unlock()
825 return824 return
826 }825 }
827 if d.notices.beforeClientNoticeMutation != nil {826 if d.notices.beforeClientNoticeMutation != nil {
828 d.notices.beforeClientNoticeMutation()827 d.notices.beforeClientNoticeMutation()
829 }828 }
830- if !token.activeEffect() {829+ if !token.attachmentEffectCurrent() {
831 d.notices.routingMu.Unlock()830 d.notices.routingMu.Unlock()
832 return831 return
833 }832 }
@@ -838,7 +837,7 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por
838 }837 }
839 repaint := d.mutateClientNotice(sess, token.ac, notice)838 repaint := d.mutateClientNotice(sess, token.ac, notice)
840 d.notices.routingMu.Unlock()839 d.notices.routingMu.Unlock()
841- if repaint && token.activeEffect() {840+ if repaint && token.attachmentEffectCurrent() {
842 d.repaintForNotice(token.ac)841 d.repaintForNotice(token.ac)
843 }842 }
844 }843 }
M internal/usecase/daemon/client_frame_routing.go +43 -43
@@ -8,31 +8,31 @@ import (
8 "github.com/bnema/vev/internal/usecase/command"8 "github.com/bnema/vev/internal/usecase/command"
9 )9 )
10 10
11-const connectionRoleAttempts = 811+const connectionSnapshotAttempts = 8
12 12
13-// currentConnectionRole retries the lock-free session-to-role snapshot when a13+// currentAttachmentConnection retries the lock-free session-to-role snapshot when a
14-// handoff lands between those reads. The role gate still performs the final14+// handoff lands between those reads. The attachment gate still performs the final
15 // mutation admission after this function returns.15 // mutation admission after this function returns.
16-func (d *Daemon) currentConnectionRole(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentRoleToken, bool) {16+func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (attachmentSession, attachmentConnectionToken, bool) {
17- for range connectionRoleAttempts {17+ for range connectionSnapshotAttempts {
18 if !ac.currentTransportIs(tr) {18 if !ac.currentTransportIs(tr) {
19 break19 break
20 }20 }
21 sess := ac.currentAttachmentSession()21 sess := ac.currentAttachmentSession()
22 if sess == nil {22 if sess == nil {
23- return nil, attachmentRoleToken{}, false23+ return nil, attachmentConnectionToken{}, false
24 }24 }
25 if d.afterConnectionSessionSnapshot != nil {25 if d.afterConnectionSessionSnapshot != nil {
26 d.afterConnectionSessionSnapshot(sess)26 d.afterConnectionSessionSnapshot(sess)
27 }27 }
28 token := attachmentToken(sess, ac, tr)28 token := attachmentToken(sess, ac, tr)
29- if token.role == attachmentDetached || ac.currentAttachmentSession() != sess {29+ if token.ac == nil || ac.currentAttachmentSession() != sess {
30 runtime.Gosched()30 runtime.Gosched()
31 continue31 continue
32 }32 }
33 return sess, token, true33 return sess, token, true
34 }34 }
35- return nil, attachmentRoleToken{}, false35+ return nil, attachmentConnectionToken{}, false
36 }36 }
37 37
38 // runConnLoop is the per-connection input router: it pumps client messages38 // runConnLoop is the per-connection input router: it pumps client messages
@@ -49,8 +49,8 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
49 }49 }
50 f, err := tr.Recv()50 f, err := tr.Recv()
51 if err != nil {51 if err != nil {
52- for range connectionRoleAttempts {52+ for range connectionSnapshotAttempts {
53- sess, _, ok := d.currentConnectionRole(ac, tr)53+ sess, _, ok := d.currentAttachmentConnection(ac, tr)
54 if !ok {54 if !ok {
55 return55 return
56 }56 }
@@ -68,40 +68,40 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
68 }68 }
69 return69 return
70 }70 }
71- _, token, ok := d.currentConnectionRole(ac, tr)71+ _, token, ok := d.currentAttachmentConnection(ac, tr)
72 if !ok {72 if !ok {
73 return73 return
74 }74 }
75- if token.role == attachmentActive && d.handleActiveClientFrame(token, f) {75+ if d.handleAttachmentClientFrame(token, f) {
76 return76 return
77 }77 }
78 }78 }
79 }79 }
80 80
81-// handleActiveClientFrame owns the attached-client protocol.81+// handleAttachmentClientFrame owns the attached-client protocol.
82-func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Frame) bool {82+func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f ports.Frame) bool {
83- if !token.activeEffect() {83+ if !token.attachmentEffectCurrent() {
84 return false84 return false
85 }85 }
86- if d.afterActiveFrameDispatch != nil {86+ if d.afterAttachmentFrameDispatch != nil {
87- d.afterActiveFrameDispatch(token)87+ d.afterAttachmentFrameDispatch(token)
88 }88 }
89- ticket, admitted := token.ac.beginRoleEffect(token)89+ ticket, admitted := token.ac.beginAttachmentEffect(token)
90 if !admitted {90 if !admitted {
91 return false91 return false
92 }92 }
93 token.effect = ticket93 token.effect = ticket
94- defer token.endRoleEffect()94+ defer token.endAttachmentEffect()
95- if d.afterRoleEffectAdmitted != nil {95+ if d.afterAttachmentEffectAdmitted != nil {
96- d.afterRoleEffectAdmitted(token)96+ d.afterAttachmentEffectAdmitted(token)
97 }97 }
98 switch f.Type {98 switch f.Type {
99 case ports.MsgInput:99 case ports.MsgInput:
100 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {100 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)
102 }102 }
103 case ports.MsgResize:103 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() {
105 if sess, ok := localSession(token.sess); ok {105 if sess, ok := localSession(token.sess); ok {
106 d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)106 d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)
107 } else if proxy, ok := token.sess.(*proxySession); ok {107 } else if proxy, ok := token.sess.(*proxySession); ok {
@@ -110,30 +110,30 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
110 }110 }
111 case ports.MsgTheme:111 case ports.MsgTheme:
112 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {112 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
113- d.applyThemeForRole(token, th)113+ d.applyThemeForAttachment(token, th)
114 }114 }
115 case ports.MsgImagePush:115 case ports.MsgImagePush:
116 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {116 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
117- d.handleSequencedImagePushForRole(token, ip.InputSeq, ip)117+ d.handleSequencedImagePushForAttachment(token, ip.InputSeq, ip)
118 }118 }
119 case ports.MsgClientNotice:119 case ports.MsgClientNotice:
120 if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {120 if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
121- d.handleClientNoticeForRole(token, notice)121+ d.handleClientNoticeForAttachment(token, notice)
122 } else {122 } else {
123 d.log.Warn("malformed client notice", "err", derr)123 d.log.Warn("malformed client notice", "err", derr)
124 }124 }
125 case ports.MsgDetach:125 case ports.MsgDetach:
126- if token.activeEffect() {126+ if token.attachmentEffectCurrent() {
127- d.clientGoneForRole(token, true)127+ d.clientGoneForAttachment(token, true)
128 return true128 return true
129 }129 }
130 case ports.MsgAck:130 case ports.MsgAck:
131 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {131 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
132- d.ackActiveOutput(token, ack.AckedStateNum)132+ d.ackOutput(token, ack.AckedStateNum)
133 }133 }
134 case ports.MsgOutputResetRequest:134 case ports.MsgOutputResetRequest:
135 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {135 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {
136- d.resetActiveOutput(token)136+ d.resetOutput(token)
137 }137 }
138 case ports.MsgCommand:138 case ports.MsgCommand:
139 if token.ac.proxied {139 if token.ac.proxied {
@@ -142,16 +142,16 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
142 return false142 return false
143 }143 }
144 result := d.executeAttachedCommand(token, request)144 result := d.executeAttachedCommand(token, request)
145- if err := token.sendActiveControl(frameCommandResult(result)); err != nil {145+ if err := token.sendControl(frameCommandResult(result)); err != nil {
146- token.endRoleEffect()146+ token.endAttachmentEffect()
147- d.detachOnRoleSendError(token, token.transport.transport)147+ d.detachOnAttachmentSendError(token, token.transport.transport)
148 return true148 return true
149 }149 }
150 }150 }
151 case ports.MsgPing:151 case ports.MsgPing:
152- if err := token.sendActiveControl(framePong()); err != nil {152+ if err := token.sendControl(framePong()); err != nil {
153- token.endRoleEffect()153+ token.endAttachmentEffect()
154- d.detachOnRoleSendError(token, token.transport.transport)154+ d.detachOnAttachmentSendError(token, token.transport.transport)
155 return true155 return true
156 }156 }
157 default:157 default:
@@ -161,7 +161,7 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram
161 return false161 return false
162 }162 }
163 163
164-func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports.CommandRequest) ports.CommandResult {164+func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request ports.CommandRequest) ports.CommandResult {
165 result := ports.CommandResult{RequestID: request.RequestID}165 result := ports.CommandResult{RequestID: request.RequestID}
166 if request.Version != ports.ProtocolVersion {166 if request.Version != ports.ProtocolVersion {
167 result.Code = ports.ErrInvalidCommandArgs167 result.Code = ports.ErrInvalidCommandArgs
@@ -189,7 +189,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports
189 return result189 return result
190 }190 }
191 sess, ok := localSession(token.sess)191 sess, ok := localSession(token.sess)
192- if !ok || !token.activeEffect() {192+ if !ok || !token.attachmentEffectCurrent() {
193 result.Code = ports.ErrNoSuchTarget193 result.Code = ports.ErrNoSuchTarget
194 result.Text = "attached session is no longer active"194 result.Text = "attached session is no longer active"
195 return result195 return result
@@ -216,7 +216,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentRoleToken, request ports
216 return result216 return result
217 }217 }
218 218
219- // The frame's role token is the target capability. No registry/name lookup is219+ // The frame's attachment token is the target capability. No registry/name lookup is
220 // performed, so the command cannot escape to another remote session.220 // performed, so the command cannot escape to another remote session.
221 sess.dispatchMu.Lock()221 sess.dispatchMu.Lock()
222 err := cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)222 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 {
241 return proxyPaletteCommandOwnership(slug) == proxyPaletteRemote241 return proxyPaletteCommandOwnership(slug) == proxyPaletteRemote
242 }242 }
243 243
244-// resetActiveOutput rebases only the exact proxied role and transport admitted244+// resetOutput rebases only the exact proxied attachment and transport admitted
245 // by the frame router. sendMu serializes the rebase with every output send;245 // by the frame router. sendMu serializes the rebase with every output send;
246 // transport revalidation under that lock rejects a link replaced while waiting.246 // 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 {
248 ac := token.ac248 ac := token.ac
249 if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() {249 if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() {
250 return false250 return false
251 }251 }
252 ac.sendMu.Lock()252 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 {
254 ac.sendMu.Unlock()254 ac.sendMu.Unlock()
255 return false255 return false
256 }256 }
@@ -263,7 +263,7 @@ func (d *Daemon) resetActiveOutput(token attachmentRoleToken) bool {
263 })263 })
264 }264 }
265 265
266-func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool {266+func (d *Daemon) ackOutput(token attachmentConnectionToken, state uint64) bool {
267 ac := token.ac267 ac := token.ac
268 if token.effect == nil || token.effect.ended.Load() {268 if token.effect == nil || token.effect.ended.Load() {
269 return false269 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
28 d.finishClientGone(sess, ac, failed, explicit)28 d.finishClientGone(sess, ac, failed, explicit)
29 }29 }
30 30
31-func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool {31+func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explicit bool) bool {
32 if token.effect == nil {32 if token.effect == nil {
33 return false33 return false
34 }34 }
35 token.effect.bindActionEnd(d, "detach")35 token.effect.bindActionEnd(d, "detach")
36 token.effect.End()36 token.effect.End()
37 if proxy, ok := token.sess.(*proxySession); ok {37 if proxy, ok := token.sess.(*proxySession); ok {
38- if !d.detachIfRoleCurrent(token) {38+ if !d.detachIfAttachmentCurrent(token) {
39 return false39 return false
40 }40 }
41 d.finishProxyClientGone(proxy, token.ac, token.transport.transport, explicit)41 d.finishProxyClientGone(proxy, token.ac, token.transport.transport, explicit)
@@ -50,7 +50,7 @@ func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) boo
50 if !explicit {50 if !explicit {
51 parkingToken = d.markParkingInFlight(sess, token.ac)51 parkingToken = d.markParkingInFlight(sess, token.ac)
52 }52 }
53- if !d.detachIfRoleCurrent(token) {53+ if !d.detachIfAttachmentCurrent(token) {
54 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)54 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
55 return false55 return false
56 }56 }
@@ -143,7 +143,7 @@ func (d *Daemon) detachProxyOnSendError(p *proxySession, ac *attachedClient, fai
143 }143 }
144 144
145 // finishProxyClientGone retires external client ownership after the exact proxy145 // finishProxyClientGone retires external client ownership after the exact proxy
146-// role has been unpublished. Warm-timer publication remains with the caller so146+// attachment has been unpublished. Warm-timer publication remains with the caller so
147 // each detach path can arm once, after membership is empty.147 // each detach path can arm once, after membership is empty.
148 func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) {148 func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, failed ports.Transport, explicit bool) {
149 if rc := p.coordinator.Load(); rc != nil {149 if rc := p.coordinator.Load(); rc != nil {
@@ -160,18 +160,18 @@ func (d *Daemon) finishProxyClientGone(p *proxySession, ac *attachedClient, fail
160 d.log.Warn("detached proxy client", "host", p.key.Host, "session", p.key.Name, "explicit", explicit)160 d.log.Warn("detached proxy client", "host", p.key.Host, "session", p.key.Name, "explicit", explicit)
161 }161 }
162 162
163-func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.Transport) {163+func (d *Daemon) detachOnAttachmentSendError(token attachmentConnectionToken, failed ports.Transport) {
164- d.detachOnRoleSendErrorUntil(token, failed, nil)164+ d.detachOnAttachmentSendErrorUntil(token, failed, nil)
165 }165 }
166 166
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{}) {
168 // A delayed sender may report after the client has rebound. Only the exact168 // 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.
170 if failed == nil || failed != token.transport.transport {170 if failed == nil || failed != token.transport.transport {
171 return171 return
172 }172 }
173 if proxy, ok := token.sess.(*proxySession); ok {173 if proxy, ok := token.sess.(*proxySession); ok {
174- if d.detachIfRoleCurrentUntil(token, done) {174+ if d.detachIfAttachmentCurrentUntil(token, done) {
175 d.finishProxyClientGone(proxy, token.ac, failed, false)175 d.finishProxyClientGone(proxy, token.ac, failed, false)
176 d.armProxyWarm(proxy)176 d.armProxyWarm(proxy)
177 }177 }
@@ -182,30 +182,30 @@ func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed po
182 return182 return
183 }183 }
184 parkingToken := d.markParkingInFlight(sess, token.ac)184 parkingToken := d.markParkingInFlight(sess, token.ac)
185- if d.detachIfRoleCurrentUntil(token, done) {185+ if d.detachIfAttachmentCurrentUntil(token, done) {
186 d.finishSendErrorDetach(sess, token.ac, failed)186 d.finishSendErrorDetach(sess, token.ac, failed)
187 return187 return
188 }188 }
189 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)189 d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
190 }190 }
191 191
192-// reserveRoleSendErrorCleanup accounts for cleanup before End releases the192+// reserveAttachmentSendErrorCleanup accounts for cleanup before End releases the
193-// role gate. This closes the WaitGroup Add/Wait race with terminal teardown;193+// attachment gate. This closes the WaitGroup Add/Wait race with terminal teardown;
194 // the returned launch function must be invoked immediately after ticket End.194 // 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() {
196 d.attachmentCleanupWg.Add(1)196 d.attachmentCleanupWg.Add(1)
197 return func() {197 return func() {
198 go func() {198 go func() {
199 defer d.attachmentCleanupWg.Done()199 defer d.attachmentCleanupWg.Done()
200- if d.afterRoleSendErrorCleanup != nil {200+ if d.afterAttachmentSendErrorCleanup != nil {
201- defer d.afterRoleSendErrorCleanup()201+ defer d.afterAttachmentSendErrorCleanup()
202 }202 }
203- if d.beforeRoleSendErrorCleanup != nil {203+ if d.beforeAttachmentSendErrorCleanup != nil {
204- d.beforeRoleSendErrorCleanup(token)204+ d.beforeAttachmentSendErrorCleanup(token)
205 }205 }
206- deadline := newRoleEffectDrainDeadline(d.clock)206+ deadline := newAttachmentEffectDrainDeadline(d.clock)
207 defer deadline.stop()207 defer deadline.stop()
208- d.detachOnRoleSendErrorUntil(token, failed, deadline.Done)208+ d.detachOnAttachmentSendErrorUntil(token, failed, deadline.Done)
209 }()209 }()
210 }210 }
211 }211 }
M internal/usecase/daemon/clipboard.go +20 -20
@@ -26,27 +26,27 @@ var (
26 clipPasteCloseMarker = []byte("\x1b[201~")26 clipPasteCloseMarker = []byte("\x1b[201~")
27 )27 )
28 28
29-func (d *Daemon) handleSequencedImagePushForRole(token attachmentRoleToken, _ uint64, ip ports.ImagePush) {29+func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {
30- if !token.activeEffect() {30+ if !token.attachmentEffectCurrent() {
31 return31 return
32 }32 }
33- d.handleImagePushForRole(token, ip)33+ d.handleImagePushForAttachment(token, ip)
34 }34 }
35 35
36-func (d *Daemon) handleImagePushForRole(token attachmentRoleToken, ip ports.ImagePush) {36+func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {
37- if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.activeEffect() {37+ if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {
38 return38 return
39 }39 }
40- path, err := d.writeClipboardImageForRole(token, ip)40+ path, err := d.writeClipboardImageForAttachment(token, ip)
41 if err != nil {41 if err != nil {
42 d.log.Error("writing clipboard image failed", "err", err)42 d.log.Error("writing clipboard image failed", "err", err)
43 return43 return
44 }44 }
45- if !token.activeEffect() {45+ if !token.attachmentEffectCurrent() {
46 _ = os.Remove(path)46 _ = os.Remove(path)
47 return47 return
48 }48 }
49- d.injectClipboardPathForRole(token, path)49+ d.injectClipboardPathForAttachment(token, path)
50 }50 }
51 51
52 // handleImagePush writes an ImagePush's bytes to a temp file and injects the52 // 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,
84 return path, nil84 return path, nil
85 }85 }
86 86
87-func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.ImagePush) (string, error) {87+func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {
88 path, err := d.createClipboardImage(ip)88 path, err := d.createClipboardImage(ip)
89 if err != nil {89 if err != nil {
90 return "", err90 return "", err
@@ -95,7 +95,7 @@ func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports.
95 return "", errAttachmentTransition95 return "", errAttachmentTransition
96 }96 }
97 sess.mu.Lock()97 sess.mu.Lock()
98- if !token.activeEffectSessionLocked() {98+ if !token.attachmentEffectCurrentSessionLocked() {
99 sess.mu.Unlock()99 sess.mu.Unlock()
100 _ = os.Remove(path)100 _ = os.Remove(path)
101 return "", errAttachmentTransition101 return "", errAttachmentTransition
@@ -134,15 +134,15 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
134 d.injectClipboardPathToTarget(sess, path, nil)134 d.injectClipboardPathToTarget(sess, path, nil)
135 }135 }
136 136
137-func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) {137+func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138 sess, ok := localSession(token.sess)138 sess, ok := localSession(token.sess)
139- if !ok || !token.activeEffect() {139+ if !ok || !token.attachmentEffectCurrent() {
140 return140 return
141 }141 }
142 d.injectClipboardPathToTarget(sess, path, &token)142 d.injectClipboardPathToTarget(sess, path, &token)
143 }143 }
144 144
145-func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) {145+func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentConnectionToken) {
146 var ac *attachedClient146 var ac *attachedClient
147 if token != nil {147 if token != nil {
148 ac = token.ac148 ac = token.ac
@@ -175,7 +175,7 @@ func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *
175 wrapped = append(wrapped, clipPasteCloseMarker...)175 wrapped = append(wrapped, clipPasteCloseMarker...)
176 data = wrapped176 data = wrapped
177 }177 }
178- if token != nil && !token.activeEffect() {178+ if token != nil && !token.attachmentEffectCurrent() {
179 return179 return
180 }180 }
181 d.writeToPane(sess, p, data)181 d.writeToPane(sess, p, data)
@@ -202,7 +202,7 @@ func clipboardExt(mime string) string {
202 202
203 type clipboardForward struct {203 type clipboardForward struct {
204 owner paneEffectLease204 owner paneEffectLease
205- token attachmentRoleToken205+ token attachmentConnectionToken
206 seq []byte206 seq []byte
207 }207 }
208 208
@@ -238,7 +238,7 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
238 for _, ac := range attachments {238 for _, ac := range attachments {
239 transport := ac.transport()239 transport := ac.transport()
240 token := sess.attachmentToken(ac, transport)240 token := sess.attachmentToken(ac, transport)
241- if token.activeCurrent() {241+ if token.attachmentCurrent() {
242 items = append(items, clipboardForward{owner: owner, token: token, seq: seq})242 items = append(items, clipboardForward{owner: owner, token: token, seq: seq})
243 }243 }
244 }244 }
@@ -254,7 +254,7 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
254 sess.mu.Unlock()254 sess.mu.Unlock()
255 }255 }
256 256
257-func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *roleEffectTicket) (ports.Transport, error) {257+func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *attachmentEffectTicket) (ports.Transport, error) {
258 token := item.token258 token := item.token
259 if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil {259 if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil {
260 return token.transport.transport, errAttachmentTransition260 return token.transport.transport, errAttachmentTransition
@@ -294,7 +294,7 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *role
294 // admission and marks the transport interval while ownership publication is294 // admission and marks the transport interval while ownership publication is
295 // excluded. Publication therefore orders either before this check (the send is295 // excluded. Publication therefore orders either before this check (the send is
296 // dropped) or after transport admission (the send belongs to the old owner).296 // 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 {
298 if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil {298 if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil {
299 return false299 return false
300 }300 }
@@ -325,7 +325,7 @@ func (d *Daemon) clipboardWorker(sess *session) {
325 if !item.owner.Current() {325 if !item.owner.Current() {
326 continue326 continue
327 }327 }
328- ticket, admitted := item.token.ac.beginRoleEffect(item.token)328+ ticket, admitted := item.token.ac.beginAttachmentEffect(item.token)
329 if !admitted {329 if !admitted {
330 continue330 continue
331 }331 }
@@ -335,7 +335,7 @@ func (d *Daemon) clipboardWorker(sess *session) {
335 }335 }
336 ticket.End()336 ticket.End()
337 if err != nil && item.owner.Current() {337 if err != nil && item.owner.Current() {
338- d.detachOnRoleSendError(item.token, failed)338+ d.detachOnAttachmentSendError(item.token, failed)
339 }339 }
340 }340 }
341 }341 }
M internal/usecase/daemon/clipboard_test.go +4 -5
@@ -206,7 +206,7 @@ func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClie
206 rc := d.attachCoordinator(sess, nil, ac, true)206 rc := d.attachCoordinator(sess, nil, ac, true)
207 token := sess.attachmentToken(ac, tr)207 token := sess.attachmentToken(ac, tr)
208 token.lease = rc.attachmentLease(ac)208 token.lease = rc.attachmentLease(ac)
209- ac.publishRoleCapability(token)209+ ac.publishAttachmentCapability(token)
210 }210 }
211 211
212 func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {212 func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {
@@ -419,7 +419,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
419 close(workerDone)419 close(workerDone)
420 }()420 }()
421 require.Eventually(t, func() bool {421 require.Eventually(t, func() bool {
422- return ac.roleEffects.inFlightCount() == 1422+ return ac.attachmentEffects.inFlightCount() == 1
423 }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")423 }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")
424 424
425 sourceTab := testAttachmentTab(sess)425 sourceTab := testAttachmentTab(sess)
@@ -476,7 +476,7 @@ func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) {
476 rc := d.attachCoordinator(sess, nil, old, true)476 rc := d.attachCoordinator(sess, nil, old, true)
477 token := sess.attachmentToken(old, oldTransport)477 token := sess.attachmentToken(old, oldTransport)
478 token.lease = rc.attachmentLease(old)478 token.lease = rc.attachmentLease(old)
479- old.publishRoleCapability(token)479+ old.publishAttachmentCapability(token)
480 480
481 sess.mu.Lock()481 sess.mu.Lock()
482 sess.clipboardWorkerRunning = true482 sess.clipboardWorkerRunning = true
@@ -487,8 +487,7 @@ func TestQueuedClipboardBeforeSnatchDropsExactStaleCapability(t *testing.T) {
487 next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}487 next := &attachedClient{tr: newTransport, output: newOutputStateStream(), size: old.size}
488 next.initOverlays()488 next.initOverlays()
489 _, err := d.transitionAttachment(attachmentTransitionRequest{489 _, err := d.transitionAttachment(attachmentTransitionRequest{
490- target: sess, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,490+ target: sess, next: next, expectedTransport: next.transportSnapshot(), ready: true,
491- expectedTransport: next.transportSnapshot(), ready: true,
492 })491 })
493 require.NoError(t, err)492 require.NoError(t, err)
494 493
M internal/usecase/daemon/daemon.go +42 -45
@@ -147,19 +147,19 @@ type Daemon struct {
147 // afterConnectionSessionSnapshot is a deterministic test seam after the147 // afterConnectionSessionSnapshot is a deterministic test seam after the
148 // connection loop reads its current session and before it captures that role.148 // connection loop reads its current session and before it captures that role.
149 afterConnectionSessionSnapshot func(attachmentSession)149 afterConnectionSessionSnapshot func(attachmentSession)
150- // afterActiveFrameDispatch is a deterministic test seam after the connection150+ // afterAttachmentFrameDispatch is a deterministic test seam after the
151- // loop's first role check and before a decoded active frame takes effect.151+ // connection loop snapshots a token and before a decoded frame takes effect.
152- afterActiveFrameDispatch func(attachmentRoleToken)152+ afterAttachmentFrameDispatch func(attachmentConnectionToken)
153- // afterRoleEffectParticipantsSnapshotted observes immutable role-gate153+ // afterAttachmentEffectParticipantsSnapshotted observes immutable role-gate
154 // participants after architecture preflight locks are released and before154 // participants after architecture preflight locks are released and before
155 // the globally ordered freeze begins.155 // the globally ordered freeze begins.
156- afterRoleEffectParticipantsSnapshotted func(string, []*attachedClient)156+ afterAttachmentEffectParticipantsSnapshotted func(string, []*attachedClient)
157- // afterRoleEffectGateFrozen observes each participant after it is frozen in157+ // afterAttachmentEffectGateFrozen observes each participant after it is frozen in
158 // immutable identity order and before drain/publication continues.158 // immutable identity order and before drain/publication continues.
159- afterRoleEffectGateFrozen func(string, *attachedClient)159+ afterAttachmentEffectGateFrozen func(string, *attachedClient)
160- // afterRoleEffectsFrozen observes the lock-free boundary after all affected160+ // afterAttachmentEffectsFrozen observes the lock-free boundary after all affected
161 // attachment gates are frozen and drained, before architecture publication.161 // attachment gates are frozen and drained, before architecture publication.
162- afterRoleEffectsFrozen func()162+ afterAttachmentEffectsFrozen func()
163 // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only163 // afterMoveLifecycleReserved and afterMovePaneSourceSnapshot are test-only
164 // seams. They run before role/fence work and after the pre-fence source164 // seams. They run before role/fence work and after the pre-fence source
165 // attachment snapshot respectively. beforeMovePaneCommit runs inside the165 // attachment snapshot respectively. beforeMovePaneCommit runs inside the
@@ -171,9 +171,9 @@ type Daemon struct {
171 afterMoveTabSourceSnapshot func()171 afterMoveTabSourceSnapshot func()
172 beforeMovePaneCommit func()172 beforeMovePaneCommit func()
173 beforeMoveTabCommit func()173 beforeMoveTabCommit func()
174- // afterDetachRoleEffectsFrozen observes terminal detach after it wins the174+ // afterDetachAttachmentEffectsFrozen observes terminal detach after it wins the
175 // attachment gate but before it checks session ownership.175 // attachment gate but before it checks session ownership.
176- afterDetachRoleEffectsFrozen func()176+ afterDetachAttachmentEffectsFrozen func()
177 // beforeClientGoneDetach pauses clientGone after the stale-transport177 // beforeClientGoneDetach pauses clientGone after the stale-transport
178 // precheck and before exact transport/incarnation detach validation.178 // precheck and before exact transport/incarnation detach validation.
179 beforeClientGoneDetach func()179 beforeClientGoneDetach func()
@@ -195,34 +195,34 @@ type Daemon struct {
195 // lookup and before the attachment send lock, so tests can consume or195 // lookup and before the attachment send lock, so tests can consume or
196 // replace the credential while the handshake waits.196 // replace the credential while the handshake waits.
197 beforeResumeParkedSendMu func()197 beforeResumeParkedSendMu func()
198- // afterRoleEffectAdmitted is a deterministic test seam after a frame/paint198+ // afterAttachmentEffectAdmitted is a deterministic test seam after a frame/paint
199 // reserves its exact capability and before its first observable mutation.199 // reserves its exact capability and before its first observable mutation.
200- afterRoleEffectAdmitted func(attachmentRoleToken)200+ afterAttachmentEffectAdmitted func(attachmentConnectionToken)
201 // beforeFirstPaintSendWait is a test-only seam immediately before a201 // beforeFirstPaintSendWait is a test-only seam immediately before a
202 // transition first paint waits for the attachment send lock.202 // transition first paint waits for the attachment send lock.
203- beforeFirstPaintSendWait func(attachmentRoleToken)203+ beforeFirstPaintSendWait func(attachmentConnectionToken)
204 // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a204 // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a
205 // fresh exact capability before producing PTY, action, or overlay effects.205 // fresh exact capability before producing PTY, action, or overlay effects.
206 afterDelayedKeyEffectAttempt func(bool)206 afterDelayedKeyEffectAttempt func(bool)
207- // afterActionRoleEffectEnded observes action-specific admission release. It207+ // afterActionAttachmentEffectEnded observes action-specific admission release. It
208 // is a deterministic seam for proving no role-bound mutation follows release.208 // is a deterministic seam for proving no role-bound mutation follows release.
209- afterActionRoleEffectEnded func(string)209+ afterActionAttachmentEffectEnded func(string)
210- // beforeRoleSendErrorCleanup pauses asynchronous render-failure retirement210+ // beforeAttachmentSendErrorCleanup pauses asynchronous render-failure retirement
211 // after the render ticket ends and before exact lifecycle validation.211 // after the render ticket ends and before exact lifecycle validation.
212- beforeRoleSendErrorCleanup func(attachmentRoleToken)212+ beforeAttachmentSendErrorCleanup func(attachmentConnectionToken)
213- afterRoleSendErrorCleanup func()213+ afterAttachmentSendErrorCleanup func()
214- ptys ports.PTYFactory214+ ptys ports.PTYFactory
215- clock ports.Clock215+ clock ports.Clock
216- log *slog.Logger216+ log *slog.Logger
217- runtimeObserver ports.RuntimeObserver217+ runtimeObserver ports.RuntimeObserver
218- baseEnv []string218+ baseEnv []string
219- shell string219+ shell string
220- shellArgs []string220+ shellArgs []string
221- shellOverride bool221+ shellOverride bool
222- persistEnabled bool222+ persistEnabled bool
223- catalogue ports.Catalogue223+ catalogue ports.Catalogue
224- catalogueRecords []domain.CatalogueRecord224+ catalogueRecords []domain.CatalogueRecord
225- catalogueRecordsProvided bool225+ catalogueRecordsProvided bool
226 // snapshotRepository is the sole checkpoint storage contract.226 // snapshotRepository is the sole checkpoint storage contract.
227 snapshotRepository ports.SnapshotRepository227 snapshotRepository ports.SnapshotRepository
228 recovery *recoveryusecase.Coordinator228 recovery *recoveryusecase.Coordinator
@@ -1099,9 +1099,8 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
1099 expected := ac.transportSnapshot()1099 expected := ac.transportSnapshot()
1100 welcomeToken := sess.attachmentToken(ac, tr)1100 welcomeToken := sess.attachmentToken(ac, tr)
1101 welcomeToken.lease = lease1101 welcomeToken.lease = lease
1102- welcomeTicket, admitted := ac.beginRoleEffect(welcomeToken)1102+ welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1103- validRole := welcomeToken.role == attachmentActive1103+ if expected.transport != tr || !admitted || welcomeToken.ac == nil {
1104- if expected.transport != tr || !admitted || !validRole {
1105 if admitted {1104 if admitted {
1106 welcomeTicket.End()1105 welcomeTicket.End()
1107 }1106 }
@@ -1110,7 +1109,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
1110 }1109 }
1111 return1110 return
1112 }1111 }
1113- if err := ac.sendExpectedTransportForRole(expected, frameWelcome(sess, ac), welcomeTicket); err != nil {1112+ if err := ac.sendExpectedTransportForAttachment(expected, frameWelcome(sess, ac), welcomeTicket); err != nil {
1114 welcomeTicket.End()1113 welcomeTicket.End()
1115 if !d.abortResumeClaim(ac) {1114 if !d.abortResumeClaim(ac) {
1116 d.clientGone(sess, ac, tr, false)1115 d.clientGone(sess, ac, tr, false)
@@ -1124,17 +1123,16 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
1124 }1123 }
1125 return1124 return
1126 }1125 }
1127- // Welcome is the only effect allowed to retain the route-time role. Release1126+ // Release Welcome's effect before discovering post-handshake authority so a
1128- // it before discovering post-handshake authority so a replacement blocked1127+ // replacement blocked behind the send can publish its generation and lease.
1129- // behind the send can publish its generation and lease first.
1130 welcomeTicket.End()1128 welcomeTicket.End()
1131- postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentRoleEffect(sess, tr)1129+ postWelcomeToken, postWelcomeTicket, admitted := ac.beginCurrentAttachmentEffect(sess, tr)
1132 if !admitted {1130 if !admitted {
1133 d.clientGone(sess, ac, tr, false)1131 d.clientGone(sess, ac, tr, false)
1134 return1132 return
1135 }1133 }
1136 postWelcomeLease := postWelcomeToken.lease1134 postWelcomeLease := postWelcomeToken.lease
1137- if postWelcomeToken.role != attachmentActive {1135+ if postWelcomeToken.ac == nil {
1138 postWelcomeTicket.End()1136 postWelcomeTicket.End()
1139 d.clientGone(sess, ac, tr, false)1137 d.clientGone(sess, ac, tr, false)
1140 return1138 return
@@ -1142,7 +1140,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
1142 if ac.proxied {1140 if ac.proxied {
1143 meta, ok := sess.sessionMetaSnapshotFor(ac)1141 meta, ok := sess.sessionMetaSnapshotFor(ac)
1144 metaFrame, metaErr := frameSessionMeta(meta)1142 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 {
1146 postWelcomeTicket.End()1144 postWelcomeTicket.End()
1147 d.clientGone(sess, ac, tr, false)1145 d.clientGone(sess, ac, tr, false)
1148 return1146 return
@@ -1195,10 +1193,9 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1195 ac := d.prepareAttachedClientLocked(tr, sz, opts)1193 ac := d.prepareAttachedClientLocked(tr, sz, opts)
1196 d.mu.Unlock()1194 d.mu.Unlock()
1197 result, err := d.transitionAttachment(attachmentTransitionRequest{1195 result, err := d.transitionAttachment(attachmentTransitionRequest{
1198- target: sess,1196+ target: sess,
1199- next: ac,1197+ next: ac,
1200- expectedRole: attachmentDetached,1198+
1201- targetRole: attachmentActive,
1202 expectedTransport: ac.transportSnapshot(),1199 expectedTransport: ac.transportSnapshot(),
1203 ready: false,1200 ready: false,
1204 })1201 })
M internal/usecase/daemon/input.go +30 -30
@@ -20,11 +20,11 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
20 d.handleInput(sess, ac, data)20 d.handleInput(sess, ac, data)
21 }21 }
22 22
23-func (d *Daemon) handleSequencedInputForRole(token attachmentRoleToken, _ uint64, data []byte) {23+func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {
24- if !token.activeEffect() {24+ if !token.attachmentEffectCurrent() {
25 return25 return
26 }26 }
27- d.handleInputForRole(token, data)27+ d.handleInputForAttachment(token, data)
28 }28 }
29 29
30 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {30 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
@@ -40,19 +40,19 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
40 )40 )
41 }41 }
42 42
43-func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) {43+func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data []byte) {
44 ac := token.ac44 ac := token.ac
45 ac.initOverlays()45 ac.initOverlays()
46 if proxy, ok := token.sess.(*proxySession); ok {46 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}
48 ac.mouseScan.Scan(data,48 ac.mouseScan.Scan(data,
49 func(ev mouse.Event) {49 func(ev mouse.Event) {
50- if token.activeEffect() && !proxyMouseOwnedLocally(ac.overlays) {50+ if token.attachmentEffectCurrent() && !proxyMouseOwnedLocally(ac.overlays) {
51 handler.Mouse(ev)51 handler.Mouse(ev)
52 }52 }
53 },53 },
54 func(b []byte) {54 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() {
56 return56 return
57 }57 }
58 ac.keys.RouteWithHandler(b, handler)58 ac.keys.RouteWithHandler(b, handler)
@@ -62,21 +62,21 @@ func (d *Daemon) handleInputForRole(token attachmentRoleToken, data []byte) {
62 }62 }
63 ac.mouseScan.Scan(data,63 ac.mouseScan.Scan(data,
64 func(ev mouse.Event) {64 func(ev mouse.Event) {
65- if token.activeEffect() {65+ if token.attachmentEffectCurrent() {
66 d.handleMouse(ac, ev)66 d.handleMouse(ac, ev)
67 }67 }
68 },68 },
69 func(b []byte) {69 func(b []byte) {
70- if !token.activeEffect() {70+ if !token.attachmentEffectCurrent() {
71 return71 return
72 }72 }
73 if ac.overlays.HandleInput(d, b, token.effect) {73 if ac.overlays.HandleInput(d, b, token.effect) {
74 return74 return
75 }75 }
76- if !token.activeEffect() {76+ if !token.attachmentEffectCurrent() {
77 return77 return
78 }78 }
79- ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, roleToken: token})79+ ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, connectionToken: token})
80 },80 },
81 )81 )
82 }82 }
@@ -315,34 +315,34 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
315 }315 }
316 316
317 type daemonKeyHandler struct {317 type daemonKeyHandler struct {
318- d *Daemon318+ d *Daemon
319- ac *attachedClient319+ ac *attachedClient
320- actions daemonActionRunner320+ actions daemonActionRunner
321- roleToken attachmentRoleToken321+ connectionToken attachmentConnectionToken
322 }322 }
323 323
324-// acquireRoleEffect preserves a synchronous frame's existing admission and324+// acquireAttachmentEffect preserves a synchronous frame's existing admission and
325 // gives delayed router callbacks a fresh ticket for the exact captured role.325 // gives delayed router callbacks a fresh ticket for the exact captured role.
326 // Direct/headless callers without a role token retain their existing behavior.326 // Direct/headless callers without a role token retain their existing behavior.
327-func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) {327+func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
328- if h.roleToken.ac != nil {328+ if h.connectionToken.ac != nil {
329- if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {329+ if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
330- sess, _ := localSession(h.roleToken.sess)330+ sess, _ := localSession(h.connectionToken.sess)
331 return sess, effect, false331 return sess, effect, false
332 }332 }
333- effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)333+ effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
334 if h.d.afterDelayedKeyEffectAttempt != nil {334 if h.d.afterDelayedKeyEffectAttempt != nil {
335 h.d.afterDelayedKeyEffectAttempt(admitted)335 h.d.afterDelayedKeyEffectAttempt(admitted)
336 }336 }
337 if !admitted {337 if !admitted {
338 return nil, nil, false338 return nil, nil, false
339 }339 }
340- if h.d.afterRoleEffectAdmitted != nil {340+ if h.d.afterAttachmentEffectAdmitted != nil {
341- token := h.roleToken341+ token := h.connectionToken
342 token.effect = effect342 token.effect = effect
343- h.d.afterRoleEffectAdmitted(token)343+ h.d.afterAttachmentEffectAdmitted(token)
344 }344 }
345- sess, ok := localSession(h.roleToken.sess)345+ sess, ok := localSession(h.connectionToken.sess)
346 if !ok {346 if !ok {
347 effect.End()347 effect.End()
348 return nil, nil, false348 return nil, nil, false
@@ -357,7 +357,7 @@ func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool
357 }357 }
358 358
359 func (h daemonKeyHandler) Forward(data []byte) {359 func (h daemonKeyHandler) Forward(data []byte) {
360- sess, effect, owned := h.acquireRoleEffect()360+ sess, effect, owned := h.acquireAttachmentEffect()
361 if sess == nil {361 if sess == nil {
362 return362 return
363 }363 }
@@ -375,7 +375,7 @@ func (h daemonKeyHandler) Forward(data []byte) {
375 }375 }
376 376
377 func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {377 func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
378- sess, effect, owned := h.acquireRoleEffect()378+ sess, effect, owned := h.acquireAttachmentEffect()
379 if sess == nil {379 if sess == nil {
380 return380 return
381 }381 }
@@ -421,9 +421,9 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
421 return421 return
422 }422 }
423 effect.bindActionEnd(h.d, "jump-attention")423 effect.bindActionEnd(h.d, "jump-attention")
424- token := h.roleToken424+ token := h.connectionToken
425 token.effect = effect425 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 {
427 h.d.reportError(sess, err)427 h.d.reportError(sess, err)
428 }428 }
429 case keys.ActionToggleFloatingPane:429 case keys.ActionToggleFloatingPane:
@@ -464,7 +464,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
464 }464 }
465 }465 }
466 466
467-func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *roleEffectTicket) {467+func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) {
468 var err error468 var err error
469 if h.ac.proxied {469 if h.ac.proxied {
470 err = h.d.focusDirProxied(sess, h.ac, dir)470 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) {
77 movedPane := admission.movedPane77 movedPane := admission.movedPane
78 sourceClient := admission.sourceClient78 sourceClient := admission.sourceClient
79 destinationClient := admission.destinationClient79 destinationClient := admission.destinationClient
80- sourceSnatched := admission.sourceSnatched
81 sourceTabInitialGeneration := admission.sourceGeneration80 sourceTabInitialGeneration := admission.sourceGeneration
82 destinationTabInitialGeneration := admission.destinationGeneration81 destinationTabInitialGeneration := admission.destinationGeneration
83 finalSourceTab := admission.finalSourceTab82 finalSourceTab := admission.finalSourceTab
@@ -90,14 +89,14 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
90 // the callback then validates the handoff before changing any topology.89 // the callback then validates the handoff before changing any topology.
91 var handoffReq attachmentTransitionRequest90 var handoffReq attachmentTransitionRequest
92 var handoffParticipants attachmentTransitionParticipants91 var handoffParticipants attachmentTransitionParticipants
93- var frozen frozenRoleEffectGates92+ var frozen frozenAttachmentEffectGates
94- var sourceRolesFrozen bool93+ var sourceEffectsFrozen bool
95 var handoffFrozen bool94 var handoffFrozen bool
96 95
97 if finalSourceTab && source != destination && sourceClient != nil {96 if finalSourceTab && source != destination && sourceClient != nil {
98 transport := sourceClient.transportSnapshot()97 transport := sourceClient.transportSnapshot()
99 sourceToken := source.attachmentToken(sourceClient, transport.transport)98 sourceToken := source.attachmentToken(sourceClient, transport.transport)
100- if sourceToken.role != attachmentActive || sourceToken.transport.transport == nil {99+ if sourceToken.ac == nil || sourceToken.transport.transport == nil {
101 return errMovePaneInvalid100 return errMovePaneInvalid
102 }101 }
103 destinationTabIndex := moveTabIndex(destination, destinationTab)102 destinationTabIndex := moveTabIndex(destination, destinationTab)
@@ -106,32 +105,18 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
106 }105 }
107 handoffReq = attachmentTransitionRequest{106 handoffReq = attachmentTransitionRequest{
108 source: source, target: destination, next: sourceClient,107 source: source, target: destination, next: sourceClient,
109- expectedRole: attachmentActive, targetRole: attachmentActive,108+
110 expectedTransport: sourceToken.transport, sourceToken: &sourceToken,109 expectedTransport: sourceToken.transport, sourceToken: &sourceToken,
111- expectedSourceTab: sourceTab, activateTargetTab: true,110+ expectedSourceTab: sourceTab, targetTabIndex: destinationTabIndex, ready: true,
112- targetTabIndex: destinationTabIndex, ready: true,
113 }111 }
114 var err error112 var err error
115 handoffReq, handoffParticipants, err = d.snapshotAttachmentTransition(handoffReq)113 handoffReq, handoffParticipants, err = d.snapshotAttachmentTransition(handoffReq)
116 if err != nil {114 if err != nil {
117 return err115 return err
118 }116 }
119- // Retiring source snatched clients is part of the same role gate set as117+ frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: handoffParticipants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
120- // the follower. Their exact membership is checked again in the commit.118+ if d.afterAttachmentEffectGateFrozen != nil {
121- d.mu.Lock()119+ d.afterAttachmentEffectGateFrozen("move-pane", ac)
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)
135 }120 }
136 }}, handoffParticipants.clients...)121 }}, handoffParticipants.clients...)
137 if !frozen.acquired || !frozen.drained {122 if !frozen.acquired || !frozen.drained {
@@ -139,36 +124,12 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
139 return errMovePaneInvalid124 return errMovePaneInvalid
140 }125 }
141 handoffFrozen = true126 handoffFrozen = true
142- sourceRolesFrozen = true127+ sourceEffectsFrozen = true
143- handoffReq.roleEffectsFrozen = true128+ handoffReq.attachmentEffectsFrozen = 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
168 }129 }
169- if sourceRolesFrozen {130+ if sourceEffectsFrozen {
170 defer func() {131 defer func() {
171- if sourceRolesFrozen {132+ if sourceEffectsFrozen {
172 frozen.unfreeze()133 frozen.unfreeze()
173 }134 }
174 }()135 }()
@@ -182,11 +143,10 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
182 destinationTab: destinationTab,143 destinationTab: destinationTab,
183 movedPane: movedPane,144 movedPane: movedPane,
184 sourceClient: sourceClient,145 sourceClient: sourceClient,
185- sourceSnatched: sourceSnatched,
186 sourceGeneration: sourceTabInitialGeneration,146 sourceGeneration: sourceTabInitialGeneration,
187 destinationGeneration: destinationTabInitialGeneration,147 destinationGeneration: destinationTabInitialGeneration,
188 handoffFrozen: handoffFrozen,148 handoffFrozen: handoffFrozen,
189- frozenRoles: frozen,149+ frozenEffects: frozen,
190 handoffReq: handoffReq,150 handoffReq: handoffReq,
191 }151 }
192 fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane)152 fences := newMovePaneResizeFences(source, destination, sourceTab, destinationTab, movedPane)
@@ -210,8 +170,8 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
210 sourceCleanupToken: commit.sourceCleanupToken,170 sourceCleanupToken: commit.sourceCleanupToken,
211 handoffResult: commit.handoffResult,171 handoffResult: commit.handoffResult,
212 syncCleanup: commit.syncCleanup,172 syncCleanup: commit.syncCleanup,
213- frozenRoles: frozen,173+ frozenEffects: frozen,
214- rolesFrozen: sourceRolesFrozen,174+ effectsFrozen: sourceEffectsFrozen,
215 unlockDispatch: unlockDispatch,175 unlockDispatch: unlockDispatch,
216 reservation: reservation,176 reservation: reservation,
217 sourceTabRemoved: commit.sourceTabRemoved,177 sourceTabRemoved: commit.sourceTabRemoved,
@@ -225,7 +185,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
225 }185 }
226 // Transfer cleanup ownership away from the admission defers only after the186 // Transfer cleanup ownership away from the admission defers only after the
227 // complete postcommit plan exists.187 // complete postcommit plan exists.
228- sourceRolesFrozen = false188+ sourceEffectsFrozen = false
229 dispatchHeld = false189 dispatchHeld = false
230 reservationHeld = false190 reservationHeld = false
231 postcommit.execute(d)191 postcommit.execute(d)
M internal/usecase/daemon/move_atomicity_test.go +8 -8
@@ -21,8 +21,8 @@ type moveCommitObservation struct {
21 destinationFocus layout.PaneID21 destinationFocus layout.PaneID
22 ownerSession *session22 ownerSession *session
23 ownerTab *tab23 ownerTab *tab
24- sourceRole attachmentRole24+ sourceRole bool
25- destinationRole attachmentRole25+ destinationRole bool
26 sourceClientSession *session26 sourceClientSession *session
27 }27 }
28 28
@@ -50,8 +50,8 @@ func readMoveCommitObservation(d *Daemon, source, destination *session, sourceTa
50 destinationFocus: destinationTab.tree.Focus,50 destinationFocus: destinationTab.tree.Focus,
51 ownerSession: nil,51 ownerSession: nil,
52 ownerTab: nil,52 ownerTab: nil,
53- sourceRole: source.attachmentRoleLocked(sourceClient),53+ sourceRole: source.attachmentRegisteredLocked(sourceClient),
54- destinationRole: destination.attachmentRoleLocked(sourceClient),54+ destinationRole: destination.attachmentRegisteredLocked(sourceClient),
55 sourceClientSession: sourceClient.currentSession(),55 sourceClientSession: sourceClient.currentSession(),
56 }56 }
57 if owner != nil {57 if owner != nil {
@@ -127,8 +127,8 @@ func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) {
127 {name: "active tab and focus", read: func(o moveCommitObservation) bool {127 {name: "active tab and focus", read: func(o moveCommitObservation) bool {
128 return o.sourceActive == sourceTab || o.sourceFocus != layout.PaneID("pane-1") || o.destinationActive != destinationTab || o.destinationFocus != layout.PaneID("pane-2")128 return o.sourceActive == sourceTab || o.sourceFocus != layout.PaneID("pane-1") || o.destinationActive != destinationTab || o.destinationFocus != layout.PaneID("pane-2")
129 }},129 }},
130- {name: "attachment roles", read: func(o moveCommitObservation) bool {130+ {name: "attachment membership", read: func(o moveCommitObservation) bool {
131- return o.sourceRole == attachmentActive && o.destinationRole == attachmentDetached && o.sourceClientSession == source131+ return o.sourceRole != false || o.destinationRole != true || o.sourceClientSession != destination
132 }},132 }},
133 }133 }
134 started := make(chan string, len(readers))134 started := make(chan string, len(readers))
@@ -176,7 +176,7 @@ func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) {
176 require.Equal(t, layout.PaneID("pane-2"), observation.destinationFocus)176 require.Equal(t, layout.PaneID("pane-2"), observation.destinationFocus)
177 require.Same(t, destination, observation.ownerSession)177 require.Same(t, destination, observation.ownerSession)
178 require.Same(t, destinationTab, observation.ownerTab)178 require.Same(t, destinationTab, observation.ownerTab)
179- require.Equal(t, attachmentDetached, observation.sourceRole)179+ require.Equal(t, false, observation.sourceRole)
180- require.Equal(t, attachmentActive, observation.destinationRole)180+ require.Equal(t, true, observation.destinationRole)
181 require.Same(t, destination, observation.sourceClientSession)181 require.Same(t, destination, observation.sourceClientSession)
182 }182 }
M internal/usecase/daemon/move_attachments.go +4 -28
@@ -1,30 +1,6 @@
1 package daemon1 package daemon
2 2
3-// frozenMoveAttachmentRetirement is proof that every source snatched client3+// frozenMoveAttachmentRetirement records that the exact source attachment
4-// retired by a final move has had its exact role-effect gate frozen and drained.4+// effect gate was frozen before a final move removes its session membership.
5-// It is prepared while the source session lock is held, before topology writes.5+// Other registered attachments are independent and are never retired here.
6-type frozenMoveAttachmentRetirement struct {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-}
M internal/usecase/daemon/move_cleanup_test.go +10 -10
@@ -41,7 +41,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
41 require.NotNil(t, rc)41 require.NotNil(t, rc)
42 lease := rc.attachmentLease(client)42 lease := rc.attachmentLease(client)
43 epoch := rc.recordResizeRequestForLease(client.size, client, lease)43 epoch := rc.recordResizeRequestForLease(client.size, client, lease)
44- ticket, admitted := beginActiveLeaseEffect(source, client, lease)44+ ticket, admitted := beginAttachmentLeaseEffect(source, client, lease)
45 require.True(t, admitted)45 require.True(t, admitted)
46 defer ticket.End()46 defer ticket.End()
47 47
@@ -109,9 +109,9 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
109 d.sessions[destination.id] = destination109 d.sessions[destination.id] = destination
110 d.mu.Unlock()110 d.mu.Unlock()
111 111
112- // Both clients carry state tied specifically to the pane. The displaced112+ // Both clients carry state tied specifically to the pane. The existing
113- // client was captured before the fence wait; the replacement is the only113+ // attachment and the concurrently registered attachment remain independent;
114- // attachment that may be cleaned after commit.114+ // moving one exact attachment must not clean the other.
115 displaced.overlays.copyMu.Lock()115 displaced.overlays.copyMu.Lock()
116 displaced.overlays.copyPane = moved116 displaced.overlays.copyPane = moved
117 displaced.overlays.copyMode = &scopy.Mode{}117 displaced.overlays.copyMode = &scopy.Mode{}
@@ -166,20 +166,20 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
166 require.NoError(t, awaitTestValue(t, moveDone, "move did not finish after source fence release"))166 require.NoError(t, awaitTestValue(t, moveDone, "move did not finish after source fence release"))
167 167
168 displaced.overlays.copyMu.Lock()168 displaced.overlays.copyMu.Lock()
169- require.NotNil(t, displaced.overlays.copyMode, "displaced source client was incorrectly cleaned")169+ require.Nil(t, displaced.overlays.copyMode, "moved attachment copy mode was not cleaned")
170- require.Same(t, moved, displaced.overlays.copyPane)170+ require.Nil(t, displaced.overlays.copyPane)
171 displaced.overlays.copyMu.Unlock()171 displaced.overlays.copyMu.Unlock()
172 displaced.sendMu.Lock()172 displaced.sendMu.Lock()
173 _, displacedCaptured := displaced.captureFrames[moved]173 _, displacedCaptured := displaced.captureFrames[moved]
174 displaced.sendMu.Unlock()174 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")
176 176
177 replacement.overlays.copyMu.Lock()177 replacement.overlays.copyMu.Lock()
178- require.Nil(t, replacement.overlays.copyMode, "current source client copy mode was not cleaned")178+ require.NotNil(t, replacement.overlays.copyMode, "independent attachment copy mode was cleaned")
179- require.Nil(t, replacement.overlays.copyPane)179+ require.Same(t, moved, replacement.overlays.copyPane)
180 replacement.overlays.copyMu.Unlock()180 replacement.overlays.copyMu.Unlock()
181 replacement.sendMu.Lock()181 replacement.sendMu.Lock()
182 _, replacementCaptured := replacement.captureFrames[moved]182 _, replacementCaptured := replacement.captureFrames[moved]
183 replacement.sendMu.Unlock()183 replacement.sendMu.Unlock()
184- require.False(t, replacementCaptured, "current source capture was not pruned")184+ require.True(t, replacementCaptured, "independent attachment capture was pruned")
185 }185 }
M internal/usecase/daemon/move_gate_race_test.go +6 -6
@@ -74,7 +74,7 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
74 killFrozen := make(chan struct{})74 killFrozen := make(chan struct{})
75 releaseKill := make(chan struct{})75 releaseKill := make(chan struct{})
76 var killFrozenOnce sync.Once76 var killFrozenOnce sync.Once
77- d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {77+ d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) {
78 if action != "" {78 if action != "" {
79 return79 return
80 }80 }
@@ -83,7 +83,7 @@ func TestMovePaneGateReservationRaceKillWinsWithoutDeadlock(t *testing.T) {
83 }83 }
84 defer func() {84 defer func() {
85 d.afterMoveLifecycleReserved = nil85 d.afterMoveLifecycleReserved = nil
86- d.afterRoleEffectGateFrozen = nil86+ d.afterAttachmentEffectGateFrozen = nil
87 }()87 }()
88 88
89 moveDone := make(chan error, 1)89 moveDone := make(chan error, 1)
@@ -118,7 +118,7 @@ func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
118 moveGateFrozen := make(chan struct{})118 moveGateFrozen := make(chan struct{})
119 releaseMove := make(chan struct{})119 releaseMove := make(chan struct{})
120 var moveGateFrozenOnce sync.Once120 var moveGateFrozenOnce sync.Once
121- d.afterRoleEffectGateFrozen = func(action string, _ *attachedClient) {121+ d.afterAttachmentEffectGateFrozen = func(action string, _ *attachedClient) {
122 if action != "move-pane" {122 if action != "move-pane" {
123 return123 return
124 }124 }
@@ -127,14 +127,14 @@ func TestMovePaneGateReservationRaceMoveWinsWithoutDeadlock(t *testing.T) {
127 }127 }
128 killSnapshotted := make(chan struct{})128 killSnapshotted := make(chan struct{})
129 var killSnapshottedOnce sync.Once129 var killSnapshottedOnce sync.Once
130- d.afterRoleEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {130+ d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
131 if action == "" {131 if action == "" {
132 killSnapshottedOnce.Do(func() { close(killSnapshotted) })132 killSnapshottedOnce.Do(func() { close(killSnapshotted) })
133 }133 }
134 }134 }
135 defer func() {135 defer func() {
136- d.afterRoleEffectGateFrozen = nil136+ d.afterAttachmentEffectGateFrozen = nil
137- d.afterRoleEffectParticipantsSnapshotted = nil137+ d.afterAttachmentEffectParticipantsSnapshotted = nil
138 }()138 }()
139 139
140 moveDone := make(chan error, 1)140 moveDone := make(chan error, 1)
M internal/usecase/daemon/move_pane_admission.go +8 -3
@@ -13,7 +13,6 @@ type movePaneAdmission struct {
13 movedPane *pane13 movedPane *pane
14 sourceClient *attachedClient14 sourceClient *attachedClient
15 destinationClient *attachedClient15 destinationClient *attachedClient
16- sourceSnatched []*attachedClient
17 sourceGeneration uint6416 sourceGeneration uint64
18 destinationGeneration uint6417 destinationGeneration uint64
19 finalSourceTab bool18 finalSourceTab bool
@@ -53,15 +52,21 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat
53 return nil, errMoveStaleTarget52 return nil, errMoveStaleTarget
54 }53 }
55 54
55+ sourceClient := req.Client
56+ if sourceClient == nil {
57+ attachments := source.snapshotAttachmentsLocked()
58+ if len(attachments) != 0 {
59+ sourceClient = attachments[0]
60+ }
61+ }
56 return &movePaneAdmission{62 return &movePaneAdmission{
57 source: source,63 source: source,
58 destination: destination,64 destination: destination,
59 sourceTab: sourceTab,65 sourceTab: sourceTab,
60 destinationTab: destinationTab,66 destinationTab: destinationTab,
61 movedPane: movedPane,67 movedPane: movedPane,
62- sourceClient: req.Client,68+ sourceClient: sourceClient,
63 destinationClient: nil,69 destinationClient: nil,
64- sourceSnatched: nil,
65 sourceGeneration: sourceTab.layoutGeneration,70 sourceGeneration: sourceTab.layoutGeneration,
66 destinationGeneration: destinationTab.layoutGeneration,71 destinationGeneration: destinationTab.layoutGeneration,
67 finalSourceTab: len(source.tabs) == 1 && source.tabs[0] == sourceTab &&72 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 {
14 sourceTab, destinationTab *tab14 sourceTab, destinationTab *tab
15 movedPane *pane15 movedPane *pane
16 sourceClient *attachedClient16 sourceClient *attachedClient
17- sourceSnatched []*attachedClient
18 sourceGeneration uint6417 sourceGeneration uint64
19 destinationGeneration uint6418 destinationGeneration uint64
20 handoffFrozen bool19 handoffFrozen bool
21- frozenRoles frozenRoleEffectGates20+ frozenEffects frozenAttachmentEffectGates
22 handoffReq attachmentTransitionRequest21 handoffReq attachmentTransitionRequest
23 handoffPublication *attachmentPublication22 handoffPublication *attachmentPublication
24 err error23 err error
25 24
26 handoffResult attachmentTransitionResult25 handoffResult attachmentTransitionResult
27 syncCleanup syncTimerCleanup26 syncCleanup syncTimerCleanup
28- sourceCleanupToken attachmentRoleToken27+ sourceCleanupToken attachmentConnectionToken
29 sourceEmpty bool28 sourceEmpty bool
30 sourceTabRemoved bool29 sourceTabRemoved bool
31 retiredParked []parkedAttachmentRetirement30 retiredParked []parkedAttachmentRetirement
@@ -62,7 +61,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
62 return false61 return false
63 }62 }
64 if c.handoffFrozen {63 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) ||
66 c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) ||65 c.handoffReq.targetTabIndex < 0 || c.handoffReq.targetTabIndex >= len(c.destination.tabs) ||
67 c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab {66 c.destination.tabs[c.handoffReq.targetTabIndex] != c.destinationTab {
68 return false67 return false
@@ -102,15 +101,10 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
102 101
103 sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1102 sourceWillEmpty := candidate.removeSourceTab && len(c.source.tabs) == 1
104 if sourceWillEmpty && c.source != c.destination {103 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)) ||
106 (c.sourceClient != nil) != c.handoffFrozen {105 (c.sourceClient != nil) != c.handoffFrozen {
107 return false106 return false
108 }107 }
109- var retirementOK bool
110- retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.sourceSnatched, c.frozenRoles)
111- if !retirementOK {
112- return false
113- }
114 }108 }
115 109
116 // Everything above is fallible. Topology, owner, lifecycle registry, and110 // 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 {
27 movedPane *pane27 movedPane *pane
28 movedPanes []*pane28 movedPanes []*pane
29 destinationClient *attachedClient29 destinationClient *attachedClient
30- sourceCleanupToken attachmentRoleToken30+ sourceCleanupToken attachmentConnectionToken
31 handoffResult attachmentTransitionResult31 handoffResult attachmentTransitionResult
32 syncCleanup syncTimerCleanup32 syncCleanup syncTimerCleanup
33- frozenRoles frozenRoleEffectGates33+ frozenEffects frozenAttachmentEffectGates
34- rolesFrozen bool34+ effectsFrozen bool
35 unlockDispatch func()35 unlockDispatch func()
36 reservation *moveLifecycleReservation36 reservation *moveLifecycleReservation
37 oldTabCancel context.CancelFunc37 oldTabCancel context.CancelFunc
@@ -77,8 +77,8 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
77 markSnapshotDirty(p.source)77 markSnapshotDirty(p.source)
78 }78 }
79 p.syncCleanup.finish()79 p.syncCleanup.finish()
80- if p.rolesFrozen {80+ if p.effectsFrozen {
81- p.frozenRoles.unfreeze()81+ p.frozenEffects.unfreeze()
82 }82 }
83 p.unlockDispatch()83 p.unlockDispatch()
84 p.reservation.Release()84 p.reservation.Release()
@@ -114,6 +114,7 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
114 }114 }
115 if p.handoffResult.published.ac != nil {115 if p.handoffResult.published.ac != nil {
116 follower := p.handoffResult.published.ac116 follower := p.handoffResult.published.ac
117+ p.destination.selectAttachmentTab(follower, domain.TabStableID(p.destinationTab.stableID))
117 d.applyHostTheme(p.destination, follower, follower.getClientTheme(), false)118 d.applyHostTheme(p.destination, follower, follower.getClientTheme(), false)
118 follower.recordPreviousSession(p.source)119 follower.recordPreviousSession(p.source)
119 d.deferAttachmentTransitionCleanups(p.handoffResult)120 d.deferAttachmentTransitionCleanups(p.handoffResult)
@@ -145,23 +146,15 @@ func (p movePanePostcommitPlan) execute(d *Daemon) {
145 }146 }
146 }147 }
147 148
148-// retireEmptyMoveSessionLocked removes all source-owned attachment roles that149+// retireEmptyMoveSessionLocked clears the empty source session after the exact
149-// were not transferred. It runs inside the move commit, after the moved pane150+// attachment transfer has committed. It performs no external work.
150-// has already acquired its destination owner, and performs no external work.
151 func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachmentRetirement) []detachedAttachmentSnapshot {151 func retireEmptyMoveSessionLocked(sess *session, retirement frozenMoveAttachmentRetirement) []detachedAttachmentSnapshot {
152 if sess == nil || len(sess.attachments) != 0 {152 if sess == nil || len(sess.attachments) != 0 {
153 return nil153 return nil
154 }154 }
155- retired := make([]detachedAttachmentSnapshot, 0, len(retirement.clients))155+ _ = retirement
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- }
163 sess.tabs = nil156 sess.tabs = nil
164- return retired157+ return nil
165 }158 }
166 159
167 func moveSessionRetired(d *Daemon, sess *session) bool {160 func moveSessionRetired(d *Daemon, sess *session) bool {
M internal/usecase/daemon/move_tab.go +27 -58
@@ -11,7 +11,6 @@ type moveTabAdmission struct {
11 tab *tab11 tab *tab
12 sourceIndex int12 sourceIndex int
13 sourceClient *attachedClient13 sourceClient *attachedClient
14- sourceSnatched []*attachedClient
15 layoutGeneration uint6414 layoutGeneration uint64
16 floatingState floatingState15 floatingState floatingState
17 floatingPane *pane16 floatingPane *pane
@@ -28,13 +27,13 @@ type moveTabCommit struct {
28 destination *session27 destination *session
29 admission moveTabAdmission28 admission moveTabAdmission
30 handoffFrozen bool29 handoffFrozen bool
31- sourceRolesFrozen bool30+ sourceEffectsFrozen bool
32- frozenRoles frozenRoleEffectGates31+ frozenEffects frozenAttachmentEffectGates
33 handoffReq attachmentTransitionRequest32 handoffReq attachmentTransitionRequest
34 handoffPublication *attachmentPublication33 handoffPublication *attachmentPublication
35 handoffResult attachmentTransitionResult34 handoffResult attachmentTransitionResult
36 syncCleanup syncTimerCleanup35 syncCleanup syncTimerCleanup
37- sourceCleanupToken attachmentRoleToken36+ sourceCleanupToken attachmentConnectionToken
38 oldTabCancel context.CancelFunc37 oldTabCancel context.CancelFunc
39 sourceEmpty bool38 sourceEmpty bool
40 retiredParked []parkedAttachmentRetirement39 retiredParked []parkedAttachmentRetirement
@@ -46,27 +45,6 @@ type moveTabCommit struct {
46 err error45 err error
47 }46 }
48 47
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-
70 func (d *Daemon) moveTab(req moveTabRequest) (result error) {48 func (d *Daemon) moveTab(req moveTabRequest) (result error) {
71 defer func() { result = normalizeMoveRejection(result) }()49 defer func() { result = normalizeMoveRejection(result) }()
72 if d == nil || req.Source.ID == "" || req.Destination.ID == "" || req.SourceTabID == "" || req.Source.ID == req.Destination.ID {50 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) {
111 d.afterMoveTabSourceSnapshot()89 d.afterMoveTabSourceSnapshot()
112 }90 }
113 commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}91 commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}
114- var frozen frozenRoleEffectGates92+ var frozen frozenAttachmentEffectGates
115 if admission.finalSource && admission.sourceClient != nil {93 if admission.finalSource && admission.sourceClient != nil {
116 transport := admission.sourceClient.transportSnapshot()94 transport := admission.sourceClient.transportSnapshot()
117 token := source.attachmentToken(admission.sourceClient, transport.transport)95 token := source.attachmentToken(admission.sourceClient, transport.transport)
118- if token.role != attachmentActive || token.transport.transport == nil {96+ if token.transport.transport == nil {
119 return errMovePaneInvalid97 return errMovePaneInvalid
120 }98 }
121 commit.handoffReq = attachmentTransitionRequest{99 commit.handoffReq = attachmentTransitionRequest{
122 source: source, target: destination, next: admission.sourceClient,100 source: source, target: destination, next: admission.sourceClient,
123- expectedRole: attachmentActive, targetRole: attachmentActive,101+
124 expectedTransport: token.transport, sourceToken: &token,102 expectedTransport: token.transport, sourceToken: &token,
125 expectedSourceTab: admission.tab, transferExpectedSourceTab: true, ready: true, action: "move-tab",103 expectedSourceTab: admission.tab, transferExpectedSourceTab: true, ready: true, action: "move-tab",
126 }104 }
@@ -129,12 +107,9 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
129 if err != nil {107 if err != nil {
130 return err108 return err
131 }109 }
132- snatchedClients, snatchedInterrupts := d.snapshotSnatchedRoleParticipants(source, admission.sourceSnatched)110+ frozen = freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
133- participants.clients = append(participants.clients, snatchedClients...)111+ if d.afterAttachmentEffectGateFrozen != nil {
134- participants.interrupts = append(participants.interrupts, snatchedInterrupts...)112+ d.afterAttachmentEffectGateFrozen("move-tab", ac)
135- frozen = freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, nonblocking: true, afterFrozen: func(ac *attachedClient) {
136- if d.afterRoleEffectGateFrozen != nil {
137- d.afterRoleEffectGateFrozen("move-tab", ac)
138 }113 }
139 }}, participants.clients...)114 }}, participants.clients...)
140 if !frozen.acquired || !frozen.drained {115 if !frozen.acquired || !frozen.drained {
@@ -142,21 +117,13 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
142 return errMovePaneInvalid117 return errMovePaneInvalid
143 }118 }
144 commit.handoffFrozen = true119 commit.handoffFrozen = true
145- commit.sourceRolesFrozen = true120+ commit.sourceEffectsFrozen = true
146- commit.handoffReq.roleEffectsFrozen = true121+ commit.handoffReq.attachmentEffectsFrozen = 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
155 }122 }
156- commit.frozenRoles = frozen123+ commit.frozenEffects = frozen
157- rolesFrozen := commit.sourceRolesFrozen124+ effectsFrozen := commit.sourceEffectsFrozen
158 defer func() {125 defer func() {
159- if rolesFrozen {126+ if effectsFrozen {
160 frozen.unfreeze()127 frozen.unfreeze()
161 }128 }
162 }()129 }()
@@ -180,8 +147,8 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
180 sourceCleanupToken: commit.sourceCleanupToken,147 sourceCleanupToken: commit.sourceCleanupToken,
181 handoffResult: commit.handoffResult,148 handoffResult: commit.handoffResult,
182 syncCleanup: commit.syncCleanup,149 syncCleanup: commit.syncCleanup,
183- frozenRoles: frozen,150+ frozenEffects: frozen,
184- rolesFrozen: rolesFrozen,151+ effectsFrozen: effectsFrozen,
185 unlockDispatch: unlockDispatch,152 unlockDispatch: unlockDispatch,
186 reservation: reservation,153 reservation: reservation,
187 oldTabCancel: commit.oldTabCancel,154 oldTabCancel: commit.oldTabCancel,
@@ -194,7 +161,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
194 destinationMetadata: commit.destMetadata,161 destinationMetadata: commit.destMetadata,
195 destinationMetadataValid: commit.destMetadataValid,162 destinationMetadataValid: commit.destMetadataValid,
196 }163 }
197- rolesFrozen = false164+ effectsFrozen = false
198 dispatchHeld = false165 dispatchHeld = false
199 reservationHeld = false166 reservationHeld = false
200 postcommit.execute(d)167 postcommit.execute(d)
@@ -239,9 +206,16 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
239 panes = append(panes, moved.floating.pane)206 panes = append(panes, moved.floating.pane)
240 }207 }
241 idx := indexMoveTabLocked(source, moved)208 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+ }
242 return &moveTabAdmission{216 return &moveTabAdmission{
243 tab: moved, sourceIndex: idx,217 tab: moved, sourceIndex: idx,
244- sourceClient: req.Client, sourceSnatched: nil,218+ sourceClient: sourceClient,
245 layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,219 layoutGeneration: moved.layoutGeneration, floatingState: moved.floating.state,
246 floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,220 floatingPane: moved.floating.pane, floatingGeneration: moved.floating.generation,
247 panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,221 panes: panes, destinationActive: destinationActive, destinationSize: destinationSize,
@@ -279,7 +253,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
279 // precede moved.mu. The resize fences and ordered session locks keep topology253 // precede moved.mu. The resize fences and ordered session locks keep topology
280 // stable until the tab/pane checks below complete.254 // stable until the tab/pane checks below complete.
281 if c.handoffFrozen {255 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) {
283 return false257 return false
284 }258 }
285 publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq)259 publication, err := d.validateAttachmentTransitionPrelocked(c.handoffReq)
@@ -292,15 +266,10 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
292 }266 }
293 var retirement frozenMoveAttachmentRetirement267 var retirement frozenMoveAttachmentRetirement
294 if c.admission.finalSource {268 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)) ||
296 (c.admission.sourceClient != nil) != c.handoffFrozen {270 (c.admission.sourceClient != nil) != c.handoffFrozen {
297 return false271 return false
298 }272 }
299- var retirementOK bool
300- retirement, retirementOK = prepareFrozenMoveAttachmentRetirementLocked(c.source, c.admission.sourceSnatched, c.frozenRoles)
301- if !retirementOK {
302- return false
303- }
304 }273 }
305 if len(c.destination.tabs) == 0 || c.destination.tabs[0] != c.admission.destinationActive {274 if len(c.destination.tabs) == 0 || c.destination.tabs[0] != c.admission.destinationActive {
306 return false275 return false
M internal/usecase/daemon/move_tab_test.go +6 -6
@@ -160,7 +160,7 @@ func TestMoveTabFinalSourceClientFollowsAndActivatesMovedTab(t *testing.T) {
160 require.Nil(t, source.tabs)160 require.Nil(t, source.tabs)
161 require.Same(t, destination, client.currentSession())161 require.Same(t, destination, client.currentSession())
162 require.Contains(t, destination.snapshotAttachments(), client)162 require.Contains(t, destination.snapshotAttachments(), client)
163- require.Equal(t, attachmentActive, destination.attachmentRole(client))163+ require.Equal(t, true, destination.attachmentRegistered(client))
164 require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs)164 require.Equal(t, []*tab{oldDestinationActive, moved}, destination.tabs)
165 require.Same(t, moved, destination.tabs[testAttachmentTabIndex(destination)], "final-source follower activates the moved tab")165 require.Same(t, moved, destination.tabs[testAttachmentTabIndex(destination)], "final-source follower activates the moved tab")
166 require.True(t, rebased)166 require.True(t, rebased)
@@ -241,7 +241,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
241 241
242 source.layoutApplyMu.Lock()242 source.layoutApplyMu.Lock()
243 admitted := make(chan struct{})243 admitted := make(chan struct{})
244- d.afterRoleEffectGateFrozen = func(action string, ac *attachedClient) {244+ d.afterAttachmentEffectGateFrozen = func(action string, ac *attachedClient) {
245 if action == "move-tab" && ac == client {245 if action == "move-tab" && ac == client {
246 close(admitted)246 close(admitted)
247 }247 }
@@ -262,7 +262,7 @@ func TestMoveTabFinalSourceVisibleFloatingRejectsStaleSlotTransitions(t *testing
262 require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return"))262 require.Error(t, awaitTestValue(t, moveDone, "stale floating transfer did not return"))
263 require.NotContains(t, destination.tabs, moved)263 require.NotContains(t, destination.tabs, moved)
264 require.Same(t, source, floating.ownerSnapshot().session)264 require.Same(t, source, floating.ownerSnapshot().session)
265- d.afterRoleEffectGateFrozen = nil265+ d.afterAttachmentEffectGateFrozen = nil
266 })266 })
267 }267 }
268 }268 }
@@ -487,12 +487,12 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.
487 sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()487 sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()
488 destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()488 destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()
489 tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()489 tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()
490- roleUnlocked := r.client.roleEffects.mu.TryLock()490+ roleUnlocked := r.client.attachmentEffects.mu.TryLock()
491- roleStable := roleUnlocked && r.client.roleEffects.phase == roleEffectsStable491+ roleStable := roleUnlocked && r.client.attachmentEffects.phase == attachmentEffectsStable
492 r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&492 r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&
493 sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable493 sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable
494 if roleUnlocked {494 if roleUnlocked {
495- r.client.roleEffects.mu.Unlock()495+ r.client.attachmentEffects.mu.Unlock()
496 }496 }
497 if tabFenceUnlocked {497 if tabFenceUnlocked {
498 r.moved.layoutApplyMu.Unlock()498 r.moved.layoutApplyMu.Unlock()
M internal/usecase/daemon/move_test.go +1 -1
@@ -116,7 +116,7 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) {
116 require.Nil(t, source.tabs)116 require.Nil(t, source.tabs)
117 require.Same(t, destination, client.currentSession())117 require.Same(t, destination, client.currentSession())
118 require.Contains(t, destination.snapshotAttachments(), client)118 require.Contains(t, destination.snapshotAttachments(), client)
119- require.Equal(t, attachmentActive, destination.attachmentRole(client))119+ require.Equal(t, true, destination.attachmentRegistered(client))
120 require.Same(t, destinationTab, destination.tabs[testAttachmentTabIndex(destination)])120 require.Same(t, destinationTab, destination.tabs[testAttachmentTabIndex(destination)])
121 require.True(t, rebased.Load(), "final-source follower must rebase output before its first paint")121 require.True(t, rebased.Load(), "final-source follower must rebase output before its first paint")
122 require.NoError(t, moved.ctx.Err(), "transferred pane process must remain alive")122 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) {
16 tests := []struct {16 tests := []struct {
17 name string17 name string
18 action string18 action string
19- run func(*Daemon, *session, *session, *attachedClient, *roleEffectTicket) error19+ run func(*Daemon, *session, *session, *attachedClient, *attachmentEffectTicket) error
20 check func(*testing.T, *Daemon, *session, *session)20 check func(*testing.T, *Daemon, *session, *session)
21 }{21 }{
22 {22 {
23 name: "picker selection", action: "picker-select",23 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 {
25 d.enterPicker(source, ac)25 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")
27 },27 },
28 },28 },
29 {29 {
30 name: "palette session", action: "palette-session",30 name: "palette session", action: "palette-session",
31- run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *roleEffectTicket) error {31+ run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
32- return d.switchToTargetForRole(effect.roleToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")32+ return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
33 },33 },
34 },34 },
35 {35 {
36 name: "palette recent session", action: "palette-recent-session",36 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 {
38 return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1)38 return paletteExec{d: d, sess: source, ac: ac, recent: []recentSession{{id: target.id}}, effect: effect}.JumpRecentSession(1)
39 },39 },
40 },40 },
41 {41 {
42 name: "session overflow", action: "overflow-session",42 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 {
44 source.mu.Lock()44 source.mu.Lock()
45 source.name = "alpha"45 source.name = "alpha"
46 source.mu.Unlock()46 source.mu.Unlock()
@@ -53,13 +53,13 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
53 },53 },
54 {54 {
55 name: "stopped session resume", action: "stopped-session",55 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 {
57 d.mu.Lock()57 d.mu.Lock()
58 delete(d.sessions, target.id)58 delete(d.sessions, target.id)
59 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionStopped}59 d.stopped["stopped"] = stoppedSession{name: "stopped", cwd: "/tmp", createdAt: 9, state: ports.SessionStopped}
60 d.mu.Unlock()60 d.mu.Unlock()
61 expectedCreatedAt := int64(9)61 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")
63 },63 },
64 check: func(t *testing.T, d *Daemon, _, _ *session) {64 check: func(t *testing.T, d *Daemon, _, _ *session) {
65 d.mu.Lock()65 d.mu.Lock()
@@ -72,8 +72,8 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
72 },72 },
73 {73 {
74 name: "transition prompt creation", action: "create-session",74 name: "transition prompt creation", action: "create-session",
75- run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *roleEffectTicket) error {75+ run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffectTicket) error {
76- return d.createSessionAndSwitchForRole(effect.roleToken(), "created")76+ return d.createSessionAndSwitchForAttachment(effect.connectionToken(), "created")
77 },77 },
78 check: func(t *testing.T, d *Daemon, _, _ *session) {78 check: func(t *testing.T, d *Daemon, _, _ *session) {
79 d.mu.Lock()79 d.mu.Lock()
@@ -100,14 +100,14 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
100 rc := d.attachCoordinator(source, nil, old, true)100 rc := d.attachCoordinator(source, nil, old, true)
101 token := source.attachmentToken(old, old.transport())101 token := source.attachmentToken(old, old.transport())
102 token.lease = rc.attachmentLease(old)102 token.lease = rc.attachmentLease(old)
103- old.publishRoleCapability(token)103+ old.publishAttachmentCapability(token)
104- effect, admitted := old.beginRoleEffect(token)104+ effect, admitted := old.beginAttachmentEffect(token)
105 require.True(t, admitted)105 require.True(t, admitted)
106 106
107 admissionEnded := make(chan struct{})107 admissionEnded := make(chan struct{})
108 releaseAction := make(chan struct{})108 releaseAction := make(chan struct{})
109 var admissionEndedOnce sync.Once109 var admissionEndedOnce sync.Once
110- d.afterActionRoleEffectEnded = func(action string) {110+ d.afterActionAttachmentEffectEnded = func(action string) {
111 if action == tt.action {111 if action == tt.action {
112 admissionEndedOnce.Do(func() { close(admissionEnded) })112 admissionEndedOnce.Do(func() { close(admissionEnded) })
113 <-releaseAction113 <-releaseAction
@@ -123,7 +123,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
123 123
124 replacement, err := d.transitionAttachment(attachmentTransitionRequest{124 replacement, err := d.transitionAttachment(attachmentTransitionRequest{
125 source: source, target: source, next: old,125 source: source, target: source, next: old,
126- expectedRole: attachmentActive, targetRole: attachmentActive,126+
127 expectedTransport: old.transportSnapshot(), ready: true,127 expectedTransport: old.transportSnapshot(), ready: true,
128 })128 })
129 require.NoError(t, err)129 require.NoError(t, err)
@@ -155,14 +155,14 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
155 rc := d.attachCoordinator(source, nil, old, true)155 rc := d.attachCoordinator(source, nil, old, true)
156 token := source.attachmentToken(old, old.transport())156 token := source.attachmentToken(old, old.transport())
157 token.lease = rc.attachmentLease(old)157 token.lease = rc.attachmentLease(old)
158- old.publishRoleCapability(token)158+ old.publishAttachmentCapability(token)
159- effect, admitted := old.beginRoleEffect(token)159+ effect, admitted := old.beginAttachmentEffect(token)
160 require.True(t, admitted)160 require.True(t, admitted)
161 161
162 ended := make(chan struct{})162 ended := make(chan struct{})
163 release := make(chan struct{})163 release := make(chan struct{})
164 var endedOnce sync.Once164 var endedOnce sync.Once
165- d.afterActionRoleEffectEnded = func(action string) {165+ d.afterActionAttachmentEffectEnded = func(action string) {
166 if action == "picker-stopped" {166 if action == "picker-stopped" {
167 endedOnce.Do(func() { close(ended) })167 endedOnce.Do(func() { close(ended) })
168 <-release168 <-release
@@ -171,7 +171,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
171 expectedCreatedAt := int64(7)171 expectedCreatedAt := int64(7)
172 done := make(chan error, 1)172 done := make(chan error, 1)
173 go func() {173 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")
175 }()175 }()
176 select {176 select {
177 case <-ended:177 case <-ended:
@@ -182,8 +182,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
182 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}182 next := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: old.size}
183 next.initOverlays()183 next.initOverlays()
184 replacement, err := d.transitionAttachment(attachmentTransitionRequest{184 replacement, err := d.transitionAttachment(attachmentTransitionRequest{
185- target: source, next: next, expectedRole: attachmentDetached, targetRole: attachmentActive,185+ target: source, next: next, expectedTransport: next.transportSnapshot(), ready: true,
186- expectedTransport: next.transportSnapshot(), ready: true,
187 })186 })
188 require.NoError(t, err)187 require.NoError(t, err)
189 close(release)188 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
18 rebind: func(t *testing.T, d *Daemon, source *session, ac *attachedClient) {18 rebind: func(t *testing.T, d *Daemon, source *session, ac *attachedClient) {
19 result, err := d.transitionAttachment(attachmentTransitionRequest{19 result, err := d.transitionAttachment(attachmentTransitionRequest{
20 source: source, target: source, next: ac,20 source: source, target: source, next: ac,
21- expectedRole: attachmentActive, targetRole: attachmentActive,21+
22 expectedTransport: ac.transportSnapshot(), ready: true,22 expectedTransport: ac.transportSnapshot(), ready: true,
23 })23 })
24 require.NoError(t, err)24 require.NoError(t, err)
@@ -31,7 +31,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
31 ac.replaceTransport(&closeTrackingTransport{})31 ac.replaceTransport(&closeTrackingTransport{})
32 current := source.attachmentToken(ac, ac.transport())32 current := source.attachmentToken(ac, ac.transport())
33 current.lease = source.renderCoordinator().attachmentLease(ac)33 current.lease = source.renderCoordinator().attachmentLease(ac)
34- ac.publishRoleCapability(current)34+ ac.publishAttachmentCapability(current)
35 },35 },
36 },36 },
37 }37 }
@@ -50,13 +50,13 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
50 rc := d.attachCoordinator(source, nil, ac, true)50 rc := d.attachCoordinator(source, nil, ac, true)
51 token := source.attachmentToken(ac, ac.transport())51 token := source.attachmentToken(ac, ac.transport())
52 token.lease = rc.attachmentLease(ac)52 token.lease = rc.attachmentLease(ac)
53- ac.publishRoleCapability(token)53+ ac.publishAttachmentCapability(token)
54- effect, admitted := ac.beginRoleEffect(token)54+ effect, admitted := ac.beginAttachmentEffect(token)
55 require.True(t, admitted)55 require.True(t, admitted)
56 56
57 ended := make(chan struct{})57 ended := make(chan struct{})
58 release := make(chan struct{})58 release := make(chan struct{})
59- d.afterActionRoleEffectEnded = func(action string) {59+ d.afterActionAttachmentEffectEnded = func(action string) {
60 if action == "test-handoff" {60 if action == "test-handoff" {
61 close(ended)61 close(ended)
62 <-release62 <-release
@@ -64,7 +64,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing
64 }64 }
65 done := make(chan error, 1)65 done := make(chan error, 1)
66 go func() {66 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")
68 }()68 }()
69 awaitTestCompletion(t, ended, "test handoff did not release its role ticket")69 awaitTestCompletion(t, ended, "test handoff did not release its role ticket")
70 tt.rebind(t, d, source, ac)70 tt.rebind(t, d, source, ac)
M internal/usecase/daemon/navigation_overflow_test.go +1 -1
@@ -130,7 +130,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t
130 }130 }
131 131
132 for _, target := range []*session{charlie, echo} {132 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")
134 target.tabs[1].mu.Lock()134 target.tabs[1].mu.Lock()
135 require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus")135 require.Equal(t, layout.PaneID("pane-2"), target.tabs[1].tree.Focus, "switch preserves the target pane focus")
136 target.tabs[1].mu.Unlock()136 target.tabs[1].mu.Unlock()
M internal/usecase/daemon/notices_overlay_test.go +2 -2
@@ -434,7 +434,7 @@ func TestYankLastNotificationWithEmptyHistoryShowsWarnToast(t *testing.T) {
434 func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T) {434 func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T) {
435 d, proxy, ac, _, handler := newProxyInputHarness(t)435 d, proxy, ac, _, handler := newProxyInputHarness(t)
436 handler.enterPalette()436 handler.enterPalette()
437- d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect)437+ d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect)
438 438
439 history := d.notices.history()439 history := d.notices.history()
440 require.Len(t, history, 1)440 require.Len(t, history, 1)
@@ -444,7 +444,7 @@ func TestProxyYankLastNotificationEmptyHistoryUsesAttachmentScope(t *testing.T)
444 444
445 func TestProxyNoticeYankSendFailureDetachesAndArmsWarmExpiry(t *testing.T) {445 func TestProxyNoticeYankSendFailureDetachesAndArmsWarmExpiry(t *testing.T) {
446 d, proxy, ac, _, handler := newProxyInputHarness(t)446 d, proxy, ac, _, handler := newProxyInputHarness(t)
447- handler.roleToken.effect.End()447+ handler.connectionToken.effect.End()
448 client, ok := ac.transport().(*proxyTestTransport)448 client, ok := ac.transport().(*proxyTestTransport)
449 require.True(t, ok)449 require.True(t, ok)
450 client.sendFails.Store(true)450 client.sendFails.Store(true)
M internal/usecase/daemon/notify_test.go +3 -3
@@ -86,7 +86,7 @@ func TestHandleClientNoticeSerializesReplacement(t *testing.T) {
86 tr, _ := newCapturingTransport(t)86 tr, _ := newCapturingTransport(t)
87 replaced := make(chan *attachedClient, 1)87 replaced := make(chan *attachedClient, 1)
88 go func() {88 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{})
90 replaced <- current90 replaced <- current
91 }()91 }()
92 92
@@ -134,7 +134,7 @@ func TestHandleClientNoticeRejectsReplacedClient(t *testing.T) {
134 tt.setup(d, old, sess.id)134 tt.setup(d, old, sess.id)
135 }135 }
136 tr, _ := newCapturingTransport(t)136 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{})
138 d.attachmentCleanupWg.Wait()138 d.attachmentCleanupWg.Wait()
139 139
140 d.handleClientNotice(sess, old, tt.notice)140 d.handleClientNotice(sess, old, tt.notice)
@@ -767,7 +767,7 @@ func TestNotifyGlobalSerializesReplacementWithDelivery(t *testing.T) {
767 tr, _ := newCapturingTransport(t)767 tr, _ := newCapturingTransport(t)
768 replaced := make(chan *attachedClient, 1)768 replaced := make(chan *attachedClient, 1)
769 go func() {769 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{})
771 replaced <- ac771 replaced <- ac
772 }()772 }()
773 close(releaseRoute)773 close(releaseRoute)
M internal/usecase/daemon/overlay_runtime.go +3 -3
@@ -48,7 +48,7 @@ type overlayRuntime struct {
48 promptMu sync.Mutex48 promptMu sync.Mutex
49 prompt *promptui.Model49 prompt *promptui.Model
50 promptSubmit func(string) error50 promptSubmit func(string) error
51- promptTransitionSubmit func(string, attachmentRoleToken) error51+ promptTransitionSubmit func(string, attachmentConnectionToken) error
52 promptPending []byte52 promptPending []byte
53 53
54 resizeMu sync.Mutex54 resizeMu sync.Mutex
@@ -242,12 +242,12 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
242 return active || prePublication242 return active || prePublication
243 }243 }
244 244
245-func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*roleEffectTicket) bool {245+func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attachmentEffectTicket) bool {
246 if rt == nil || rt.ac == nil {246 if rt == nil || rt.ac == nil {
247 return false247 return false
248 }248 }
249 ac := rt.ac249 ac := rt.ac
250- var effect *roleEffectTicket250+ var effect *attachmentEffectTicket
251 if len(effects) != 0 {251 if len(effects) != 0 {
252 effect = effects[0]252 effect = effects[0]
253 }253 }
M internal/usecase/daemon/palette.go +15 -15
@@ -174,7 +174,7 @@ func (d *Daemon) recordPaletteUse(code string) {
174 d.paletteRecent = recent174 d.paletteRecent = recent
175 }175 }
176 176
177-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {177+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
178 entry := ac.currentAttachmentSession()178 entry := ac.currentAttachmentSession()
179 if entry == nil {179 if entry == nil {
180 return180 return
@@ -189,7 +189,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
189 var generation uint64189 var generation uint64
190 var rawQuery string190 var rawQuery string
191 changed, cancel, execute := false, false, false191 changed, cancel, execute := false, false, false
192- var effect *roleEffectTicket192+ var effect *attachmentEffectTicket
193 if len(effects) != 0 {193 if len(effects) != 0 {
194 effect = effects[0]194 effect = effects[0]
195 }195 }
@@ -305,7 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
305 }305 }
306 var err error306 var err error
307 if effect != nil {307 if effect != nil {
308- err = d.switchToTargetForRole(effect.roleToken(), target, sessionHandoffGuard{}, "palette-session")308+ err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-session")
309 } else if local {309 } else if local {
310 err = d.switchToTarget(sess, ac, target)310 err = d.switchToTarget(sess, ac, target)
311 } else {311 } else {
@@ -389,10 +389,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
389 }389 }
390 case "back-session":390 case "back-session":
391 if effect != nil {391 if effect != nil {
392- err = d.backSessionForRole(effect.roleToken())392+ err = d.backSessionForAttachment(effect.connectionToken())
393 }393 }
394 case "detach":394 case "detach":
395- if effect != nil && !d.clientGoneForRole(effect.roleToken(), true) {395+ if effect != nil && !d.clientGoneForAttachment(effect.connectionToken(), true) {
396 err = errAttachmentTransition396 err = errAttachmentTransition
397 }397 }
398 case "session-picker":398 case "session-picker":
@@ -413,17 +413,17 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
413 }413 }
414 return414 return
415 }415 }
416- roleHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"416+ attachmentHandoff := cmd.Slug == "back-session" || cmd.Slug == "detach"
417- if !roleHandoff && !ac.closeExecutedPalette(generation, rawQuery) {417+ if !attachmentHandoff && !ac.closeExecutedPalette(generation, rawQuery) {
418 return418 return
419 }419 }
420 sess.dispatchMu.Lock()420 sess.dispatchMu.Lock()
421 err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)421 err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)
422 sess.dispatchMu.Unlock()422 sess.dispatchMu.Unlock()
423- if roleHandoff {423+ if attachmentHandoff {
424 if current := ac.currentSession(); current != nil {424 if current := ac.currentSession(); current != nil {
425 currentToken := current.attachmentToken(ac, ac.transport())425 currentToken := current.attachmentToken(ac, ac.transport())
426- fresh, admitted := ac.beginRoleEffect(currentToken)426+ fresh, admitted := ac.beginAttachmentEffect(currentToken)
427 if ac.closeExecutedPalette(generation, rawQuery) {427 if ac.closeExecutedPalette(generation, rawQuery) {
428 d.invalidateRender(current, ac, true, "palette.go")428 d.invalidateRender(current, ac, true, "palette.go")
429 }429 }
@@ -495,7 +495,7 @@ type paletteExec struct {
495 ac *attachedClient495 ac *attachedClient
496 recent []recentSession496 recent []recentSession
497 actions daemonActionRunner497 actions daemonActionRunner
498- effect *roleEffectTicket498+ effect *attachmentEffectTicket
499 redrawClosedPalette bool499 redrawClosedPalette bool
500 }500 }
501 501
@@ -532,7 +532,7 @@ func (e paletteExec) CreateSession() error {
532 if entry == nil {532 if entry == nil {
533 entry = e.sess533 entry = e.sess
534 }534 }
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 {
536 if token.ac == nil {536 if token.ac == nil {
537 sess, ok := e.localSession()537 sess, ok := e.localSession()
538 if !ok {538 if !ok {
@@ -540,7 +540,7 @@ func (e paletteExec) CreateSession() error {
540 }540 }
541 return e.d.createSessionAndSwitch(sess, e.ac, name)541 return e.d.createSessionAndSwitch(sess, e.ac, name)
542 }542 }
543- return e.d.createSessionAndSwitchForRole(token, name)543+ return e.d.createSessionAndSwitchForAttachment(token, name)
544 })544 })
545 return nil545 return nil
546 }546 }
@@ -639,7 +639,7 @@ func (e paletteExec) ToggleFloatingPane() error {
639 639
640 func (e paletteExec) BackSession() error {640 func (e paletteExec) BackSession() error {
641 if e.effect != nil {641 if e.effect != nil {
642- return e.d.backSessionForRole(e.effect.roleToken())642+ return e.d.backSessionForAttachment(e.effect.connectionToken())
643 }643 }
644 e.d.backSession(e.sess, e.ac)644 e.d.backSession(e.sess, e.ac)
645 return nil645 return nil
@@ -647,7 +647,7 @@ func (e paletteExec) BackSession() error {
647 647
648 func (e paletteExec) Detach() error {648 func (e paletteExec) Detach() error {
649 if e.effect != nil {649 if e.effect != nil {
650- if !e.d.clientGoneForRole(e.effect.roleToken(), true) {650+ if !e.d.clientGoneForAttachment(e.effect.connectionToken(), true) {
651 return errAttachmentTransition651 return errAttachmentTransition
652 }652 }
653 return nil653 return nil
@@ -743,7 +743,7 @@ func (e paletteExec) JumpRecentSession(rank int) error {
743 }743 }
744 var err error744 var err error
745 if e.effect != nil {745 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")
747 } else {747 } else {
748 sess, ok := e.localSession()748 sess, ok := e.localSession()
749 if !ok {749 if !ok {
M internal/usecase/daemon/palette_test.go +11 -11
@@ -138,7 +138,7 @@ func TestRemotePaletteClosesBeforeOpeningLocalOverlay(t *testing.T) {
138 t.Run(test.name, func(t *testing.T) {138 t.Run(test.name, func(t *testing.T) {
139 d, _, ac, link, handler := newProxyInputHarness(t)139 d, _, ac, link, handler := newProxyInputHarness(t)
140 handler.enterPalette()140 handler.enterPalette()
141- d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.roleToken.effect)141+ d.handlePaletteInput(ac, []byte(test.code+"\r"), handler.connectionToken.effect)
142 142
143 require.False(t, ac.overlays.paletteActive(), "stale remote palette remained active")143 require.False(t, ac.overlays.paletteActive(), "stale remote palette remained active")
144 require.True(t, test.active(ac.overlays), "replacement local overlay was not opened")144 require.True(t, test.active(ac.overlays), "replacement local overlay was not opened")
@@ -361,14 +361,14 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
361 361
362 d.handleInput(current, ac, []byte("\x1b "))362 d.handleInput(current, ac, []byte("\x1b "))
363 awaitFrame(t, sends, ports.MsgOutput)363 awaitFrame(t, sends, ports.MsgOutput)
364- generation := ac.roleGeneration.Load()364+ generation := ac.connectionGeneration.Load()
365 d.handleInput(current, ac, []byte("stopped\r"))365 d.handleInput(current, ac, []byte("stopped\r"))
366 366
367 resumed := ac.currentSession()367 resumed := ac.currentSession()
368 require.Equal(t, "stopped", resumed.name)368 require.Equal(t, "stopped", resumed.name)
369 require.Equal(t, int64(42), resumed.createdAt)369 require.Equal(t, int64(42), resumed.createdAt)
370- require.Equal(t, attachmentActive, resumed.attachmentRole(ac))370+ require.Equal(t, true, resumed.attachmentRegistered(ac))
371- require.Greater(t, ac.roleGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition")371+ require.Greater(t, ac.connectionGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition")
372 require.False(t, ac.overlays.paletteActive())372 require.False(t, ac.overlays.paletteActive())
373 firstPaint := awaitFrame(t, sends, ports.MsgOutput)373 firstPaint := awaitFrame(t, sends, ports.MsgOutput)
374 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)374 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
@@ -486,7 +486,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
486 invalidations := installPaletteInvalidationObserver(current)486 invalidations := installPaletteInvalidationObserver(current)
487 d.enterPalette(current, ac)487 d.enterPalette(current, ac)
488 d.handlePaletteInput(ac, []byte("BSK"))488 d.handlePaletteInput(ac, []byte("BSK"))
489- d.afterActionRoleEffectEnded = func(action string) {489+ d.afterActionAttachmentEffectEnded = func(action string) {
490 if action == "back-session" {490 if action == "back-session" {
491 d.mu.Lock()491 d.mu.Lock()
492 delete(d.sessions, target.core().id)492 delete(d.sessions, target.core().id)
@@ -495,7 +495,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
495 }495 }
496 496
497 token := current.attachmentToken(ac, ac.transport())497 token := current.attachmentToken(ac, ac.transport())
498- effect, admitted := ac.beginRoleEffect(token)498+ effect, admitted := ac.beginAttachmentEffect(token)
499 require.True(t, admitted)499 require.True(t, admitted)
500 d.handlePaletteInput(ac, []byte("\r"), effect)500 d.handlePaletteInput(ac, []byte("\r"), effect)
501 501
@@ -505,7 +505,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) {
505 require.True(t, invalidation.reset)505 require.True(t, invalidation.reset)
506 }506 }
507 507
508-func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) {508+func TestPaletteDeniedPostHandoffAttachmentEffectClosesAndInvalidates(t *testing.T) {
509 p, release := newBlockingPTY(t)509 p, release := newBlockingPTY(t)
510 defer release()510 defer release()
511 d, sess, ac, _ := newManualSessionWithPTYs(t, p)511 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
@@ -514,7 +514,7 @@ func TestPaletteDeniedPostHandoffRoleEffectClosesAndInvalidates(t *testing.T) {
514 d.enterPalette(sess, ac)514 d.enterPalette(sess, ac)
515 d.handlePaletteInput(ac, []byte("BSK"))515 d.handlePaletteInput(ac, []byte("BSK"))
516 // Make the attachment token detached while retaining currentSession. The516 // Make the attachment token detached while retaining currentSession. The
517- // no-op BSK command succeeds, but its post-execution beginRoleEffect is517+ // no-op BSK command succeeds, but its post-execution beginAttachmentEffect is
518 // deterministically denied.518 // deterministically denied.
519 sess.mu.Lock()519 sess.mu.Lock()
520 clearAttachmentsForTestLocked(sess)520 clearAttachmentsForTestLocked(sess)
@@ -671,7 +671,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
671 require.True(t, ac.overlays.promptActive())671 require.True(t, ac.overlays.promptActive())
672 require.Contains(t, string(promptOutput.Data), "Create session")672 require.Contains(t, string(promptOutput.Data), "Create session")
673 673
674- generation := ac.roleGeneration.Load()674+ generation := ac.connectionGeneration.Load()
675 d.handleInput(sess, ac, []byte("scratch\r"))675 d.handleInput(sess, ac, []byte("scratch\r"))
676 // The submit first paints the newly attached session while the prompt is676 // The submit first paints the newly attached session while the prompt is
677 // still open, then handlePromptInput closes the prompt and repaints the677 // still open, then handlePromptInput closes the prompt and repaints the
@@ -692,8 +692,8 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
692 require.Equal(t, "scratch", newSess.name)692 require.Equal(t, "scratch", newSess.name)
693 require.False(t, newSess.ephemeral)693 require.False(t, newSess.ephemeral)
694 require.Contains(t, newSess.snapshotAttachments(), ac)694 require.Contains(t, newSess.snapshotAttachments(), ac)
695- require.Equal(t, attachmentActive, newSess.attachmentRole(ac))695+ require.Equal(t, true, newSess.attachmentRegistered(ac))
696- require.Greater(t, ac.roleGeneration.Load(), generation, "new-session handoff must publish through the attachment transition")696+ require.Greater(t, ac.connectionGeneration.Load(), generation, "new-session handoff must publish through the attachment transition")
697 require.Contains(t, string(finalOutput.Data), "scratch")697 require.Contains(t, string(finalOutput.Data), "scratch")
698 require.NotContains(t, string(finalOutput.Data), "Create session")698 require.NotContains(t, string(finalOutput.Data), "Create session")
699 }699 }
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
356 return nil356 return nil
357 }357 }
358 358
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 {
360 target := resolveDaemonActionTargetForAttachment(sess, ac)360 target := resolveDaemonActionTargetForAttachment(sess, ac)
361 oldFocus := layout.PaneID("")361 oldFocus := layout.PaneID("")
362 if target.pane != nil {362 if target.pane != nil {
@@ -382,7 +382,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
382 return errNoNeighbor382 return errNoNeighbor
383 }383 }
384 if effect != nil {384 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")
386 }386 }
387 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)387 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
388 }388 }
M internal/usecase/daemon/picker.go +47 -48
@@ -258,7 +258,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
258 }258 }
259 }259 }
260 260
261-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {261+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
262 sess := ac.currentAttachmentSession()262 sess := ac.currentAttachmentSession()
263 if sess == nil {263 if sess == nil {
264 return264 return
@@ -303,7 +303,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
303 return303 return
304 }304 }
305 if result.action == 'x' {305 if result.action == 'x' {
306- var effect *roleEffectTicket306+ var effect *attachmentEffectTicket
307 if len(effects) != 0 {307 if len(effects) != 0 {
308 effect = effects[0]308 effect = effects[0]
309 }309 }
@@ -311,7 +311,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
311 var err error311 var err error
312 if effect != nil {312 if effect != nil {
313 effect.bindActionEnd(d, "picker-delete")313 effect.bindActionEnd(d, "picker-delete")
314- err = d.killPickerTargetForRole(target, effect.roleToken())314+ err = d.killPickerTargetForAttachment(target, effect.connectionToken())
315 } else {315 } else {
316 err = d.killPickerTarget(target)316 err = d.killPickerTarget(target)
317 }317 }
@@ -320,7 +320,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
320 }320 }
321 }321 }
322 if effect != nil && effect.ended.Load() {322 if effect != nil && effect.ended.Load() {
323- fresh, admitted := ac.beginRoleEffect(effect.token)323+ fresh, admitted := ac.beginAttachmentEffect(effect.token)
324 if !admitted {324 if !admitted {
325 return325 return
326 }326 }
@@ -359,7 +359,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
359 }359 }
360 var err error360 var err error
361 if len(effects) != 0 && effects[0] != nil {361 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")
363 } else if local, ok := localSession(sess); ok {363 } else if local, ok := localSession(sess); ok {
364 d.closePicker(ac)364 d.closePicker(ac)
365 err = d.switchToTarget(local, ac, target)365 err = d.switchToTarget(local, ac, target)
@@ -581,12 +581,12 @@ type sessionHandoffGuard struct {
581 closePicker bool581 closePicker bool
582 }582 }
583 583
584-// switchActiveTargetForRole hands a frame-bound navigation request to the584+// switchActiveTargetForAttachment hands a frame-bound navigation request to the
585 // centralized transition. The transition releases admission at its freeze seam585 // centralized transition. The transition releases admission at its freeze seam
586 // and revalidates the exact initiating token before changing target focus or586 // and revalidates the exact initiating token before changing target focus or
587 // attachment ownership.587 // attachment ownership.
588-func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target picker.Target) error {588+func (d *Daemon) switchActiveTargetForAttachment(token attachmentConnectionToken, target picker.Target) error {
589- return d.switchActiveTargetForRoleGuarded(token, target, sessionHandoffGuard{}, "jump-attention")589+ return d.switchActiveTargetForAttachmentGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
590 }590 }
591 591
592 func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {592 func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
@@ -611,15 +611,15 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence
611 return fence611 return fence
612 }612 }
613 613
614-func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {614+func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
615- if token.sess == nil || token.ac == nil || token.role != attachmentActive {615+ if token.sess == nil || token.ac == nil || false {
616 return nil616 return nil
617 }617 }
618 d.mu.Lock()618 d.mu.Lock()
619 targetSess, ok := localSession(d.sessions[target.Session])619 targetSess, ok := localSession(d.sessions[target.Session])
620 d.mu.Unlock()620 d.mu.Unlock()
621 if !ok {621 if !ok {
622- if !token.activeCurrent() {622+ if !token.attachmentCurrent() {
623 return nil623 return nil
624 }624 }
625 d.invalidateRender(token.sess, token.ac, true, "picker.go")625 d.invalidateRender(token.sess, token.ac, true, "picker.go")
@@ -631,7 +631,7 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar
631 631
632 transition, err := d.transitionAttachment(attachmentTransitionRequest{632 transition, err := d.transitionAttachment(attachmentTransitionRequest{
633 source: token.sess, target: targetSess, next: token.ac,633 source: token.sess, target: targetSess, next: token.ac,
634- expectedRole: attachmentActive, targetRole: attachmentActive,634+
635 expectedTransport: token.transport, sourceToken: &token, action: action,635 expectedTransport: token.transport, sourceToken: &token, action: action,
636 expectedTargetLifecycle: pickerTargetLifecycleFence(target),636 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
637 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,637 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
@@ -639,13 +639,13 @@ func (d *Daemon) switchActiveTargetForRoleGuarded(token attachmentRoleToken, tar
639 if err != nil {639 if err != nil {
640 // Losing the exact source role is a benign stale action, not a notice for640 // Losing the exact source role is a benign stale action, not a notice for
641 // whichever attachment replaced the initiator.641 // whichever attachment replaced the initiator.
642- if !token.activeCurrent() {642+ if !token.attachmentCurrent() {
643 return nil643 return nil
644 }644 }
645 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)645 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
646 }646 }
647 if guard.closePicker {647 if guard.closePicker {
648- fresh, admitted := token.ac.beginRoleEffect(transition.published)648+ fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
649 if admitted {649 if admitted {
650 d.closePicker(token.ac)650 d.closePicker(token.ac)
651 fresh.End()651 fresh.End()
@@ -665,34 +665,34 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
665 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})665 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
666 }666 }
667 667
668-// switchToTargetForRole is the sole client-originated navigation entry. The668+// switchToTargetForAttachment is the sole client-originated navigation entry. The
669 // intent captures the exact initiating capability before admission is released;669 // intent captures the exact initiating capability before admission is released;
670 // every active, stopped, and same-session target then uses transitionAttachment670 // every active, stopped, and same-session target then uses transitionAttachment
671 // for frozen, atomic source-token preflight.671 // for frozen, atomic source-token preflight.
672-func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker.Target, guard sessionHandoffGuard, action string) error {672+func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
673- if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.effect == nil {673+ if token.sess == nil || token.ac == nil || token.effect == nil {
674 return nil674 return nil
675 }675 }
676 token.effect.bindActionEnd(d, action)676 token.effect.bindActionEnd(d, action)
677 token.effect.End()677 token.effect.End()
678 if target.RemoteKey != nil {678 if target.RemoteKey != nil {
679- return d.switchToRemoteTargetForRole(token, target, *target.RemoteKey, guard, action)679+ return d.switchToRemoteTargetForAttachment(token, target, *target.RemoteKey, guard, action)
680 }680 }
681 if token.sess.isProxy() && !target.Stopped {681 if token.sess.isProxy() && !target.Stopped {
682- return d.switchActiveTargetForRoleGuarded(token, target, guard, action)682+ return d.switchActiveTargetForAttachmentGuarded(token, target, guard, action)
683 }683 }
684 sess, ok := localSession(token.sess)684 sess, ok := localSession(token.sess)
685 if !ok {685 if !ok {
686 return errAttachmentTransition686 return errAttachmentTransition
687 }687 }
688- return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action)688+ return d.switchToTargetGuardedForAttachment(sess, token.ac, target, guard, &token, action)
689 }689 }
690 690
691-// switchToRemoteTargetForRole performs dialing after the initiating effect has691+// switchToRemoteTargetForAttachment performs dialing after the initiating effect has
692 // ended and without role or architecture locks. transitionAttachment then692 // ended and without role or architecture locks. transitionAttachment then
693 // freezes and revalidates the exact source role and exact structured-key proxy693 // freezes and revalidates the exact source role and exact structured-key proxy
694 // before publishing ownership.694 // 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 {
696 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" {696 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" {
697 return errAttachmentTransition697 return errAttachmentTransition
698 }698 }
@@ -710,7 +710,7 @@ func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target p
710 ownsCandidate := !alreadyPublished && !constructionInFlight710 ownsCandidate := !alreadyPublished && !constructionInFlight
711 proxy, err := d.openProxySession(ctx, key, token.ac.size)711 proxy, err := d.openProxySession(ctx, key, token.ac.size)
712 if err != nil {712 if err != nil {
713- if !token.activeCurrent() {713+ if !token.attachmentCurrent() {
714 return errAttachmentTransition714 return errAttachmentTransition
715 }715 }
716 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't connect to that remote session", err)716 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
724 724
725 transition, err := d.transitionAttachment(attachmentTransitionRequest{725 transition, err := d.transitionAttachment(attachmentTransitionRequest{
726 source: token.sess, target: proxy, next: token.ac,726 source: token.sess, target: proxy, next: token.ac,
727- expectedRole: attachmentActive, targetRole: attachmentActive,727+
728 expectedTransport: token.transport, sourceToken: &token, action: action,728 expectedTransport: token.transport, sourceToken: &token, action: action,
729- preserveRole: proxy == token.sess, ready: true,729+ preserveAttachment: proxy == token.sess, ready: true,
730 })730 })
731 if err != nil {731 if err != nil {
732 if ownsCandidate {732 if ownsCandidate {
@@ -735,7 +735,7 @@ func (d *Daemon) switchToRemoteTargetForRole(token attachmentRoleToken, target p
735 return errAttachmentTransition735 return errAttachmentTransition
736 }736 }
737 if guard.closePicker {737 if guard.closePicker {
738- fresh, admitted := token.ac.beginRoleEffect(transition.published)738+ fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
739 if admitted {739 if admitted {
740 d.closePicker(token.ac)740 d.closePicker(token.ac)
741 fresh.End()741 fresh.End()
@@ -818,17 +818,17 @@ func (d *Daemon) discardUnattachedProxyGeneration(proxy *proxySession, generatio
818 818
819 // switchToTargetGuarded is retained for daemon-internal and headless callers.819 // switchToTargetGuarded is retained for daemon-internal and headless callers.
820 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {820 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, "")
822 }822 }
823 823
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 {
825 if target.Name != "" {825 if target.Name != "" {
826 ctx := d.serveCtx826 ctx := d.serveCtx
827 if ctx == nil {827 if ctx == nil {
828 ctx = context.Background()828 ctx = context.Background()
829 }829 }
830 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {830 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
831- if sourceToken != nil && !sourceToken.activeCurrent() {831+ if sourceToken != nil && !sourceToken.attachmentCurrent() {
832 return errAttachmentTransition832 return errAttachmentTransition
833 }833 }
834 d.invalidateRender(from, ac, true, "picker.go")834 d.invalidateRender(from, ac, true, "picker.go")
@@ -862,7 +862,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
862 d.mu.Unlock()862 d.mu.Unlock()
863 863
864 if !switched {864 if !switched {
865- if sourceToken != nil && !sourceToken.activeCurrent() {865+ if sourceToken != nil && !sourceToken.attachmentCurrent() {
866 return errAttachmentTransition866 return errAttachmentTransition
867 }867 }
868 if guard.expectedSource != nil {868 if guard.expectedSource != nil {
@@ -873,7 +873,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
873 }873 }
874 if guard.closePicker {874 if guard.closePicker {
875 if sourceToken != nil {875 if sourceToken != nil {
876- fresh, admitted := ac.beginRoleEffect(transition.published)876+ fresh, admitted := ac.beginAttachmentEffect(transition.published)
877 if admitted {877 if admitted {
878 d.closePicker(ac)878 d.closePicker(ac)
879 fresh.End()879 fresh.End()
@@ -885,7 +885,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient,
885 d.deferAttachmentTransitionCleanups(transition)885 d.deferAttachmentTransitionCleanups(transition)
886 if targetSess == from {886 if targetSess == from {
887 if sourceToken != nil {887 if sourceToken != nil {
888- fresh, admitted := ac.beginRoleEffect(transition.published)888+ fresh, admitted := ac.beginAttachmentEffect(transition.published)
889 if admitted {889 if admitted {
890 d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)890 d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)
891 d.invalidateRender(from, ac, true, "picker.go")891 d.invalidateRender(from, ac, true, "picker.go")
@@ -923,7 +923,7 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
923 923
924 // switchToActiveTargetLocked commits an active target handoff through the924 // switchToActiveTargetLocked commits an active target handoff through the
925 // centralized attachment transition. Caller holds d.mu.925 // 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) {
927 if d.sessions[target.Session] != targetSess {927 if d.sessions[target.Session] != targetSess {
928 return nil, attachmentTransitionResult{}, false928 return nil, attachmentTransitionResult{}, false
929 }929 }
@@ -943,9 +943,9 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
943 d.mu.Unlock()943 d.mu.Unlock()
944 transition, err := d.transitionAttachment(attachmentTransitionRequest{944 transition, err := d.transitionAttachment(attachmentTransitionRequest{
945 source: from, target: targetSess, next: ac,945 source: from, target: targetSess, next: ac,
946- expectedRole: attachmentActive, targetRole: attachmentActive,946+
947 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,947 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
948- activateTargetTab: true, targetTabIndex: target.TabIndex, preserveRole: true,948+ activateTargetTab: true, targetTabIndex: target.TabIndex, preserveAttachment: true,
949 })949 })
950 d.mu.Lock()950 d.mu.Lock()
951 return targetSess, transition, err == nil951 return targetSess, transition, err == nil
@@ -981,11 +981,10 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
981 expectedTransport = sourceToken.transport981 expectedTransport = sourceToken.transport
982 }982 }
983 transition, err := d.transitionAttachment(attachmentTransitionRequest{983 transition, err := d.transitionAttachment(attachmentTransitionRequest{
984- source: from,984+ source: from,
985- target: targetSess,985+ target: targetSess,
986- next: ac,986+ next: ac,
987- expectedRole: attachmentActive,987+
988- targetRole: attachmentActive,
989 expectedTransport: expectedTransport,988 expectedTransport: expectedTransport,
990 sourceToken: sourceToken,989 sourceToken: sourceToken,
991 action: action,990 action: action,
@@ -1007,7 +1006,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1007 // resumeStoppedAndSwitchLocked creates the stopped representation and commits1006 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
1008 // the handoff while d.mu is held. Creation failure leaves the source client1007 // the handoff while d.mu is held. Creation failure leaves the source client
1009 // and stopped record untouched.1008 // 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) {
1011 if stopped.record.Name != "" && stopped.state == ports.SessionBroken {1010 if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
1012 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}1011 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
1013 }1012 }
@@ -1016,7 +1015,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1016 var targetSess *session1015 var targetSess *session
1017 d.mu.Unlock()1016 d.mu.Unlock()
1018 transition, err := d.transitionAttachment(attachmentTransitionRequest{1017 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1019- source: from, next: ac, expectedRole: attachmentActive, targetRole: attachmentActive,1018+ source: from, next: ac,
1020 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,1019 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
1021 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,1020 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
1022 createTargetLocked: func() (*session, error) {1021 createTargetLocked: func() (*session, error) {
@@ -1065,7 +1064,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1065 d.mu.Unlock()1064 d.mu.Unlock()
1066 transition, err := d.transitionAttachment(attachmentTransitionRequest{1065 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1067 source: from, target: targetSess, next: ac,1066 source: from, target: targetSess, next: ac,
1068- expectedRole: attachmentActive, targetRole: attachmentActive,1067+
1069 expectedTransport: ac.transportSnapshot(), ready: true,1068 expectedTransport: ac.transportSnapshot(), ready: true,
1070 })1069 })
1071 d.mu.Lock()1070 d.mu.Lock()
@@ -1091,7 +1090,7 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS
1091 return nil1090 return nil
1092 }1091 }
1093 d.deferAttachmentTransitionCleanups(transition)1092 d.deferAttachmentTransitionCleanups(transition)
1094- return transition.displaced.ac1093+ return nil
1095 }1094 }
1096 1095
1097 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves1096 // resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
@@ -1116,7 +1115,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
1116 return true1115 return true
1117 }1116 }
1118 1117
1119-func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentRoleToken) error {1118+func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attachmentConnectionToken) error {
1120 if token.ac == nil {1119 if token.ac == nil {
1121 return d.killPickerTarget(target)1120 return d.killPickerTarget(target)
1122 }1121 }
@@ -1127,9 +1126,9 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR
1127 if target.Stopped {1126 if target.Stopped {
1128 token.effect.bindActionEnd(d, "picker-delete")1127 token.effect.bindActionEnd(d, "picker-delete")
1129 token.effect.End()1128 token.effect.End()
1130- frozen := freezeRoleEffectGates(token.ac)1129+ frozen := freezeAttachmentEffectGates(token.ac)
1131 defer frozen.unfreeze()1130 defer frozen.unfreeze()
1132- if !d.sourceRoleTokenCurrentFrozen(token) {1131+ if !d.sourceAttachmentTokenCurrentFrozen(token) {
1133 return nil1132 return nil
1134 }1133 }
1135 return d.killPickerTarget(target)1134 return d.killPickerTarget(target)
@@ -1140,7 +1139,7 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR
1140 if !ok {1139 if !ok {
1141 return nil1140 return nil
1142 }1141 }
1143- return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")1142+ return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
1144 }1143 }
1145 1144
1146 func (d *Daemon) killPickerTarget(target picker.Target) error {1145 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) {
993 require.NoError(t, err)993 require.NoError(t, err)
994 require.Contains(t, string(displacedOutput.Data), "Session snatched")994 require.Contains(t, string(displacedOutput.Data), "Session snatched")
995 require.Same(t, sess2, ac2.currentSession())995 require.Same(t, sess2, ac2.currentSession())
996- require.Equal(t, attachmentSnatched, sess2.attachmentRole(ac2))996+ require.Equal(t, true, sess2.attachmentRegistered(ac2))
997 997
998 movingFrame := awaitFrame(t, sends1, ports.MsgOutput)998 movingFrame := awaitFrame(t, sends1, ports.MsgOutput)
999 movingOutput, err := ports.UnmarshalOutput(movingFrame.Payload)999 movingOutput, err := ports.UnmarshalOutput(movingFrame.Payload)
@@ -1120,11 +1120,10 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
1120 switchErr := make(chan error, 1)1120 switchErr := make(chan error, 1)
1121 go func() {1121 go func() {
1122 result, err := d.transitionAttachment(attachmentTransitionRequest{1122 result, err := d.transitionAttachment(attachmentTransitionRequest{
1123- source: sess1,1123+ source: sess1,
1124- target: sess2,1124+ target: sess2,
1125- next: ac,1125+ next: ac,
1126- expectedRole: attachmentActive,1126+
1127- targetRole: attachmentActive,
1128 expectedTransport: ac.transportSnapshot(),1127 expectedTransport: ac.transportSnapshot(),
1129 ready: true,1128 ready: true,
1130 })1129 })
@@ -1139,7 +1138,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
1139 case <-time.After(2 * time.Second):1138 case <-time.After(2 * time.Second):
1140 t.Fatal("session switch waited for in-flight transport send")1139 t.Fatal("session switch waited for in-flight transport send")
1141 }1140 }
1142- require.True(t, result.published.activeCurrent())1141+ require.True(t, result.published.attachmentCurrent())
1143 select {1142 select {
1144 case <-handoffAtSendMu:1143 case <-handoffAtSendMu:
1145 t.Fatal("output rebase ran during architecture publication")1144 t.Fatal("output rebase ran during architecture publication")
@@ -1688,7 +1687,7 @@ func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) {
1688 }1687 }
1689 }1688 }
1690 1689
1691-func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {1690+func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) {
1692 d, current, ac, _, currentReleases := newManualTabSession(t, 1)1691 d, current, ac, _, currentReleases := newManualTabSession(t, 1)
1693 defer releaseAll(currentReleases)1692 defer releaseAll(currentReleases)
1694 current.id, current.name = "current", "z-current"1693 current.id, current.name = "current", "z-current"
@@ -1715,8 +1714,8 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T)
1715 1714
1716 token := current.attachmentToken(ac, ac.transport())1715 token := current.attachmentToken(ac, ac.transport())
1717 token.lease = current.renderCoordinator().attachmentLease(ac)1716 token.lease = current.renderCoordinator().attachmentLease(ac)
1718- ac.publishRoleCapability(token)1717+ ac.publishAttachmentCapability(token)
1719- effect, admitted := ac.beginRoleEffect(token)1718+ effect, admitted := ac.beginAttachmentEffect(token)
1720 require.True(t, admitted)1719 require.True(t, admitted)
1721 d.handlePickerInput(ac, []byte("x"), effect)1720 d.handlePickerInput(ac, []byte("x"), effect)
1722 1721
M internal/usecase/daemon/prompt.go +5 -5
@@ -23,7 +23,7 @@ func (d *Daemon) enterPrompt(sess attachmentSession, ac *attachedClient, title,
23 d.invalidateRender(sess, ac, true, "prompt.go")23 d.invalidateRender(sess, ac, true, "prompt.go")
24 }24 }
25 25
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) {
27 d.closePrompt(ac)27 d.closePrompt(ac)
28 ac.overlays.promptMu.Lock()28 ac.overlays.promptMu.Lock()
29 ac.overlays.prompt = promptui.New(title, initial)29 ac.overlays.prompt = promptui.New(title, initial)
@@ -51,7 +51,7 @@ func promptValidationError(err error) bool {
51 errors.Is(err, errProxyKillConfirmation)51 errors.Is(err, errProxyKillConfirmation)
52 }52 }
53 53
54-func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*roleEffectTicket) {54+func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
55 sess := ac.currentAttachmentSession()55 sess := ac.currentAttachmentSession()
56 if sess == nil {56 if sess == nil {
57 return57 return
@@ -67,7 +67,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
67 changed := false67 changed := false
68 exit := false68 exit := false
69 var submit func(string) error69 var submit func(string) error
70- var transitionSubmit func(string, attachmentRoleToken) error70+ var transitionSubmit func(string, attachmentConnectionToken) error
71 var submittedPrompt *promptui.Model71 var submittedPrompt *promptui.Model
72 var value string72 var value string
73 73
@@ -95,9 +95,9 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
95 if submit != nil || transitionSubmit != nil {95 if submit != nil || transitionSubmit != nil {
96 var err error96 var err error
97 if transitionSubmit != nil {97 if transitionSubmit != nil {
98- var token attachmentRoleToken98+ var token attachmentConnectionToken
99 if len(effects) != 0 && effects[0] != nil {99 if len(effects) != 0 && effects[0] != nil {
100- token = effects[0].roleToken()100+ token = effects[0].connectionToken()
101 }101 }
102 err = transitionSubmit(value, token)102 err = transitionSubmit(value, token)
103 } else {103 } else {
M internal/usecase/daemon/proxied_contract_test.go +5 -5
@@ -270,7 +270,7 @@ func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {
270 ac.sendMu.Unlock()270 ac.sendMu.Unlock()
271 271
272 token := sess.attachmentToken(ac, ac.transport())272 token := sess.attachmentToken(ac, ac.transport())
273- require.False(t, d.handleActiveClientFrame(token, ports.Frame{273+ require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{
274 Type: ports.MsgOutputResetRequest,274 Type: ports.MsgOutputResetRequest,
275 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),275 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
276 }))276 }))
@@ -309,9 +309,9 @@ func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) {
309 309
310 token := sess.attachmentToken(ac, ac.transport())310 token := sess.attachmentToken(ac, ac.transport())
311 if tt.staleRole {311 if tt.staleRole {
312- token.role = attachmentSnatched312+ token.generation++
313 }313 }
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}))
315 ac.sendMu.Lock()315 ac.sendMu.Lock()
316 acked := ac.output.acked316 acked := ac.output.acked
317 ac.sendMu.Unlock()317 ac.sendMu.Unlock()
@@ -331,11 +331,11 @@ func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) {
331 331
332 admitted := make(chan struct{})332 admitted := make(chan struct{})
333 var once sync.Once333 var once sync.Once
334- d.afterRoleEffectAdmitted = func(attachmentRoleToken) { once.Do(func() { close(admitted) }) }334+ d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) { once.Do(func() { close(admitted) }) }
335 ac.sendMu.Lock()335 ac.sendMu.Lock()
336 done := make(chan bool, 1)336 done := make(chan bool, 1)
337 go func() {337 go func() {
338- done <- d.handleActiveClientFrame(token, ports.Frame{338+ done <- d.handleAttachmentClientFrame(token, ports.Frame{
339 Type: ports.MsgOutputResetRequest,339 Type: ports.MsgOutputResetRequest,
340 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),340 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
341 })341 })
M internal/usecase/daemon/proxy_input.go +18 -18
@@ -42,36 +42,36 @@ func (p *proxySession) sendInput(raw []byte) error {
42 // proxy. Local UI actions never reach the link. Every remote-owned action is42 // proxy. Local UI actions never reach the link. Every remote-owned action is
43 // sent using the exact bytes supplied by keys.Router.43 // sent using the exact bytes supplied by keys.Router.
44 type proxyKeyHandler struct {44 type proxyKeyHandler struct {
45- d *Daemon45+ d *Daemon
46- proxy *proxySession46+ proxy *proxySession
47- ac *attachedClient47+ ac *attachedClient
48- roleToken attachmentRoleToken48+ connectionToken attachmentConnectionToken
49 }49 }
50 50
51-func (h proxyKeyHandler) acquireRoleEffect() (*roleEffectTicket, bool, bool) {51+func (h proxyKeyHandler) acquireAttachmentEffect() (*attachmentEffectTicket, bool, bool) {
52 if h.proxy == nil || h.ac == nil {52 if h.proxy == nil || h.ac == nil {
53 return nil, false, false53 return nil, false, false
54 }54 }
55- if h.roleToken.ac == nil {55+ if h.connectionToken.ac == nil {
56 return nil, false, false56 return nil, false, false
57 }57 }
58- if h.roleToken.sess != h.proxy || h.roleToken.ac != h.ac {58+ if h.connectionToken.sess != h.proxy || h.connectionToken.ac != h.ac {
59 return nil, false, false59 return nil, false, false
60 }60 }
61- if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() {61+ if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
62 return effect, false, true62 return effect, false, true
63 }63 }
64- effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken)64+ effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
65 if h.d != nil && h.d.afterDelayedKeyEffectAttempt != nil {65 if h.d != nil && h.d.afterDelayedKeyEffectAttempt != nil {
66 h.d.afterDelayedKeyEffectAttempt(admitted)66 h.d.afterDelayedKeyEffectAttempt(admitted)
67 }67 }
68 if !admitted {68 if !admitted {
69 return nil, false, false69 return nil, false, false
70 }70 }
71- if h.d != nil && h.d.afterRoleEffectAdmitted != nil {71+ if h.d != nil && h.d.afterAttachmentEffectAdmitted != nil {
72- token := h.roleToken72+ token := h.connectionToken
73 token.effect = effect73 token.effect = effect
74- h.d.afterRoleEffectAdmitted(token)74+ h.d.afterAttachmentEffectAdmitted(token)
75 }75 }
76 return effect, true, true76 return effect, true, true
77 }77 }
@@ -86,7 +86,7 @@ func (h proxyKeyHandler) sendOwned(raw []byte) {
86 }86 }
87 87
88 func (h proxyKeyHandler) send(raw []byte) {88 func (h proxyKeyHandler) send(raw []byte) {
89- effect, owned, ok := h.acquireRoleEffect()89+ effect, owned, ok := h.acquireAttachmentEffect()
90 if !ok {90 if !ok {
91 return91 return
92 }92 }
@@ -101,14 +101,14 @@ func (h proxyKeyHandler) Forward(raw []byte) { h.send(raw) }
101 func (h proxyKeyHandler) Mouse(event mouse.Event) { h.send(event.Raw) }101 func (h proxyKeyHandler) Mouse(event mouse.Event) { h.send(event.Raw) }
102 102
103 func (h proxyKeyHandler) Action(action keys.Action, raw []byte) {103 func (h proxyKeyHandler) Action(action keys.Action, raw []byte) {
104- effect, owned, ok := h.acquireRoleEffect()104+ effect, owned, ok := h.acquireAttachmentEffect()
105 if !ok {105 if !ok {
106 return106 return
107 }107 }
108 if owned {108 if owned {
109 defer effect.End()109 defer effect.End()
110 }110 }
111- h.roleToken.effect = effect111+ h.connectionToken.effect = effect
112 switch action {112 switch action {
113 case keys.ActionOpenPalette:113 case keys.ActionOpenPalette:
114 h.enterPalette()114 h.enterPalette()
@@ -176,7 +176,7 @@ func (h proxyKeyHandler) enterPalette() {
176 // navigation. A pending tab on the proxy remains remote-owned; without one,176 // navigation. A pending tab on the proxy remains remote-owned; without one,
177 // the local daemon may move the client to another local session.177 // the local daemon may move the client to another local session.
178 func (h proxyKeyHandler) jumpLocalAttention() {178 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 {
180 return180 return
181 }181 }
182 target, ok := h.d.oldestOtherSessionAttention(nil)182 target, ok := h.d.oldestOtherSessionAttention(nil)
@@ -190,12 +190,12 @@ func (h proxyKeyHandler) jumpLocalAttention() {
190 return190 return
191 }191 }
192 192
193- token := h.roleToken193+ token := h.connectionToken
194 token.effect.bindActionEnd(h.d, "proxy-jump-attention")194 token.effect.bindActionEnd(h.d, "proxy-jump-attention")
195 token.effect.End()195 token.effect.End()
196 transition, err := h.d.transitionAttachment(attachmentTransitionRequest{196 transition, err := h.d.transitionAttachment(attachmentTransitionRequest{
197 source: h.proxy, target: targetSession, next: h.ac,197 source: h.proxy, target: targetSession, next: h.ac,
198- expectedRole: attachmentActive, targetRole: attachmentActive,198+
199 expectedTransport: token.transport, sourceToken: &token,199 expectedTransport: token.transport, sourceToken: &token,
200 action: "proxy-jump-attention", activateTargetTab: true,200 action: "proxy-jump-attention", activateTargetTab: true,
201 targetTabIndex: target.tabIndex, ready: true,201 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
35 proxy.mu.Unlock()35 proxy.mu.Unlock()
36 ac := &attachedClient{size: domain.Size{Cols: 80, Rows: 24}, output: newOutputStateStream()}36 ac := &attachedClient{size: domain.Size{Cols: 80, Rows: 24}, output: newOutputStateStream()}
37 ac.initOverlays()37 ac.initOverlays()
38- token := roleEffectForTest(t, attachProxyInputRole(t, d, proxy, ac))38+ token := attachmentEffectForTest(t, attachProxyInputRole(t, d, proxy, ac))
39- return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, roleToken: token}39+ return d, proxy, ac, link, proxyKeyHandler{d: d, proxy: proxy, ac: ac, connectionToken: token}
40 }40 }
41 41
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 {
43 t.Helper()43 t.Helper()
44 clientTransport := newProxyTestTransport()44 clientTransport := newProxyTestTransport()
45 ac.replaceTransport(clientTransport)45 ac.replaceTransport(clientTransport)
@@ -49,7 +49,7 @@ func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *atta
49 d.mu.Unlock()49 d.mu.Unlock()
50 transition, err := d.transitionAttachment(attachmentTransitionRequest{50 transition, err := d.transitionAttachment(attachmentTransitionRequest{
51 target: proxy, next: ac,51 target: proxy, next: ac,
52- expectedRole: attachmentDetached, targetRole: attachmentActive,52+
53 expectedTransport: ac.transportSnapshot(), ready: true,53 expectedTransport: ac.transportSnapshot(), ready: true,
54 })54 })
55 require.NoError(t, err)55 require.NoError(t, err)
@@ -57,9 +57,9 @@ func attachProxyInputRole(t *testing.T, d *Daemon, proxy *proxySession, ac *atta
57 return transition.published57 return transition.published
58 }58 }
59 59
60-func roleEffectForTest(t *testing.T, token attachmentRoleToken) attachmentRoleToken {60+func attachmentEffectForTest(t *testing.T, token attachmentConnectionToken) attachmentConnectionToken {
61 t.Helper()61 t.Helper()
62- effect, admitted := token.ac.beginRoleEffect(token)62+ effect, admitted := token.ac.beginAttachmentEffect(token)
63 require.True(t, admitted)63 require.True(t, admitted)
64 token.effect = effect64 token.effect = effect
65 t.Cleanup(effect.End)65 t.Cleanup(effect.End)
@@ -151,22 +151,22 @@ func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) {
151 151
152 t.Run("retained escape reacquires its original role", func(t *testing.T) {152 t.Run("retained escape reacquires its original role", func(t *testing.T) {
153 d, proxy, ac, link, initial := newProxyInputHarness(t)153 d, proxy, ac, link, initial := newProxyInputHarness(t)
154- base := initial.roleToken154+ base := initial.connectionToken
155 base.effect.End()155 base.effect.End()
156 base.effect = nil156 base.effect = nil
157 157
158- firstEffect, admitted := ac.beginRoleEffect(base)158+ firstEffect, admitted := ac.beginAttachmentEffect(base)
159 require.True(t, admitted)159 require.True(t, admitted)
160 first := base160 first := base
161 first.effect = firstEffect161 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})
163 firstEffect.End()163 firstEffect.End()
164 164
165- secondEffect, admitted := ac.beginRoleEffect(base)165+ secondEffect, admitted := ac.beginAttachmentEffect(base)
166 require.True(t, admitted)166 require.True(t, admitted)
167 second := base167 second := base
168 second.effect = secondEffect168 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})
170 secondEffect.End()170 secondEffect.End()
171 171
172 requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'})172 requireProxyInputFrame(t, link, 1, []byte{keys.ESC, 'j'})
@@ -175,15 +175,15 @@ func TestProxyInputRouterPreservesCustomAndDelayedActionBytes(t *testing.T) {
175 175
176 func TestProxyInputLocalPaletteNeverForwards(t *testing.T) {176 func TestProxyInputLocalPaletteNeverForwards(t *testing.T) {
177 d, _, ac, link, handler := newProxyInputHarness(t)177 d, _, ac, link, handler := newProxyInputHarness(t)
178- token := handler.roleToken178+ token := handler.connectionToken
179 handler.Action(keys.ActionOpenPalette, []byte{keys.ESC, ' '})179 handler.Action(keys.ActionOpenPalette, []byte{keys.ESC, ' '})
180 require.True(t, ac.overlays.paletteActive())180 require.True(t, ac.overlays.paletteActive())
181 requireNoProxyFrame(t, link)181 requireNoProxyFrame(t, link)
182 182
183 // Keyboard and mouse navigation are consumed by the local overlay before183 // Keyboard and mouse navigation are consumed by the local overlay before
184 // either the proxy router or proxy mouse sender can observe them.184 // either the proxy router or proxy mouse sender can observe them.
185- d.handleInputForRole(token, []byte("x"))185+ d.handleInputForAttachment(token, []byte("x"))
186- d.handleInputForRole(token, []byte("\x1b[<0;12;8M"))186+ d.handleInputForAttachment(token, []byte("\x1b[<0;12;8M"))
187 requireNoProxyFrame(t, link)187 requireNoProxyFrame(t, link)
188 }188 }
189 189
@@ -259,7 +259,7 @@ func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) {
259 t.Run("new session opens local transition prompt", func(t *testing.T) {259 t.Run("new session opens local transition prompt", func(t *testing.T) {
260 d, proxy, ac, link, handler := newProxyInputHarness(t)260 d, proxy, ac, link, handler := newProxyInputHarness(t)
261 handler.enterPalette()261 handler.enterPalette()
262- d.handlePaletteInput(ac, []byte("CNS\r"), handler.roleToken.effect)262+ d.handlePaletteInput(ac, []byte("CNS\r"), handler.connectionToken.effect)
263 263
264 require.False(t, ac.overlays.paletteActive())264 require.False(t, ac.overlays.paletteActive())
265 require.True(t, ac.overlays.promptActive())265 require.True(t, ac.overlays.promptActive())
@@ -276,7 +276,7 @@ func TestProxyPaletteCanonicalLocalCommandsNeverReachRemote(t *testing.T) {
276 for len(client.sent) > 0 {276 for len(client.sent) > 0 {
277 <-client.sent277 <-client.sent
278 }278 }
279- d.handlePaletteInput(ac, []byte("YLN\r"), handler.roleToken.effect)279+ d.handlePaletteInput(ac, []byte("YLN\r"), handler.connectionToken.effect)
280 280
281 require.False(t, ac.overlays.paletteActive())281 require.False(t, ac.overlays.paletteActive())
282 require.Same(t, proxy, ac.currentAttachmentSession())282 require.Same(t, proxy, ac.currentAttachmentSession())
@@ -522,7 +522,7 @@ func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *test
522 payload, err := ports.MarshalCommandRequest(request)522 payload, err := ports.MarshalCommandRequest(request)
523 require.NoError(t, err)523 require.NoError(t, err)
524 token := sess.attachmentToken(ac, ac.transport())524 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}))
526 frame := awaitFrame(t, sends, ports.MsgCommandResult)526 frame := awaitFrame(t, sends, ports.MsgCommandResult)
527 result, err := ports.UnmarshalCommandResult(frame.Payload)527 result, err := ports.UnmarshalCommandResult(frame.Payload)
528 require.NoError(t, err)528 require.NoError(t, err)
@@ -553,7 +553,7 @@ func TestProxyAttachedCommandRemoteErrorCreatesLocalNotice(t *testing.T) {
553 handler.enterPalette()553 handler.enterPalette()
554 done := make(chan struct{})554 done := make(chan struct{})
555 go func() {555 go func() {
556- d.handlePaletteInput(ac, []byte("NXT\r"), handler.roleToken.effect)556+ d.handlePaletteInput(ac, []byte("NXT\r"), handler.connectionToken.effect)
557 close(done)557 close(done)
558 }()558 }()
559 request := requireProxyCommandRequest(t, link)559 request := requireProxyCommandRequest(t, link)
@@ -623,10 +623,10 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
623 d.attachCoordinator(source, nil, ac, true)623 d.attachCoordinator(source, nil, ac, true)
624 624
625 token := source.attachmentToken(ac, ac.transport())625 token := source.attachmentToken(ac, ac.transport())
626- effect, admitted := ac.beginRoleEffect(token)626+ effect, admitted := ac.beginAttachmentEffect(token)
627 require.True(t, admitted)627 require.True(t, admitted)
628 token.effect = effect628 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")
630 require.NoError(t, err)630 require.NoError(t, err)
631 require.True(t, daemonLockAvailable)631 require.True(t, daemonLockAvailable)
632 require.True(t, sourceLockAvailable)632 require.True(t, sourceLockAvailable)
@@ -637,19 +637,19 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
637 637
638 wrong := domain.RemoteSessionKey{Host: "arch", Name: "other"}638 wrong := domain.RemoteSessionKey{Host: "arch", Name: "other"}
639 currentToken := attachmentToken(proxy, ac, ac.transport())639 currentToken := attachmentToken(proxy, ac, ac.transport())
640- currentEffect, admitted := ac.beginRoleEffect(currentToken)640+ currentEffect, admitted := ac.beginAttachmentEffect(currentToken)
641 require.True(t, admitted)641 require.True(t, admitted)
642 currentToken.effect = currentEffect642 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")
644 require.ErrorIs(t, err, errAttachmentTransition)644 require.ErrorIs(t, err, errAttachmentTransition)
645 require.Same(t, proxy, ac.currentAttachmentSession(), "mismatched structured key must not redirect attachment")645 require.Same(t, proxy, ac.currentAttachmentSession(), "mismatched structured key must not redirect attachment")
646 646
647 staleToken := attachmentToken(proxy, ac, ac.transport())647 staleToken := attachmentToken(proxy, ac, ac.transport())
648- staleEffect, admitted := ac.beginRoleEffect(staleToken)648+ staleEffect, admitted := ac.beginAttachmentEffect(staleToken)
649 require.True(t, admitted)649 require.True(t, admitted)
650 staleToken.effect = staleEffect650 staleToken.effect = staleEffect
651- ac.roleGeneration.Add(1) // model a concurrent role replacement after selection651+ ac.connectionGeneration.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")652+ err = d.switchToTargetForAttachment(staleToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select")
653 require.ErrorIs(t, err, errAttachmentTransition)653 require.ErrorIs(t, err, errAttachmentTransition)
654 require.Same(t, proxy, ac.currentAttachmentSession(), "stale initiating role must not republish attachment")654 require.Same(t, proxy, ac.currentAttachmentSession(), "stale initiating role must not republish attachment")
655 }655 }
@@ -676,7 +676,7 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T)
676 require.Equal(t, key, *target.RemoteKey)676 require.Equal(t, key, *target.RemoteKey)
677 677
678 token := local.attachmentToken(ac, ac.transport())678 token := local.attachmentToken(ac, ac.transport())
679- effect, admitted := ac.beginRoleEffect(token)679+ effect, admitted := ac.beginAttachmentEffect(token)
680 require.True(t, admitted)680 require.True(t, admitted)
681 d.handlePickerInput(ac, []byte("\r"), effect)681 d.handlePickerInput(ac, []byte("\r"), effect)
682 proxy, ok := ac.currentAttachmentSession().(*proxySession)682 proxy, ok := ac.currentAttachmentSession().(*proxySession)
@@ -702,17 +702,17 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
702 require.Equal(t, local.name, target.Name)702 require.Equal(t, local.name, target.Name)
703 require.False(t, target.Stopped)703 require.False(t, target.Stopped)
704 704
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"))
706 require.Same(t, local, ac.currentSession())706 require.Same(t, local, ac.currentSession())
707 require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker")707 require.False(t, ac.overlays.pickerActive(), "successful handoff must close the picker")
708 708
709 d.remoteDialerFactory = portsmocks.NewMockRemoteDialerFactory(t)709 d.remoteDialerFactory = portsmocks.NewMockRemoteDialerFactory(t)
710- localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))710+ localToken := attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
711- require.NoError(t, d.backSessionForRole(localToken))711+ require.NoError(t, d.backSessionForAttachment(localToken))
712 require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy")712 require.Same(t, proxy, ac.currentAttachmentSession(), "back-session must return to the previous warm proxy")
713 713
714- proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))714+ proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
715- require.NoError(t, d.switchToTargetForRole(proxyToken, target, sessionHandoffGuard{}, "picker-select"))715+ require.NoError(t, d.switchToTargetForAttachment(proxyToken, target, sessionHandoffGuard{}, "picker-select"))
716 require.Same(t, local, ac.currentSession())716 require.Same(t, local, ac.currentSession())
717 d.backSession(local, ac)717 d.backSession(local, ac)
718 require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity")718 require.Same(t, proxy, ac.currentAttachmentSession(), "legacy back-session must preserve the previous proxy identity")
@@ -720,7 +720,7 @@ func TestRemotePickerSwitchesFromProxyBackToLocalSession(t *testing.T) {
720 720
721 func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) {721 func TestRemotePickerReportsUnavailableLocalTarget(t *testing.T) {
722 d, proxy, ac, _, handler := newProxyInputHarness(t)722 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")
724 require.Error(t, err)724 require.Error(t, err)
725 require.Same(t, proxy, ac.currentAttachmentSession())725 require.Same(t, proxy, ac.currentAttachmentSession())
726 }726 }
@@ -737,15 +737,15 @@ func TestRemotePickerRejectsReplacedLocalLifecycle(t *testing.T) {
737 model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})737 model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, TabID: domain.TabStableID(tb.stableID)})
738 target, selectable := model.Selected()738 target, selectable := model.Selected()
739 require.True(t, selectable)739 require.True(t, selectable)
740- d.afterRoleEffectsFrozen = func() {740+ d.afterAttachmentEffectsFrozen = func() {
741- d.afterRoleEffectsFrozen = nil741+ d.afterAttachmentEffectsFrozen = nil
742 local.mu.Lock()742 local.mu.Lock()
743 local.name = "replacement"743 local.name = "replacement"
744 local.createdAt++744 local.createdAt++
745 local.mu.Unlock()745 local.mu.Unlock()
746 }746 }
747 747
748- require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))748+ require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
749 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment")749 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker lifecycle must not redirect the attachment")
750 }750 }
751 751
@@ -762,14 +762,14 @@ func TestRemotePickerRejectsReplacedLocalIncarnation(t *testing.T) {
762 model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, Incarnation: local.incarnation, TabID: domain.TabStableID(tb.stableID)})762 model := d.newPickerModel(proxy, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: local.id, Incarnation: local.incarnation, TabID: domain.TabStableID(tb.stableID)})
763 target, selectable := model.Selected()763 target, selectable := model.Selected()
764 require.True(t, selectable)764 require.True(t, selectable)
765- d.afterRoleEffectsFrozen = func() {765+ d.afterAttachmentEffectsFrozen = func() {
766- d.afterRoleEffectsFrozen = nil766+ d.afterAttachmentEffectsFrozen = nil
767 local.mu.Lock()767 local.mu.Lock()
768 local.incarnation = domain.IncarnationID{2}768 local.incarnation = domain.IncarnationID{2}
769 local.mu.Unlock()769 local.mu.Unlock()
770 }770 }
771 771
772- require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))772+ require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
773 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker incarnation must not redirect the attachment")773 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker incarnation must not redirect the attachment")
774 }774 }
775 775
@@ -790,14 +790,14 @@ func TestRemotePickerRejectsReplacedLocalTab(t *testing.T) {
790 target, selectable := model.Selected()790 target, selectable := model.Selected()
791 require.True(t, selectable)791 require.True(t, selectable)
792 require.Equal(t, domain.TabStableID(second.stableID), target.TabID)792 require.Equal(t, domain.TabStableID(second.stableID), target.TabID)
793- d.afterRoleEffectsFrozen = func() {793+ d.afterAttachmentEffectsFrozen = func() {
794- d.afterRoleEffectsFrozen = nil794+ d.afterAttachmentEffectsFrozen = nil
795 local.mu.Lock()795 local.mu.Lock()
796 local.tabs[0], local.tabs[1] = local.tabs[1], local.tabs[0]796 local.tabs[0], local.tabs[1] = local.tabs[1], local.tabs[0]
797 local.mu.Unlock()797 local.mu.Unlock()
798 }798 }
799 799
800- require.Error(t, d.switchToTargetForRole(handler.roleToken, target, sessionHandoffGuard{}, "picker-select"))800+ require.Error(t, d.switchToTargetForAttachment(handler.connectionToken, target, sessionHandoffGuard{}, "picker-select"))
801 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker tab must not redirect the attachment")801 require.Same(t, proxy, ac.currentAttachmentSession(), "stale picker tab must not redirect the attachment")
802 }802 }
803 803
@@ -812,14 +812,14 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
812 d.remoteDialerFactory = newProxyConstructionFactory(remote)812 d.remoteDialerFactory = newProxyConstructionFactory(remote)
813 d.remoteTransportMode = ports.RemoteTransportUDP813 d.remoteTransportMode = ports.RemoteTransportUDP
814 814
815- localToken := roleEffectForTest(t, local.attachmentToken(ac, ac.transport()))815+ localToken := attachmentEffectForTest(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"))816+ require.NoError(t, d.switchToTargetForAttachment(localToken, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
817 proxy, ok := ac.currentAttachmentSession().(*proxySession)817 proxy, ok := ac.currentAttachmentSession().(*proxySession)
818 require.True(t, ok)818 require.True(t, ok)
819 t.Cleanup(func() { stopProxy(t, proxy) })819 t.Cleanup(func() { stopProxy(t, proxy) })
820 820
821- proxyToken := roleEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))821+ proxyToken := attachmentEffectForTest(t, attachmentToken(proxy, ac, ac.transport()))
822- require.NoError(t, d.switchToTargetForRole(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))822+ require.NoError(t, d.switchToTargetForAttachment(proxyToken, picker.Target{Session: local.id, TabIndex: 0}, sessionHandoffGuard{}, "picker-select"))
823 require.Same(t, local, ac.currentSession())823 require.Same(t, local, ac.currentSession())
824 824
825 model := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})825 model := d.newPickerModel(local, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: key.ID(), RemoteKey: &key})
@@ -827,8 +827,8 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
827 target, selectable := model.Selected()827 target, selectable := model.Selected()
828 require.True(t, selectable)828 require.True(t, selectable)
829 require.Empty(t, target.Name, "live proxy rows must route only by their structured remote key")829 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()))830+ localToken = attachmentEffectForTest(t, local.attachmentToken(ac, ac.transport()))
831- require.NoError(t, d.switchToTargetForRole(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))831+ require.NoError(t, d.switchToTargetForAttachment(localToken, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
832 require.Same(t, proxy, ac.currentAttachmentSession())832 require.Same(t, proxy, ac.currentAttachmentSession())
833 require.False(t, ac.overlays.pickerActive())833 require.False(t, ac.overlays.pickerActive())
834 }834 }
@@ -856,7 +856,7 @@ func TestConnectionRoleResolutionStopsAfterUnstableSnapshots(t *testing.T) {
856 }856 }
857 }857 }
858 858
859- _, _, ok := d.currentConnectionRole(ac, client)859+ _, _, ok := d.currentAttachmentConnection(ac, client)
860 require.False(t, ok)860 require.False(t, ok)
861 require.Less(t, attempts, unstableSnapshots)861 require.Less(t, attempts, unstableSnapshots)
862 }862 }
@@ -904,13 +904,13 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
904 remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}904 remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
905 d.remoteDialerFactory = newProxyConstructionFactory(remote)905 d.remoteDialerFactory = newProxyConstructionFactory(remote)
906 d.remoteTransportMode = ports.RemoteTransportUDP906 d.remoteTransportMode = ports.RemoteTransportUDP
907- token := roleEffectForTest(t, local.attachmentToken(ac, client))907+ token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
908 snapshots := make(chan attachmentSession, 1)908 snapshots := make(chan attachmentSession, 1)
909 transitionErr := make(chan error, 1)909 transitionErr := make(chan error, 1)
910 d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {910 d.afterConnectionSessionSnapshot = func(snapshot attachmentSession) {
911 d.afterConnectionSessionSnapshot = nil911 d.afterConnectionSessionSnapshot = nil
912 snapshots <- snapshot912 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")
914 }914 }
915 915
916 loopDone := make(chan struct{})916 loopDone := make(chan struct{})
@@ -951,8 +951,8 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T)
951 }()951 }()
952 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)952 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
953 953
954- token := roleEffectForTest(t, local.attachmentToken(ac, client))954+ token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
955- require.NoError(t, d.switchToTargetForRole(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))955+ require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: key.ID(), RemoteKey: &key, TabIndex: -1}, sessionHandoffGuard{}, "picker-select"))
956 proxy, ok := ac.currentAttachmentSession().(*proxySession)956 proxy, ok := ac.currentAttachmentSession().(*proxySession)
957 require.True(t, ok)957 require.True(t, ok)
958 t.Cleanup(func() { stopProxy(t, proxy) })958 t.Cleanup(func() { stopProxy(t, proxy) })
@@ -980,7 +980,7 @@ func TestConnectionLoopFollowsClientFromProxyBackToLocal(t *testing.T) {
980 }()980 }()
981 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)981 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
982 982
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"))
984 require.Same(t, local, ac.currentSession())984 require.Same(t, local, ac.currentSession())
985 client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}985 client.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}}
986 awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session")986 awaitTestCompletion(t, loopDone, "connection loop did not follow the client back to its local session")
@@ -1009,10 +1009,10 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
1009 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)1009 require.Eventually(t, func() bool { return client.receiving.Load() == 1 }, time.Second, time.Millisecond)
1010 1010
1011 token := local.attachmentToken(ac, client)1011 token := local.attachmentToken(ac, client)
1012- effect, admitted := ac.beginRoleEffect(token)1012+ effect, admitted := ac.beginAttachmentEffect(token)
1013 require.True(t, admitted)1013 require.True(t, admitted)
1014 token.effect = effect1014 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"))
1016 proxy, ok := ac.currentAttachmentSession().(*proxySession)1016 proxy, ok := ac.currentAttachmentSession().(*proxySession)
1017 require.True(t, ok)1017 require.True(t, ok)
1018 t.Cleanup(func() { stopProxy(t, proxy) })1018 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
47 if unreachable {47 if unreachable {
48 title = " Type " + key.Display() + " to kill (remote unreachable) "48 title = " Type " + key.Display() + " to kill (remote unreachable) "
49 }49 }
50- d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentRoleToken) error {50+ d.enterTransitionPrompt(owner, ac, title, "", func(value string, token attachmentConnectionToken) error {
51 if value != key.Display() {51 if value != key.Display() {
52 return errProxyKillConfirmation52 return errProxyKillConfirmation
53 }53 }
54 if token.ac != nil {54 if token.ac != nil {
55- if !token.activeCurrent() {55+ if !token.attachmentCurrent() {
56 return errAttachmentTransition56 return errAttachmentTransition
57 }57 }
58 token.effect.bindActionEnd(d, "remote-picker-delete")58 token.effect.bindActionEnd(d, "remote-picker-delete")
M internal/usecase/daemon/proxy_lifecycle.go +2 -2
@@ -39,8 +39,8 @@ func (t *proxyWarmTimer) stop() {
39 // proxyAttachmentTransitionCommitted applies lifecycle policy only after the39 // proxyAttachmentTransitionCommitted applies lifecycle policy only after the
40 // attachment publication has released every architecture lock. Each helper40 // attachment publication has released every architecture lock. Each helper
41 // performs its own exact pointer/client revalidation before changing a timer.41 // performs its own exact pointer/client revalidation before changing a timer.
42-func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveRole bool) {42+func (d *Daemon) proxyAttachmentTransitionCommitted(source, target attachmentSession, ac *attachedClient, preserveAttachment bool) {
43- if d == nil || preserveRole || source == target {43+ if d == nil || preserveAttachment || source == target {
44 return44 return
45 }45 }
46 if proxy, ok := source.(*proxySession); ok {46 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) {
186 186
187 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{187 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
188 source: local, target: proxy, next: ac,188 source: local, target: proxy, next: ac,
189- expectedRole: attachmentActive, targetRole: attachmentActive,189+
190 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,190 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
191 action: "lifecycle test", ready: true,191 action: "lifecycle test", ready: true,
192 })192 })
@@ -198,7 +198,7 @@ func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
198 198
199 _, err = d.transitionAttachment(attachmentTransitionRequest{199 _, err = d.transitionAttachment(attachmentTransitionRequest{
200 source: proxy, target: local, next: ac,200 source: proxy, target: local, next: ac,
201- expectedRole: attachmentActive, targetRole: attachmentActive,201+
202 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,202 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
203 action: "lifecycle test", ready: true,203 action: "lifecycle test", ready: true,
204 })204 })
@@ -484,24 +484,25 @@ func TestIncomingDirectAttachTerminatesProxiedRemoteAttachment(t *testing.T) {
484 next.initOverlays()484 next.initOverlays()
485 result, err := d.transitionAttachment(attachmentTransitionRequest{485 result, err := d.transitionAttachment(attachmentTransitionRequest{
486 target: sess, next: next,486 target: sess, next: next,
487- expectedRole: attachmentDetached, targetRole: attachmentActive,487+
488 expectedTransport: next.transportSnapshot(), ready: true,488 expectedTransport: next.transportSnapshot(), ready: true,
489 })489 })
490 require.NoError(t, err)490 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)
492 require.Contains(t, sess.snapshotAttachments(), next)492 require.Contains(t, sess.snapshotAttachments(), next)
493- require.Nil(t, old.currentAttachmentSession())493+ require.Same(t, sess, old.currentAttachmentSession())
494 494
495 d.deferAttachmentTransitionCleanups(result)495 d.deferAttachmentTransitionCleanups(result)
496 d.waitNotifies()496 d.waitNotifies()
497- frame := awaitTestValue(t, oldTransport.sent, "replacement did not notify old transport")497+ select {
498- detached, err := ports.UnmarshalDetached(frame.Payload)498+ case <-oldTransport.sent:
499- require.NoError(t, err)499+ t.Fatal("publishing an attachment displaced the existing connection")
500- require.Equal(t, ports.ReasonReplaced, detached.Reason)500+ default:
501+ }
501 select {502 select {
502 case <-oldTransport.closed:503 case <-oldTransport.closed:
504+ t.Fatal("publishing an attachment closed the existing connection")
503 default:505 default:
504- t.Fatal("terminal proxied replacement did not close its transport")
505 }506 }
506 }507 }
507 508
@@ -552,7 +553,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
552 553
553 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{554 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
554 source: local, target: proxy, next: ac,555 source: local, target: proxy, next: ac,
555- expectedRole: attachmentActive, targetRole: attachmentActive,556+
556 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,557 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
557 action: "replaced picker regression", ready: true,558 action: "replaced picker regression", ready: true,
558 })559 })
@@ -567,7 +568,7 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
567 568
568 _, err = d.transitionAttachment(attachmentTransitionRequest{569 _, err = d.transitionAttachment(attachmentTransitionRequest{
569 source: proxy, target: local, next: ac,570 source: proxy, target: local, next: ac,
570- expectedRole: attachmentActive, targetRole: attachmentActive,571+
571 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,572 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
572 action: "replaced picker regression", ready: true,573 action: "replaced picker regression", ready: true,
573 })574 })
@@ -613,10 +614,10 @@ func TestReplacedProxyCannotBeSelectedOrReattachedAfterSwitchAway(t *testing.T)
613 require.Same(t, local, ac.currentAttachmentSession(), "picker activation reattached the terminal proxy")614 require.Same(t, local, ac.currentAttachmentSession(), "picker activation reattached the terminal proxy")
614 615
615 token := attachmentToken(local, ac, ac.transport())616 token := attachmentToken(local, ac, ac.transport())
616- effect, admitted := ac.beginRoleEffect(token)617+ effect, admitted := ac.beginAttachmentEffect(token)
617 require.True(t, admitted)618 require.True(t, admitted)
618 token.effect = effect619 token.effect = effect
619- err = d.switchToTargetForRole(token, cursor, sessionHandoffGuard{}, "replaced picker regression")620+ err = d.switchToTargetForAttachment(token, cursor, sessionHandoffGuard{}, "replaced picker regression")
620 require.Error(t, err)621 require.Error(t, err)
621 require.Same(t, local, ac.currentAttachmentSession(), "defensive activation reused the terminal proxy")622 require.Same(t, local, ac.currentAttachmentSession(), "defensive activation reused the terminal proxy")
622 623
@@ -1087,7 +1088,7 @@ func TestProxyKillConfirmationRequiresExactRemoteDisplay(t *testing.T) {
1087 require.Equal(t, " Type work@arch to kill ", ac.overlays.prompt.Title())1088 require.Equal(t, " Type work@arch to kill ", ac.overlays.prompt.Title())
1088 submit := ac.overlays.promptTransitionSubmit1089 submit := ac.overlays.promptTransitionSubmit
1089 ac.overlays.promptMu.Unlock()1090 ac.overlays.promptMu.Unlock()
1090- require.Error(t, submit("work", attachmentRoleToken{}))1091+ require.Error(t, submit("work", attachmentConnectionToken{}))
1091 d.mu.Lock()1092 d.mu.Lock()
1092 require.Same(t, proxy, d.sessions[proxy.id])1093 require.Same(t, proxy, d.sessions[proxy.id])
1093 d.mu.Unlock()1094 d.mu.Unlock()
@@ -1546,7 +1547,7 @@ func TestExpireWarmProxyIgnoresSupersededToken(t *testing.T) {
1546 require.False(t, proxyRegistered(d, p))1547 require.False(t, proxyRegistered(d, p))
1547 }1548 }
1548 1549
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) {
1550 t.Helper()1551 t.Helper()
1551 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())1552 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
1552 clock := &warmHookClock{timers: make(chan *signalTimer, 4)}1553 clock := &warmHookClock{timers: make(chan *signalTimer, 4)}
@@ -1559,7 +1560,7 @@ func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedC
1559 proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"})1560 proxy := registerLifecycleProxy(t, d, domain.RemoteSessionKey{Host: "arch", Name: "detach"})
1560 transition, err := d.transitionAttachment(attachmentTransitionRequest{1561 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1561 source: local, target: proxy, next: ac,1562 source: local, target: proxy, next: ac,
1562- expectedRole: attachmentActive, targetRole: attachmentActive,1563+
1563 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,1564 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
1564 action: "proxy detach fixture", ready: true,1565 action: "proxy detach fixture", ready: true,
1565 })1566 })
@@ -1570,21 +1571,21 @@ func newProxyRoleDetachFixture(t *testing.T) (*Daemon, *proxySession, *attachedC
1570 func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) {1571 func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) {
1571 tests := []struct {1572 tests := []struct {
1572 name string1573 name string
1573- detach func(*Daemon, attachmentRoleToken, ports.Transport) bool1574+ detach func(*Daemon, attachmentConnectionToken, ports.Transport) bool
1574 }{1575 }{
1575 {1576 {
1576 name: "explicit",1577 name: "explicit",
1577- detach: func(d *Daemon, token attachmentRoleToken, _ ports.Transport) bool {1578+ detach: func(d *Daemon, token attachmentConnectionToken, _ ports.Transport) bool {
1578- effect, admitted := token.ac.beginRoleEffect(token)1579+ effect, admitted := token.ac.beginAttachmentEffect(token)
1579 require.True(t, admitted)1580 require.True(t, admitted)
1580 token.effect = effect1581 token.effect = effect
1581- return d.clientGoneForRole(token, true)1582+ return d.clientGoneForAttachment(token, true)
1582 },1583 },
1583 },1584 },
1584 {1585 {
1585 name: "send error",1586 name: "send error",
1586- detach: func(d *Daemon, token attachmentRoleToken, failed ports.Transport) bool {1587+ detach: func(d *Daemon, token attachmentConnectionToken, failed ports.Transport) bool {
1587- d.detachOnRoleSendError(token, failed)1588+ d.detachOnAttachmentSendError(token, failed)
1588 return token.ac.currentAttachmentSession() == nil1589 return token.ac.currentAttachmentSession() == nil
1589 },1590 },
1590 },1591 },
@@ -1622,11 +1623,11 @@ func TestProxyRoleDetachRetiresExactClientAndOwnsOneWarmTimer(t *testing.T) {
1622 func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {1623 func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
1623 tests := []struct {1624 tests := []struct {
1624 name string1625 name string
1625- stale func(*attachedClient, *attachmentRoleToken) *closeTrackingTransport1626+ stale func(*attachedClient, *attachmentConnectionToken) *closeTrackingTransport
1626 }{1627 }{
1627 {1628 {
1628 name: "transport",1629 name: "transport",
1629- stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport {1630+ stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport {
1630 current := &closeTrackingTransport{}1631 current := &closeTrackingTransport{}
1631 ac.replaceTransport(current)1632 ac.replaceTransport(current)
1632 return current1633 return current
@@ -1634,8 +1635,8 @@ func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
1634 },1635 },
1635 {1636 {
1636 name: "role generation",1637 name: "role generation",
1637- stale: func(ac *attachedClient, _ *attachmentRoleToken) *closeTrackingTransport {1638+ stale: func(ac *attachedClient, _ *attachmentConnectionToken) *closeTrackingTransport {
1638- ac.roleGeneration.Add(1)1639+ ac.connectionGeneration.Add(1)
1639 return nil1640 return nil
1640 },1641 },
1641 },1642 },
@@ -1644,7 +1645,7 @@ func TestProxyRoleDetachRejectsStaleTransportAndRole(t *testing.T) {
1644 t.Run(tt.name, func(t *testing.T) {1645 t.Run(tt.name, func(t *testing.T) {
1645 d, proxy, ac, failed, clock, token, _ := newProxyRoleDetachFixture(t)1646 d, proxy, ac, failed, clock, token, _ := newProxyRoleDetachFixture(t)
1646 current := tt.stale(ac, &token)1647 current := tt.stale(ac, &token)
1647- d.detachOnRoleSendError(token, failed)1648+ d.detachOnAttachmentSendError(token, failed)
1648 require.Same(t, proxy, ac.currentAttachmentSession())1649 require.Same(t, proxy, ac.currentAttachmentSession())
1649 if current != nil {1650 if current != nil {
1650 require.False(t, current.Closed(), "stale transport error closed the rebound client")1651 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
652 }652 }
653 653
654 // handleProxySideEffect forwards a stateless remote effect to the exact local654 // handleProxySideEffect forwards a stateless remote effect to the exact local
655-// attachment that currently owns this proxy. Role admission and transport655+// attachment that currently owns this proxy. Connection admission and transport
656 // incarnation checks make a stale handoff a harmless drop rather than a send656 // incarnation checks make a stale handoff a harmless drop rather than a send
657 // to the client's next session.657 // to the client's next session.
658 func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error {658 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
672 return nil672 return nil
673 }673 }
674 token := attachmentToken(p, ac, expected.transport)674 token := attachmentToken(p, ac, expected.transport)
675- if token.role != attachmentActive || token.sess != p {675+ if token.sess != p {
676 return nil676 return nil
677 }677 }
678- ticket, admitted := ac.beginRoleEffect(token)678+ ticket, admitted := ac.beginAttachmentEffect(token)
679 if !admitted {679 if !admitted {
680 return nil680 return nil
681 }681 }
@@ -684,7 +684,7 @@ func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out p
684 return nil684 return nil
685 }685 }
686 frame := ac.output.sideEffect(out.Data, ac.echoAck.Load())686 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 {
688 if errors.Is(err, errAttachmentTransition) {688 if errors.Is(err, errAttachmentTransition) {
689 return nil689 return nil
690 }690 }
M internal/usecase/daemon/proxy_output_test.go +2 -2
@@ -1031,10 +1031,10 @@ func TestProxySideEffectDropsStaleGenerationAndRole(t *testing.T) {
1031 }1031 }
1032 1032
1033 token := attachmentToken(proxy, ac, ac.transport())1033 token := attachmentToken(proxy, ac, ac.transport())
1034- ticket, admitted := ac.beginRoleEffect(token)1034+ ticket, admitted := ac.beginAttachmentEffect(token)
1035 require.True(t, admitted)1035 require.True(t, admitted)
1036 ticket.End()1036 ticket.End()
1037- ac.roleGeneration.Add(1)1037+ ac.connectionGeneration.Add(1)
1038 result, err = d.handleLinkFrame(proxy, 1, frame)1038 result, err = d.handleLinkFrame(proxy, 1, frame)
1039 require.Equal(t, proxyLinkResume, result)1039 require.Equal(t, proxyLinkResume, result)
1040 require.NoError(t, err)1040 require.NoError(t, err)
M internal/usecase/daemon/proxy_render_test.go +2 -2
@@ -118,7 +118,7 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
118 118
119 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{119 toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
120 source: local, target: proxy, next: ac,120 source: local, target: proxy, next: ac,
121- expectedRole: attachmentActive, targetRole: attachmentActive,121+
122 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,122 expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
123 action: "test transition", ready: true,123 action: "test transition", ready: true,
124 })124 })
@@ -129,7 +129,7 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
129 129
130 toLocal, err := d.transitionAttachment(attachmentTransitionRequest{130 toLocal, err := d.transitionAttachment(attachmentTransitionRequest{
131 source: proxy, target: local, next: ac,131 source: proxy, target: local, next: ac,
132- expectedRole: attachmentActive, targetRole: attachmentActive,132+
133 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,133 expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
134 action: "test transition", ready: true,134 action: "test transition", ready: true,
135 })135 })
M internal/usecase/daemon/render.go +5 -5
@@ -258,9 +258,9 @@ func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid
258 // paint owns attachment sendMu while it captures, composes, and emits; JSONL258 // paint owns attachment sendMu while it captures, composes, and emits; JSONL
259 // observer I/O must therefore happen only after that ownership is released.259 // observer I/O must therefore happen only after that ownership is released.
260 type runtimeMarkBatch struct {260 type runtimeMarkBatch struct {
261- observer ports.RuntimeObserver261+ observer ports.RuntimeObserver
262- marks []ports.RuntimeMark262+ marks []ports.RuntimeMark
263- roleEffect *roleEffectTicket263+ attachmentEffect *attachmentEffectTicket
264 }264 }
265 265
266 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {266 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
@@ -310,11 +310,11 @@ func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool,
310 if lease != nil {310 if lease != nil {
311 token := attachmentToken(entry, ac, ac.transport())311 token := attachmentToken(entry, ac, ac.transport())
312 token.lease = lease312 token.lease = lease
313- ticket, admitted := ac.beginRoleEffect(token)313+ ticket, admitted := ac.beginAttachmentEffect(token)
314 if !admitted {314 if !admitted {
315 return315 return
316 }316 }
317- marks.roleEffect = ticket317+ marks.attachmentEffect = ticket
318 defer ticket.End()318 defer ticket.End()
319 }319 }
320 var tb *tab320 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
532 sendTr = sendTransport.transport532 sendTr = sendTransport.transport
533 if ac.proxied {533 if ac.proxied {
534 if sess, ok := localSession(entry); ok {534 if sess, ok := localSession(entry); ok {
535- sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)535+ sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.attachmentEffect)
536 } else if proxy, ok := entry.(*proxySession); ok {536 } else if proxy, ok := entry.(*proxySession); ok {
537 meta, metaOK := proxy.sessionMetaSnapshot()537 meta, metaOK := proxy.sessionMetaSnapshot()
538 if !metaOK {538 if !metaOK {
539 sendErr = errSessionMetaUnavailable539 sendErr = errSessionMetaUnavailable
540 } else {540 } else {
541- sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.roleEffect)541+ sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.attachmentEffect)
542 }542 }
543 }543 }
544 }544 }
@@ -552,7 +552,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
552 if async, ok := sendTr.(ports.AsyncTransport); ok {552 if async, ok := sendTr.(ports.AsyncTransport); ok {
553 send = async.SendAsync553 send = async.SendAsync
554 }554 }
555- interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)555+ interruptible := marks.attachmentEffect != nil && marks.attachmentEffect.beginTransportSend(sendTransport)
556 if ac.proxied {556 if ac.proxied {
557 sendErr = preparedScreen.send(send)557 sendErr = preparedScreen.send(send)
558 } else {558 } else {
@@ -560,9 +560,9 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
560 }560 }
561 if interruptible {561 if interruptible {
562 if sendErr != nil {562 if sendErr != nil {
563- marks.roleEffect.reportTransportFailure(sendTransport)563+ marks.attachmentEffect.reportTransportFailure(sendTransport)
564 }564 }
565- marks.roleEffect.endTransportSend()565+ marks.attachmentEffect.endTransportSend()
566 }566 }
567 }567 }
568 endEmit(uint64(len(data)), sendErr == nil)568 endEmit(uint64(len(data)), sendErr == nil)
@@ -611,7 +611,7 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
611 // A transport failure may invalidate the role gate. Release this render's611 // A transport failure may invalidate the role gate. Release this render's
612 // admission first. Detachment freezes the gate and therefore cannot mutate612 // admission first. Detachment freezes the gate and therefore cannot mutate
613 // ownership until any enclosing admitted operation has also ended.613 // ownership until any enclosing admitted operation has also ended.
614- if marks.roleEffect == nil {614+ if marks.attachmentEffect == nil {
615 if sess, ok := localSession(entry); ok {615 if sess, ok := localSession(entry); ok {
616 d.detachOnSendError(sess, ac, sendTr)616 d.detachOnSendError(sess, ac, sendTr)
617 } else if proxy, ok := entry.(*proxySession); ok {617 } else if proxy, ok := entry.(*proxySession); ok {
@@ -621,9 +621,9 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
621 // Capture the exact admitted capability, including its coordinator621 // Capture the exact admitted capability, including its coordinator
622 // lease, before End permits a replacement publication. Reserve cleanup622 // lease, before End permits a replacement publication. Reserve cleanup
623 // accounting before End so terminal Wait cannot race a later Add.623 // accounting before End so terminal Wait cannot race a later Add.
624- token := marks.roleEffect.roleToken()624+ token := marks.attachmentEffect.connectionToken()
625- launchCleanup := d.reserveRoleSendErrorCleanup(token, sendTr)625+ launchCleanup := d.reserveAttachmentSendErrorCleanup(token, sendTr)
626- marks.roleEffect.End()626+ marks.attachmentEffect.End()
627 launchCleanup()627 launchCleanup()
628 }628 }
629 }629 }
M internal/usecase/daemon/resize_publication.go +10 -12
@@ -11,7 +11,7 @@ type resizeCommitPublication struct {
11 attachment *attachedClient11 attachment *attachedClient
12 lease *attachmentLease12 lease *attachmentLease
13 epoch uint6413 epoch uint64
14- role attachmentRoleToken14+ connection attachmentConnectionToken
15 coordinator *renderCoordinator15 coordinator *renderCoordinator
16 observer ports.RuntimeObserver16 observer ports.RuntimeObserver
17 mark ports.RuntimeMark17 mark ports.RuntimeMark
@@ -25,20 +25,18 @@ func (p resizeCommitPublication) current() bool {
25 return true25 return true
26 }26 }
27 // Attached resize publication is admitted only through a live role ticket.27 // Attached resize publication is admitted only through a live role ticket.
28- if p.role.effect == nil {28+ if p.connection.effect == nil {
29 return false29 return false
30 }30 }
31- if p.role.effect.ended.Load() || p.role.sess != p.session ||31+ if p.connection.effect.ended.Load() || p.connection.sess != p.session ||
32- p.role.ac != p.attachment || p.role.role != attachmentActive || p.role.lease != p.lease ||32+ p.connection.ac != p.attachment || p.connection.lease != p.lease ||
33- p.role.generation != p.attachment.roleGeneration.Load() ||33+ p.connection.generation != p.attachment.connectionGeneration.Load() ||
34- !p.attachment.transportSnapshotCurrent(p.role.transport) ||34+ !p.attachment.transportSnapshotCurrent(p.connection.transport) ||
35 p.attachment.currentSession() != p.session || p.coordinator == nil {35 p.attachment.currentSession() != p.session || p.coordinator == nil {
36 return false36 return false
37 }37 }
38- p.session.mu.Lock()38+ registered := attachmentRegistered(p.session, p.attachment)
39- active := p.session.attachmentRoleLocked(p.attachment) == attachmentActive39+ return registered && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
40- p.session.mu.Unlock()
41- return active && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
42 }40 }
43 41
44 // emit performs observer work only after every resize-owner fence and42 // emit performs observer work only after every resize-owner fence and
@@ -59,7 +57,7 @@ func (p resizeCommitPublication) emit() bool {
59 // reservation with pane ownership. sendMu is acquired before resize fences, so57 // reservation with pane ownership. sendMu is acquired before resize fences, so
60 // the canonical rule never acquires an attachment lock while a pane fence is58 // the canonical rule never acquires an attachment lock while a pane fence is
61 // held. Observer callbacks run only after both lock families are released.59 // 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 {
63 if ac != nil && ticket == nil {61 if ac != nil && ticket == nil {
64 return false62 return false
65 }63 }
@@ -87,7 +85,7 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *
87 mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),85 mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),
88 }86 }
89 if ticket != nil {87 if ticket != nil {
90- publication.role = ticket.roleToken()88+ publication.connection = ticket.connectionToken()
91 publication.coordinator = sess.renderCoordinator()89 publication.coordinator = sess.renderCoordinator()
92 }90 }
93 if !publication.current() {91 if !publication.current() {
M internal/usecase/daemon/resume.go +6 -8
@@ -110,8 +110,7 @@ func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClie
110 stillOwner := false110 stillOwner := false
111 if sess != nil {111 if sess != nil {
112 sess.mu.Lock()112 sess.mu.Lock()
113- role := sess.attachmentRoleLocked(ac)113+ stillOwner = attachmentRegisteredLocked(sess, ac)
114- stillOwner = role == attachmentActive || role == attachmentSnatched
115 sess.mu.Unlock()114 sess.mu.Unlock()
116 }115 }
117 if !stillOwner {116 if !stillOwner {
@@ -342,7 +341,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
342 ac.resumeClaimToken = 0341 ac.resumeClaimToken = 0
343 ac.resumeToken = token342 ac.resumeToken = token
344 ac.parked = true343 ac.parked = true
345- ac.roleGeneration.Add(1)344+ ac.connectionGeneration.Add(1)
346 sess := parked.sess345 sess := parked.sess
347 if sess != nil {346 if sess != nil {
348 sess.mu.Lock()347 sess.mu.Lock()
@@ -369,7 +368,7 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
369 parked.ac.clearPreviousSession()368 parked.ac.clearPreviousSession()
370 parked.ac.resumeToken = 0369 parked.ac.resumeToken = 0
371 parked.ac.parked = false370 parked.ac.parked = false
372- parked.ac.roleGeneration.Add(1)371+ parked.ac.connectionGeneration.Add(1)
373 parked.ac.setSession(nil)372 parked.ac.setSession(nil)
374 return parkedAttachmentRetirement{373 return parkedAttachmentRetirement{
375 parked: parked,374 parked: parked,
@@ -612,10 +611,9 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
612 d.mu.Unlock()611 d.mu.Unlock()
613 ac.sendMu.Unlock()612 ac.sendMu.Unlock()
614 transition, err := d.transitionAttachment(attachmentTransitionRequest{613 transition, err := d.transitionAttachment(attachmentTransitionRequest{
615- target: sess,614+ target: sess,
616- next: ac,615+ next: ac,
617- expectedRole: attachmentDetached,616+
618- targetRole: attachmentActive,
619 expectedTransport: ac.transportSnapshot(),617 expectedTransport: ac.transportSnapshot(),
620 ready: false,618 ready: false,
621 })619 })
M internal/usecase/daemon/resume_test.go +3 -3
@@ -883,7 +883,7 @@ func TestResumeLiveAttachmentParkFailureRetiresOldTransport(t *testing.T) {
883 token := ac.resumeToken883 token := ac.resumeToken
884 require.NotZero(t, token)884 require.NotZero(t, token)
885 885
886- d.afterDetachRoleEffectsFrozen = func() {886+ d.afterDetachAttachmentEffectsFrozen = func() {
887 d.mu.Lock()887 d.mu.Lock()
888 d.closing = true888 d.closing = true
889 d.mu.Unlock()889 d.mu.Unlock()
@@ -995,7 +995,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
995 token := ac.resumeToken995 token := ac.resumeToken
996 require.True(t, sess.detachIfCurrent(ac))996 require.True(t, sess.detachIfCurrent(ac))
997 require.True(t, d.parkAttachment(sess, ac))997 require.True(t, d.parkAttachment(sess, ac))
998- generation := ac.roleGeneration.Load()998+ generation := ac.connectionGeneration.Load()
999 999
1000 newTr := &closeTrackingTransport{}1000 newTr := &closeTrackingTransport{}
1001 resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})1001 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.
1003 require.True(t, ok)1003 require.True(t, ok)
1004 require.Same(t, sess, resumedSess)1004 require.Same(t, sess, resumedSess)
1005 require.Same(t, ac, resumedAC)1005 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")
1007 1007
1008 _ = ac.closeCapturedTransport(oldTr)1008 _ = ac.closeCapturedTransport(oldTr)
1009 require.True(t, oldTr.Closed(), "old transport is closed")1009 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
241 }241 }
242 }242 }
243 243
244+ if d.ptys == nil {
245+ return nil, errors.New("daemon: PTY factory is unavailable")
246+ }
244 tbSize := contentSize(sz, proxied)247 tbSize := contentSize(sz, proxied)
245 var names []string248 var names []string
246 if len(restoredTabNames) > 0 {249 if len(restoredTabNames) > 0 {
@@ -427,15 +430,14 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
427 if err != nil {430 if err != nil {
428 return err431 return err
429 }432 }
430- // The newly created target is registered before the role transition, but433+ // The newly created target is registered before the attachment transition, but
431 // remains headless. Freeze/drain must occur with no daemon/routing/session434 // remains headless. Freeze/drain must occur with no daemon/routing/session
432 // lock held; the transition revalidates both lifecycles before publication.435 // lock held; the transition revalidates both lifecycles before publication.
433 transition, err := d.transitionAttachment(attachmentTransitionRequest{436 transition, err := d.transitionAttachment(attachmentTransitionRequest{
434- source: from,437+ source: from,
435- target: newSess,438+ target: newSess,
436- next: ac,439+ next: ac,
437- expectedRole: attachmentActive,440+
438- targetRole: attachmentActive,
439 expectedTransport: ac.transportSnapshot(),441 expectedTransport: ac.transportSnapshot(),
440 ready: true,442 ready: true,
441 })443 })
@@ -452,16 +454,16 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
452 return nil454 return nil
453 }455 }
454 456
455-// createSessionAndSwitchForRole creates no target until the initiating role457+// createSessionAndSwitchForAttachment creates no target until the initiating role
456 // ticket has ended and the exact source capability is frozen and revalidated.458 // 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 {
458 if name == "" {460 if name == "" {
459 return errSessionNameRequired461 return errSessionNameRequired
460 }462 }
461 if err := domain.ValidateSessionName(name); err != nil {463 if err := domain.ValidateSessionName(name); err != nil {
462 return err464 return err
463 }465 }
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 {
465 return errAttachmentTransition467 return errAttachmentTransition
466 }468 }
467 token.effect.bindActionEnd(d, "create-session")469 token.effect.bindActionEnd(d, "create-session")
@@ -470,7 +472,7 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s
470 var created *session472 var created *session
471 transition, err := d.transitionAttachment(attachmentTransitionRequest{473 transition, err := d.transitionAttachment(attachmentTransitionRequest{
472 source: token.sess, next: token.ac,474 source: token.sess, next: token.ac,
473- expectedRole: attachmentActive, targetRole: attachmentActive,475+
474 expectedTransport: token.transport, sourceToken: &token, action: "create-session",476 expectedTransport: token.transport, sourceToken: &token, action: "create-session",
475 copySourceEnvironment: true, ready: true,477 copySourceEnvironment: true, ready: true,
476 createTargetLocked: func() (*session, error) {478 createTargetLocked: func() (*session, error) {
@@ -708,9 +710,6 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
708 go d.readPanePTY(p)710 go d.readPanePTY(p)
709 }711 }
710 712
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-
714 func (s *session) detachIfCurrent(ac *attachedClient) bool {713 func (s *session) detachIfCurrent(ac *attachedClient) bool {
715 s.mu.Lock()714 s.mu.Lock()
716 defer s.mu.Unlock()715 defer s.mu.Unlock()
@@ -722,7 +721,7 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
722 return true721 return true
723 }722 }
724 723
725-// detachIfCurrent publishes terminal role invalidation through the attachment724+// detachIfCurrent publishes terminal attachment invalidation through the attachment
726 // gate. Freeze/drain happens before routing or session locks are acquired.725 // gate. Freeze/drain happens before routing or session locks are acquired.
727 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {726 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
728 return d.detachIfCurrentTransport(sess, ac, transportSnapshot{})727 return d.detachIfCurrentTransport(sess, ac, transportSnapshot{})
@@ -735,10 +734,10 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
735 if sess == nil || ac == nil {734 if sess == nil || ac == nil {
736 return false735 return false
737 }736 }
738- frozen := freezeRoleEffectGates(ac)737+ frozen := freezeAttachmentEffectGates(ac)
739 defer frozen.unfreeze()738 defer frozen.unfreeze()
740- if d.afterDetachRoleEffectsFrozen != nil {739+ if d.afterDetachAttachmentEffectsFrozen != nil {
741- d.afterDetachRoleEffectsFrozen()740+ d.afterDetachAttachmentEffectsFrozen()
742 }741 }
743 d.notices.routingMu.Lock()742 d.notices.routingMu.Lock()
744 sess.mu.Lock()743 sess.mu.Lock()
@@ -748,9 +747,9 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
748 }747 }
749 if current {748 if current {
750 sess.unregisterAttachmentLocked(ac)749 sess.unregisterAttachmentLocked(ac)
751- ac.roleGeneration.Add(1)750+ ac.connectionGeneration.Add(1)
752 ac.setSession(nil)751 ac.setSession(nil)
753- ac.invalidateFrozenRoleCapability()752+ ac.invalidateFrozenAttachmentCapability()
754 }753 }
755 sess.mu.Unlock()754 sess.mu.Unlock()
756 d.notices.routingMu.Unlock()755 d.notices.routingMu.Unlock()
@@ -765,10 +764,10 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
765 if p == nil || ac == nil {764 if p == nil || ac == nil {
766 return false765 return false
767 }766 }
768- frozen := freezeRoleEffectGates(ac)767+ frozen := freezeAttachmentEffectGates(ac)
769 defer frozen.unfreeze()768 defer frozen.unfreeze()
770- if d.afterDetachRoleEffectsFrozen != nil {769+ if d.afterDetachAttachmentEffectsFrozen != nil {
771- d.afterDetachRoleEffectsFrozen()770+ d.afterDetachAttachmentEffectsFrozen()
772 }771 }
773 d.notices.routingMu.Lock()772 d.notices.routingMu.Lock()
774 p.sessionCore.mu.Lock()773 p.sessionCore.mu.Lock()
@@ -778,9 +777,9 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
778 }777 }
779 if current {778 if current {
780 unregisterAttachmentSessionLocked(attachmentSession(p), ac)779 unregisterAttachmentSessionLocked(attachmentSession(p), ac)
781- ac.roleGeneration.Add(1)780+ ac.connectionGeneration.Add(1)
782 ac.setSession(nil)781 ac.setSession(nil)
783- ac.invalidateFrozenRoleCapability()782+ ac.invalidateFrozenAttachmentCapability()
784 }783 }
785 p.sessionCore.mu.Unlock()784 p.sessionCore.mu.Unlock()
786 d.notices.routingMu.Unlock()785 d.notices.routingMu.Unlock()
@@ -790,15 +789,15 @@ func (d *Daemon) detachProxyIfCurrentTransport(p *proxySession, ac *attachedClie
790 return current789 return current
791 }790 }
792 791
793-// detachIfRoleCurrent invalidates only the exact role generation and transport792+// detachIfAttachmentCurrent invalidates only the exact attachment generation and transport
794-// whose admitted send failed. A queued stale error cannot detach a later role793+// whose admitted send failed. A queued stale error cannot detach a later
795-// publication or a rebound transport on the same attachment object.794+// attachment publication or a rebound transport on the same attachment object.
796-func (d *Daemon) detachIfRoleCurrent(token attachmentRoleToken) bool {795+func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool {
797- return d.detachIfRoleCurrentUntil(token, nil)796+ return d.detachIfAttachmentCurrentUntil(token, nil)
798 }797 }
799 798
800-func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() <-chan struct{}) bool {799+func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {
801- if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil {800+ if token.sess == nil || token.ac == nil || token.transport.transport == nil {
802 return false801 return false
803 }802 }
804 // Attachments without the session's optional primary render lease still803 // Attachments without the session's optional primary render lease still
@@ -813,13 +812,13 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
813 }812 }
814 return false813 return false
815 }814 }
816- frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{done: done}, token.ac)815+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{done: done}, token.ac)
817 defer frozen.unfreeze()816 defer frozen.unfreeze()
818 if !frozen.acquired || !frozen.drained {817 if !frozen.acquired || !frozen.drained {
819 return false818 return false
820 }819 }
821- if d.afterDetachRoleEffectsFrozen != nil {820+ if d.afterDetachAttachmentEffectsFrozen != nil {
822- d.afterDetachRoleEffectsFrozen()821+ d.afterDetachAttachmentEffectsFrozen()
823 }822 }
824 d.mu.Lock()823 d.mu.Lock()
825 core := token.sess.core()824 core := token.sess.core()
@@ -834,15 +833,14 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func()
834 coordinator.mu.Lock()833 coordinator.mu.Lock()
835 }834 }
836 req := attachmentTransitionRequest{835 req := attachmentTransitionRequest{
837- source: token.sess, next: token.ac, expectedRole: token.role,836+ source: token.sess, next: token.ac, expectedTransport: token.transport,
838- expectedTransport: token.transport,
839 }837 }
840 current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)838 current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
841 if current {839 if current {
842 unregisterAttachmentSessionLocked(token.sess, token.ac)840 unregisterAttachmentSessionLocked(token.sess, token.ac)
843- token.ac.roleGeneration.Add(1)841+ token.ac.connectionGeneration.Add(1)
844 token.ac.setSession(nil)842 token.ac.setSession(nil)
845- token.ac.invalidateFrozenRoleCapability()843+ token.ac.invalidateFrozenAttachmentCapability()
846 }844 }
847 if coordinator != nil {845 if coordinator != nil {
848 coordinator.mu.Unlock()846 coordinator.mu.Unlock()
@@ -1132,33 +1130,33 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
1132 }1130 }
1133 1131
1134 type sessionKillAdmission struct {1132 type sessionKillAdmission struct {
1135- token attachmentRoleToken1133+ token attachmentConnectionToken
1136 action string1134 action string
1137 }1135 }
1138 1136
1139 type sessionKillParticipants struct {1137 type sessionKillParticipants struct {
1140 attachments map[*attachedClient]struct{}1138 attachments map[*attachedClient]struct{}
1141 transports map[*attachedClient]transportSnapshot1139 transports map[*attachedClient]transportSnapshot
1142- roleGates []*attachedClient1140+ effectGates []*attachedClient
1143- interrupts []roleTransportInterrupt1141+ interrupts []attachmentTransportInterrupt
1144 source *session1142 source *session
1145- sourceToken *attachmentRoleToken1143+ sourceToken *attachmentConnectionToken
1146 target *session1144 target *session
1147 }1145 }
1148 1146
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 {
1150 return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action})1148 return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action})
1151 }1149 }
1152 1150
1153-// snapshotSessionKillParticipants captures role membership under the canonical1151+// snapshotSessionKillParticipants captures attachment membership under the canonical
1154-// architecture locks, but releases every lock before any role gate is frozen or1152+// architecture locks, but releases every lock before any attachment gate is frozen or
1155 // drained. The initiator is part of the same deduplicated global gate set even1153 // drained. The initiator is part of the same deduplicated global gate set even
1156 // when it belongs to a different source session.1154 // when it belongs to a different source session.
1157 func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) {1155 func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission) (sessionKillParticipants, bool) {
1158 snapshot := sessionKillParticipants{target: target}1156 snapshot := sessionKillParticipants{target: target}
1159 if admission != nil {1157 if admission != nil {
1160 token := admission.token1158 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 {
1162 return sessionKillParticipants{}, false1160 return sessionKillParticipants{}, false
1163 }1161 }
1164 var ok bool1162 var ok bool
@@ -1200,7 +1198,7 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1200 return1198 return
1201 }1199 }
1202 seen[ac] = struct{}{}1200 seen[ac] = struct{}{}
1203- snapshot.roleGates = append(snapshot.roleGates, ac)1201+ snapshot.effectGates = append(snapshot.effectGates, ac)
1204 }1202 }
1205 if admission != nil {1203 if admission != nil {
1206 add(admission.token.ac)1204 add(admission.token.ac)
@@ -1210,14 +1208,14 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1210 }1208 }
1211 for ac, transport := range snapshot.transports {1209 for ac, transport := range snapshot.transports {
1212 if transport.transport != nil {1210 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})
1214 }1212 }
1215 }1213 }
1216 return snapshot, true1214 return snapshot, true
1217 }1215 }
1218 1216
1219 // sessionKillParticipantsCurrent repeats lifecycle, source-token, and exact1217 // 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.
1221 func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool {1219 func (d *Daemon) sessionKillParticipantsCurrent(snapshot sessionKillParticipants) bool {
1222 d.mu.Lock()1220 d.mu.Lock()
1223 defer d.mu.Unlock()1221 defer d.mu.Unlock()
@@ -1263,8 +1261,7 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
1263 }1261 }
1264 token := *snapshot.sourceToken1262 token := *snapshot.sourceToken
1265 req := attachmentTransitionRequest{1263 req := attachmentTransitionRequest{
1266- source: token.sess, next: token.ac, expectedRole: token.role,1264+ source: token.sess, next: token.ac, expectedTransport: token.transport,
1267- expectedTransport: token.transport,
1268 }1265 }
1269 return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)1266 return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
1270 }1267 }
@@ -1374,7 +1371,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1374 if sess == nil {1371 if sess == nil {
1375 return nil1372 return nil
1376 }1373 }
1377- // Discover every role whose membership can be invalidated before freezing1374+ // Discover every attachment whose membership can be invalidated before freezing
1378 // any one of them. The common gate helper deduplicates and freezes the whole1375 // any one of them. The common gate helper deduplicates and freezes the whole
1379 // immutable identity set in the same global order used by transitions.1376 // immutable identity set in the same global order used by transitions.
1380 participants, ok := d.snapshotSessionKillParticipants(sess, admission)1377 participants, ok := d.snapshotSessionKillParticipants(sess, admission)
@@ -1382,29 +1379,29 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1382 return nil1379 return nil
1383 }1380 }
1384 if admission != nil {1381 if admission != nil {
1385- d.endActionRoleEffect(admission.token.effect, admission.action)1382+ d.endActionAttachmentEffect(admission.token.effect, admission.action)
1386 }1383 }
1387 action := ""1384 action := ""
1388 if admission != nil {1385 if admission != nil {
1389 action = admission.action1386 action = admission.action
1390 }1387 }
1391- if d.afterRoleEffectParticipantsSnapshotted != nil {1388+ if d.afterAttachmentEffectParticipantsSnapshotted != nil {
1392- d.afterRoleEffectParticipantsSnapshotted(action, participants.roleGates)1389+ d.afterAttachmentEffectParticipantsSnapshotted(action, participants.effectGates)
1393 }1390 }
1394 var drainDone func() <-chan struct{}1391 var drainDone func() <-chan struct{}
1395- var drainDeadline *roleEffectDrainDeadline1392+ var drainDeadline *attachmentEffectDrainDeadline
1396 if deadline != nil {1393 if deadline != nil {
1397 drainDone = deadline.Done1394 drainDone = deadline.Done
1398 } else {1395 } else {
1399- drainDeadline = newRoleEffectDrainDeadline(d.clock)1396+ drainDeadline = newAttachmentEffectDrainDeadline(d.clock)
1400 drainDone = drainDeadline.Done1397 drainDone = drainDeadline.Done
1401 defer drainDeadline.stop()1398 defer drainDeadline.stop()
1402 }1399 }
1403- frozen := freezeRoleEffectGatesWith(roleEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {1400+ frozen := freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{interrupts: participants.interrupts, done: drainDone, afterFrozen: func(ac *attachedClient) {
1404- if d.afterRoleEffectGateFrozen != nil {1401+ if d.afterAttachmentEffectGateFrozen != nil {
1405- d.afterRoleEffectGateFrozen(action, ac)1402+ d.afterAttachmentEffectGateFrozen(action, ac)
1406 }1403 }
1407- }}, participants.roleGates...)1404+ }}, participants.effectGates...)
1408 defer func() { frozen.unfreeze() }()1405 defer func() { frozen.unfreeze() }()
1409 if !frozen.acquired {1406 if !frozen.acquired {
1410 // The acquisition helper has already rolled back exactly the gates it1407 // The acquisition helper has already rolled back exactly the gates it
@@ -1508,8 +1505,8 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1508 sess.snapshotMu.Unlock()1505 sess.snapshotMu.Unlock()
1509 }1506 }
1510 }1507 }
1511- // Revalidate the exact source capability, target lifecycle, and active plus1508+ // Revalidate the exact source capability, target lifecycle, and attachment
1512- // snatched memberships under canonical architecture locks immediately before1509+ // memberships under canonical architecture locks immediately before
1513 // publication. A changed preflight aborts without mutating either lifecycle.1510 // publication. A changed preflight aborts without mutating either lifecycle.
1514 d.mu.Lock()1511 d.mu.Lock()
1515 if d.sessions[sess.id] != sess || participants.source != nil && d.sessions[participants.source.id] != participants.source {1512 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
1534 }1531 }
1535 1532
1536 // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held1533 // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held
1537- // here. Registry removal and every role invalidation are one publication1534+ // here. Registry removal and every attachment invalidation are one publication
1538 // while the complete immutable gate set remains frozen.1535 // while the complete immutable gate set remains frozen.
1539 if !d.unregisterSessionLocked(sess) {1536 if !d.unregisterSessionLocked(sess) {
1540 unlockCoordinators()1537 unlockCoordinators()
@@ -1546,15 +1543,15 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1546 d.clearBarScriptsForSession(sess.id)1543 d.clearBarScriptsForSession(sess.id)
1547 d.purgeParkingForSessionLocked(sess)1544 d.purgeParkingForSessionLocked(sess)
1548 parkedRetirements := d.purgeParkedForSessionLocked(sess)1545 parkedRetirements := d.purgeParkedForSessionLocked(sess)
1549- attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates))1546+ attachments := make([]detachedAttachmentSnapshot, 0, len(participants.effectGates))
1550 capture := func(attached *attachedClient) {1547 capture := func(attached *attachedClient) {
1551 if attached == nil {1548 if attached == nil {
1552 return1549 return
1553 }1550 }
1554 attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})1551 attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})
1555- attached.roleGeneration.Add(1)1552+ attached.connectionGeneration.Add(1)
1556 attached.setSession(nil)1553 attached.setSession(nil)
1557- attached.invalidateFrozenRoleCapability()1554+ attached.invalidateFrozenAttachmentCapability()
1558 }1555 }
1559 for attached := range sess.attachments {1556 for attached := range sess.attachments {
1560 capture(attached)1557 capture(attached)
@@ -1582,7 +1579,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu
1582 d.mu.Unlock()1579 d.mu.Unlock()
1583 d.finishParkedAttachmentRetirements(parkedRetirements)1580 d.finishParkedAttachmentRetirements(parkedRetirements)
1584 frozen.unfreeze()1581 frozen.unfreeze()
1585- frozen = frozenRoleEffectGates{}1582+ frozen = frozenAttachmentEffectGates{}
1586 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)1583 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
1587 1584
1588 var purgeErr error1585 var purgeErr error
M internal/usecase/daemon/session_back.go +4 -4
@@ -7,7 +7,7 @@ import (
7 7
8 // backSession toggles this attachment between its current session and the8 // backSession toggles this attachment between its current session and the
9 // immediately preceding successfully activated session.9 // immediately preceding successfully activated session.
10-func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {10+func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
11 if d == nil || token.sess == nil || token.ac == nil {11 if d == nil || token.sess == nil || token.ac == nil {
12 return nil12 return nil
13 }13 }
@@ -21,7 +21,7 @@ func (d *Daemon) backSessionForRole(token attachmentRoleToken) error {
21 key := proxy.key21 key := proxy.key
22 pickerTarget.RemoteKey = &key22 pickerTarget.RemoteKey = &key
23 }23 }
24- return d.switchToTargetForRole(token, pickerTarget, sessionHandoffGuard{}, "back-session")24+ return d.switchToTargetForAttachment(token, pickerTarget, sessionHandoffGuard{}, "back-session")
25 }25 }
26 26
27 func (d *Daemon) backSession(current *session, ac *attachedClient) {27 func (d *Daemon) backSession(current *session, ac *attachedClient) {
@@ -35,14 +35,14 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
35 return35 return
36 }36 }
37 token := attachmentToken(current, ac, ac.transport())37 token := attachmentToken(current, ac, ac.transport())
38- effect, admitted := ac.beginRoleEffect(token)38+ effect, admitted := ac.beginAttachmentEffect(token)
39 if !admitted {39 if !admitted {
40 d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition))40 d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition))
41 return41 return
42 }42 }
43 token.effect = effect43 token.effect = effect
44 defer effect.End()44 defer effect.End()
45- if err := d.backSessionForRole(token); err != nil {45+ if err := d.backSessionForAttachment(token); err != nil {
46 d.reportError(current, err)46 d.reportError(current, err)
47 }47 }
48 }48 }
M internal/usecase/daemon/session_core.go +3 -3
@@ -28,9 +28,9 @@ type sessionCore struct {
28 name string28 name string
29 ephemeral bool29 ephemeral bool
30 caps sessionCapabilities30 caps sessionCapabilities
31- // attachments is the session-owned membership registry. Role transition31+ // attachments is the session-owned membership registry. Connection
32- // metadata below is separate from membership; admission and view code use32+ // generation and effect state remain attachment-local; admission and view
33- // this collection as their membership source.33+ // code use this collection as their membership source.
34 attachments map[*attachedClient]struct{}34 attachments map[*attachedClient]struct{}
35 attachmentOrder map[*attachedClient]uint6435 attachmentOrder map[*attachedClient]uint64
36 nextAttachmentID uint6436 nextAttachmentID uint64
M internal/usecase/daemon/session_core_test.go +2 -2
@@ -37,7 +37,7 @@ func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) {
37 core.mu.Lock()37 core.mu.Lock()
38 require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu")38 require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu")
39 core.registerAttachmentLocked(ac)39 core.registerAttachmentLocked(ac)
40- require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac))40+ require.Equal(t, true, sess.attachmentRegisteredLocked(ac))
41 sess.tabs = append(sess.tabs, &tab{name: "logs"})41 sess.tabs = append(sess.tabs, &tab{name: "logs"})
42 require.Len(t, sess.tabs, 2)42 require.Len(t, sess.tabs, 2)
43 core.mu.Unlock()43 core.mu.Unlock()
@@ -83,7 +83,7 @@ func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.
83 t.Fatal("snapshotView completed while sessionCore.mu guarded local session state")83 t.Fatal("snapshotView completed while sessionCore.mu guarded local session state")
84 case <-timer.C:84 case <-timer.C:
85 }85 }
86- require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac))86+ require.Equal(t, true, sess.attachmentRegisteredLocked(ac))
87 sess.core().mu.Unlock()87 sess.core().mu.Unlock()
88 88
89 view := awaitTestValue(t, snapshotted, "snapshotView did not complete after sessionCore.mu was released")89 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 {
78 // caller holds ac.sendMu, which serializes this snapshot shadow with every78 // caller holds ac.sendMu, which serializes this snapshot shadow with every
79 // output write. The shadow is committed only after the transport accepts the79 // output write. The shadow is committed only after the transport accepts the
80 // metadata frame.80 // 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 {
82 if ac == nil || sess == nil || !ac.proxied {82 if ac == nil || sess == nil || !ac.proxied {
83 return nil83 return nil
84 }84 }
@@ -91,7 +91,7 @@ func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected trans
91 91
92 // sendSessionMetaSnapshot sends an already captured metadata snapshot while92 // sendSessionMetaSnapshot sends an already captured metadata snapshot while
93 // the caller holds ac.sendMu.93 // 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 {
95 if ac == nil || !ac.proxied {95 if ac == nil || !ac.proxied {
96 return nil96 return nil
97 }97 }
M internal/usecase/daemon/session_meta_test.go +1 -1
@@ -197,7 +197,7 @@ func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) {
197 require.NoError(t, err)197 require.NoError(t, err)
198 198
199 token := sess.attachmentToken(ac, ac.transport())199 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}))
201 201
202 reply := awaitFrame(t, sends, ports.MsgCommandResult)202 reply := awaitFrame(t, sends, ports.MsgCommandResult)
203 result, err := ports.UnmarshalCommandResult(reply.Payload)203 result, err := ports.UnmarshalCommandResult(reply.Payload)
M internal/usecase/daemon/status_test.go +5 -5
@@ -541,7 +541,7 @@ func TestAttachClientAppliesForcedThemeBeforeMsgTheme(t *testing.T) {
541 }541 }
542 tr, _ := newCapturingTransport(t)542 tr, _ := newCapturingTransport(t)
543 543
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{})
545 require.NoError(t, err)545 require.NoError(t, err)
546 546
547 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)547 require.Equal(t, tc.want, ac.getAppliedTheme().Raw)
@@ -581,7 +581,7 @@ func TestAttachClientClearsStaleColorSchemeOnReplacement(t *testing.T) {
581 assertSessionColorScheme(t, sess, true)581 assertSessionColorScheme(t, sess, true)
582 582
583 tr, _ := newCapturingTransport(t)583 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{})
585 require.NoError(t, err)585 require.NoError(t, err)
586 586
587 assertSessionColorSchemeUnknown(t, sess)587 assertSessionColorSchemeUnknown(t, sess)
@@ -690,7 +690,7 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
690 })690 })
691 tr, _ := newCapturingTransport(t)691 tr, _ := newCapturingTransport(t)
692 692
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{})
694 require.NoError(t, err)694 require.NoError(t, err)
695 695
696 var got []byte696 var got []byte
@@ -740,7 +740,7 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
740 require.True(t, sess.detachIfCurrent(ac))740 require.True(t, sess.detachIfCurrent(ac))
741 741
742 tr, _ := newCapturingTransport(t)742 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{})
744 require.NoError(t, err)744 require.NoError(t, err)
745 d.applyTheme(sess, newAC, ports.Theme{745 d.applyTheme(sess, newAC, ports.Theme{
746 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},746 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
@@ -800,7 +800,7 @@ func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
800 d, sess, old, _ := newManualSessionWithPTYs(t, p)800 d, sess, old, _ := newManualSessionWithPTYs(t, p)
801 defer release()801 defer release()
802 tr, _ := newCapturingTransport(t)802 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{})
804 require.NoError(t, err)804 require.NoError(t, err)
805 805
806 d.applyTheme(sess, old, ports.Theme{806 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