detached 5a95bf3

refactor(daemon): deepen attachment lifecycle authority (#224)

* refactor(daemon): deepen attachment lifecycle authority

* fix(daemon): validate attachment effect authority
commit
5a95bf33d48d875c69f1bd931436e18b9ff13e13
author
bnema · · 2026-08-27 13:15 UTC
committer
GitHub · · 2026-08-27 13:15 UTC
parent
d77542a
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
67 files · +1184 -1056
M internal/usecase/daemon/attachment_connection.go +68 -78
@@ -2,44 +2,42 @@ package daemon
22
33 import "github.com/bnema/vev/internal/ports"
44
5-// attachmentConnectionToken is the exact capability for one registered
6-// attachment connection incarnation. It is invalid as soon as any of the
7-// session, attachment membership, connection generation, transport link, or
8-// coordinator lease changes.
9-type attachmentConnectionToken struct {
5+// attachmentCapability is the exact capability for one registered
6+// Attachment Connection incarnation. It is invalid as soon as its Session
7+// membership, generation, Transport incarnation, or render lease changes.
8+type attachmentCapability struct {
109 sess *session
1110 ac *attachedClient
1211 generation uint64
1312 transport transportSnapshot
1413 lease *attachmentLease
15- effect *attachmentEffectTicket
1614 // rebase marks a cross-session move whose first paint starts a dependency-
1715 // free output chain after publication releases architecture locks.
1816 rebase bool
1917 }
2018
21-func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
19+func captureAttachmentCapability(entry *session, ac *attachedClient, tr ports.Transport) attachmentCapability {
2220 if entry == nil || entry.core() == nil || ac == nil || tr == nil {
23- return attachmentConnectionToken{}
21+ return attachmentCapability{}
2422 }
2523 // Capture one concrete link incarnation before taking the session lock. The
2624 // same snapshot is revalidated below; do not take a second snapshot and
2725 // accidentally bind a replacement link.
2826 transport := ac.transportSnapshot()
2927 if transport.transport != tr {
30- return attachmentConnectionToken{}
28+ return attachmentCapability{}
3129 }
32- if ac.beforeAttachmentTokenValidation != nil {
33- ac.beforeAttachmentTokenValidation()
30+ if ac.beforeAttachmentCapabilityValidation != nil {
31+ ac.beforeAttachmentCapabilityValidation()
3432 }
3533 core := entry.core()
3634 core.mu.Lock()
3735 if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(transport) {
3836 core.mu.Unlock()
39- return attachmentConnectionToken{}
37+ return attachmentCapability{}
4038 }
41- generation := ac.connectionGeneration.Load()
42- token := attachmentConnectionToken{
39+ generation := ac.lifecycle.generation.Load()
40+ token := attachmentCapability{
4341 sess: entry,
4442 ac: ac,
4543 generation: generation,
@@ -50,101 +48,93 @@ func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) att
5048 }
5149 core.mu.Unlock()
5250 if !token.current() {
53- return attachmentConnectionToken{}
51+ return attachmentCapability{}
5452 }
55- ac.bootstrapAttachmentCapability(token)
53+ ac.lifecycle.installInitialCapability(token)
5654 return token
5755 }
5856
59-func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentConnectionToken {
60- return attachmentToken(s, ac, tr)
57+func (s *session) captureAttachmentCapability(ac *attachedClient, tr ports.Transport) attachmentCapability {
58+ return captureAttachmentCapability(s, ac, tr)
6159 }
6260
63-// current validates every immutable identity captured by the token. Membership
64-// is the sole authority; there is no owner, replacement, or compatibility role.
65-func (t attachmentConnectionToken) current() bool {
66- return t.sess != nil && t.ac != nil &&
67- t.ac.connectionGeneration.Load() == t.generation &&
68- t.ac.currentAttachmentSession() == t.sess &&
69- t.ac.transportSnapshotCurrent(t.transport) &&
70- attachmentRegistered(t.sess, t.ac)
61+func (t attachmentCapability) sameIdentity(other attachmentCapability) bool {
62+ return t.sess != nil && t.ac != nil && t.sess == other.sess && t.ac == other.ac &&
63+ t.generation == other.generation && t.transport.transport == other.transport.transport &&
64+ t.transport.incarnation == other.transport.incarnation && t.lease == other.lease
7165 }
7266
73-// attachmentCurrent additionally validates the optional coordinator lease used
74-// by render and resize effects. Attachments without that optional lease still
75-// own their independent connection lifecycle.
76-func (t attachmentConnectionToken) attachmentCurrent() bool {
77- if !t.current() || t.ac.currentAttachmentSession() != t.sess {
67+func (t attachmentCapability) matchesConnectionSnapshot(sess *session, ac *attachedClient, transport transportSnapshot) bool {
68+ return t.sess == sess && t.ac == ac && ac != nil && t.transport == transport &&
69+ ac.lifecycle.identityCurrent(t)
70+}
71+
72+func (l *attachmentLifecycle) identityCurrent(capability attachmentCapability) bool {
73+ return l != nil && capability.sess != nil && capability.ac != nil &&
74+ capability.ac.lifecycle.generation.Load() == capability.generation &&
75+ capability.ac.currentAttachmentSession() == capability.sess &&
76+ capability.ac.transportSnapshotCurrent(capability.transport)
77+}
78+
79+// current validates every immutable identity captured by the capability.
80+// Membership is the sole authority; there is no owner, replacement, or
81+// compatibility role.
82+func (l *attachmentLifecycle) current(capability attachmentCapability) bool {
83+ if !l.identityCurrent(capability) || !attachmentRegistered(capability.sess, capability.ac) {
7884 return false
7985 }
80- if t.lease == nil {
86+ if capability.lease == nil {
8187 return true
8288 }
83- rc := t.sess.core().coordinator.Load()
84- return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
89+ rc := capability.sess.core().coordinator.Load()
90+ return rc != nil && capability.lease.attachment == capability.ac && rc.leaseCurrent(capability.lease, false)
8591 }
8692
87-func (t attachmentConnectionToken) attachmentEffectCurrent() bool {
88- if t.effect != nil && !t.effect.ended.Load() {
89- return true
90- }
91- return t.attachmentCurrent()
93+func (t attachmentCapability) current() bool {
94+ return t.ac != nil && t.ac.lifecycle.current(t)
9295 }
9396
94-func beginAttachmentLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {
97+func beginAttachmentLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*attachmentEffect, bool) {
9598 if sess == nil || ac == nil || lease == nil {
9699 return nil, false
97100 }
98- token := attachmentToken(sess, ac, ac.transport())
101+ token := captureAttachmentCapability(sess, ac, ac.transport())
99102 token.lease = lease
100103 return ac.beginAttachmentEffect(token)
101104 }
102105
103-func (t *attachmentConnectionToken) endAttachmentEffect() {
104- if t == nil || t.effect == nil {
105- return
106- }
107- t.effect.End()
108- t.effect = nil
106+func (t attachmentCapability) currentInSessionLocked(sess *session) bool {
107+ return t.sess == sess && t.ac != nil && t.ac.lifecycle.currentSessionLocked(t)
109108 }
110109
111-// attachmentEffectCurrentSessionLocked validates authority at a session-owned
112-// mutation boundary where sess.core().mu is already held. A live effect ticket
113-// preserves the exact capability it captured for the effect because attachment
114-// transitions drain the gate before changing identity. This differs from
115-// attachmentCurrent, which revalidates the attachment's currently published identity.
116-func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {
117- if t.effect != nil && !t.effect.ended.Load() {
118- return true
119- }
120- if t.sess == nil || t.ac == nil ||
121- t.ac.connectionGeneration.Load() != t.generation ||
122- !t.ac.transportSnapshotCurrent(t.transport) ||
123- t.ac.currentAttachmentSession() != t.sess ||
124- !attachmentRegisteredLocked(t.sess, t.ac) {
110+func (t attachmentCapability) currentInSessionAndLeaseLocked(sess *session, ac *attachedClient, coordinator *renderCoordinator) bool {
111+ return t.sess == sess && t.ac == ac && ac != nil &&
112+ ac.lifecycle.currentSessionAndLeaseLocked(t, coordinator)
113+}
114+
115+// currentSessionLocked validates capability authority where sess.core().mu is
116+// already held. An admitted effect does not use this comparison: a transition
117+// cannot change identity until that effect ends.
118+func (l *attachmentLifecycle) currentSessionLocked(capability attachmentCapability) bool {
119+ if !l.identityCurrent(capability) || !attachmentRegisteredLocked(capability.sess, capability.ac) {
125120 return false
126121 }
127- if t.lease == nil {
122+ if capability.lease == nil {
128123 return true
129124 }
130- rc := t.sess.core().coordinator.Load()
131- return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)
125+ rc := capability.sess.core().coordinator.Load()
126+ return rc != nil && capability.lease.attachment == capability.ac && rc.leaseCurrent(capability.lease, false)
132127 }
133128
134-func (t attachmentConnectionToken) sendControl(frame ports.Frame) error {
135- if t.ac == nil || t.transport.transport == nil {
136- return errAttachmentTransition
137- }
138- t.ac.sendMu.Lock()
139- defer t.ac.sendMu.Unlock()
140- if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) ||
141- !t.effect.beginTransportSend(t.transport) {
142- return errAttachmentTransition
129+// currentSessionAndLeaseLocked validates the same capability tuple when the
130+// caller already holds both the Session and coordinator locks.
131+func (l *attachmentLifecycle) currentSessionAndLeaseLocked(capability attachmentCapability, coordinator *renderCoordinator) bool {
132+ if !l.identityCurrent(capability) || !attachmentRegisteredLocked(capability.sess, capability.ac) {
133+ return false
143134 }
144- err := t.transport.transport.Send(frame)
145- if err != nil {
146- t.effect.reportTransportFailure(t.transport)
135+ if capability.lease == nil {
136+ return true
147137 }
148- t.effect.endTransportSend()
149- return err
138+ return coordinator != nil && capability.lease.attachment == capability.ac &&
139+ coordinator.leaseCurrentLocked(capability.lease, false)
150140 }
M internal/usecase/daemon/attachment_effect_freeze.go +17 -17
@@ -60,7 +60,7 @@ func (d *attachmentEffectDrainDeadline) Done() <-chan struct{} {
6060 return d.done
6161 }
6262
63-type frozenAttachmentEffectGates struct {
63+type attachmentTransitionGuard struct {
6464 clients []*attachedClient
6565 interruptions map[*attachedClient]transportSnapshot
6666 acquired bool
@@ -84,7 +84,7 @@ type attachmentEffectFreezeOptions struct {
8484
8585 // freezeAttachmentEffectGates establishes transition priority in immutable attachment
8686 // order, then drains earlier admissions.
87-func freezeAttachmentEffectGates(clients ...*attachedClient) frozenAttachmentEffectGates {
87+func freezeAttachmentEffectGates(clients ...*attachedClient) attachmentTransitionGuard {
8888 return freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{}, clients...)
8989 }
9090
@@ -93,11 +93,11 @@ func freezeAttachmentEffectGates(clients ...*attachedClient) frozenAttachmentEff
9393 // behind another owner, only gates acquired by this call are rolled back, in
9494 // reverse order. nonblocking is used by move reservations to avoid inverting
9595 // the move/teardown protocols.
96-func freezeAttachmentEffectGatesWith(options attachmentEffectFreezeOptions, clients ...*attachedClient) frozenAttachmentEffectGates {
96+func freezeAttachmentEffectGatesWith(options attachmentEffectFreezeOptions, clients ...*attachedClient) attachmentTransitionGuard {
9797 ordered := orderedAttachmentEffectClients(clients)
9898 frozen, acquired := acquireAttachmentEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)
9999 if !acquired {
100- return frozenAttachmentEffectGates{}
100+ return attachmentTransitionGuard{}
101101 }
102102
103103 interruptByClient := attachmentTransportInterruptsByClient(options.interrupts)
@@ -121,18 +121,18 @@ func orderedAttachmentEffectClients(clients []*attachedClient) []*attachedClient
121121 continue
122122 }
123123 unique[ac] = struct{}{}
124- ac.attachmentEffects.immutableOrder()
124+ ac.lifecycle.immutableOrder()
125125 ordered = append(ordered, ac)
126126 }
127127 sort.Slice(ordered, func(i, j int) bool {
128- return ordered[i].attachmentEffects.order.Load() < ordered[j].attachmentEffects.order.Load()
128+ return ordered[i].lifecycle.order.Load() < ordered[j].lifecycle.order.Load()
129129 })
130130 return ordered
131131 }
132132
133133 // waitForAttachmentEffectChangeLocked requires g.mu and returns with g.mu held. A
134134 // true result means the overall deadline won and the caller must stop waiting.
135-func waitForAttachmentEffectChangeLocked(g *attachmentEffectGate, done func() <-chan struct{}) bool {
135+func waitForAttachmentEffectChangeLocked(g *attachmentLifecycle, done func() <-chan struct{}) bool {
136136 if done == nil {
137137 g.cond.Wait()
138138 return false
@@ -156,22 +156,22 @@ func waitForAttachmentEffectChangeLocked(g *attachmentEffectGate, done func() <-
156156 // acquireAttachmentEffectGates establishes exclusive transition ownership in order.
157157 // It must run without architecture locks. Deadline rollback releases only this
158158 // call's partial acquisition and does so in reverse order.
159-func acquireAttachmentEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (frozenAttachmentEffectGates, bool) {
160- frozen := frozenAttachmentEffectGates{clients: make([]*attachedClient, 0, len(ordered))}
159+func acquireAttachmentEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (attachmentTransitionGuard, bool) {
160+ frozen := attachmentTransitionGuard{clients: make([]*attachedClient, 0, len(ordered))}
161161 for _, ac := range ordered {
162- g := &ac.attachmentEffects
162+ g := &ac.lifecycle
163163 g.mu.Lock()
164164 g.initLocked()
165165 for g.phase == attachmentEffectsFrozen {
166166 if nonblocking {
167167 g.mu.Unlock()
168168 frozen.unfreeze()
169- return frozenAttachmentEffectGates{}, false
169+ return attachmentTransitionGuard{}, false
170170 }
171171 if waitForAttachmentEffectChangeLocked(g, done) {
172172 g.mu.Unlock()
173173 frozen.unfreeze()
174- return frozenAttachmentEffectGates{}, false
174+ return attachmentTransitionGuard{}, false
175175 }
176176 }
177177 g.phase = attachmentEffectsFrozen
@@ -204,7 +204,7 @@ func interruptFrozenAttachmentTransports(ordered []*attachedClient, interruptByC
204204 if !mayInterrupt {
205205 continue
206206 }
207- g := &ac.attachmentEffects
207+ g := &ac.lifecycle
208208 g.mu.Lock()
209209 if gateHasTransportEffectLocked(g, expected) {
210210 toClose = append(toClose, attachmentTransportInterrupt{ac: ac, transport: expected})
@@ -221,7 +221,7 @@ func interruptFrozenAttachmentTransports(ordered []*attachedClient, interruptByC
221221 // late tickets can then retire without pinning the owner indefinitely.
222222 func drainFrozenAttachmentEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool {
223223 for _, ac := range ordered {
224- g := &ac.attachmentEffects
224+ g := &ac.lifecycle
225225 g.mu.Lock()
226226 for g.inFlight != 0 {
227227 if waitForAttachmentEffectChangeLocked(g, done) {
@@ -238,7 +238,7 @@ func drainFrozenAttachmentEffects(ordered []*attachedClient, interruptByClient,
238238 return true
239239 }
240240
241-func gateHasTransportEffectLocked(g *attachmentEffectGate, expected transportSnapshot) bool {
241+func gateHasTransportEffectLocked(g *attachmentLifecycle, expected transportSnapshot) bool {
242242 for _, active := range g.transportEffects {
243243 if active.transport == expected.transport && active.incarnation == expected.incarnation {
244244 return true
@@ -247,9 +247,9 @@ func gateHasTransportEffectLocked(g *attachmentEffectGate, expected transportSna
247247 return false
248248 }
249249
250-func (f frozenAttachmentEffectGates) unfreeze() {
250+func (f attachmentTransitionGuard) unfreeze() {
251251 for i := len(f.clients) - 1; i >= 0; i-- {
252- g := &f.clients[i].attachmentEffects
252+ g := &f.clients[i].lifecycle
253253 g.mu.Lock()
254254 g.initLocked()
255255 g.phase = attachmentEffectsStable
M internal/usecase/daemon/attachment_effect_gate.go +108 -101
@@ -15,46 +15,33 @@ const (
1515 attachmentEffectsFrozen
1616 )
1717
18-// attachmentCapability is the single attachment-local publication consumed by
19-// effect admission. It deliberately contains no independently mutable state:
20-// a ticket is admitted only when every field matches the frame's token.
21-type attachmentCapability struct {
22- sess *session
23- generation uint64
24- transport transportSnapshot
25- lease *attachmentLease
26-}
27-
28-func capabilityFromToken(token attachmentConnectionToken) attachmentCapability {
29- return attachmentCapability{
30- sess: token.sess,
31- generation: token.generation,
32- transport: token.transport,
33- lease: token.lease,
34- }
35-}
36-
37-func (c attachmentCapability) matches(token attachmentConnectionToken) bool {
38- return token.ac != nil && c.sess != nil && token.sess != nil && c.sess == token.sess &&
39- c.generation == token.generation && c.transport.transport == token.transport.transport &&
40- c.transport.incarnation == token.transport.incarnation && c.lease == token.lease
41-}
42-
43-type attachmentEffectGate struct {
18+// attachmentLifecycle owns the immutable committed capability and the
19+// linearization gate for every attachment-bound observable effect. Transitions
20+// freeze and drain it before changing membership, generation, Transport, or
21+// render lease identity.
22+type attachmentLifecycle struct {
23+ generation atomic.Uint64
4424 mu sync.Mutex
4525 cond *sync.Cond
4626 changed chan struct{}
4727 phase attachmentEffectPhase
4828 capability attachmentCapability
4929 inFlight uint64
50- transportEffects map[*attachmentEffectTicket]transportSnapshot
30+ transportEffects map[*attachmentEffect]transportSnapshot
5131 failedTransport transportSnapshot
5232 order atomic.Uint64
5333 }
5434
5535 var nextAttachmentEffectGateOrder atomic.Uint64
5636
57-func (g *attachmentEffectGate) initLocked() {
37+func (g *attachmentLifecycle) generationValue() uint64 {
38+ if g == nil {
39+ return 0
40+ }
41+ return g.generation.Load()
42+}
43+
44+func (g *attachmentLifecycle) initLocked() {
5845 if g.cond == nil {
5946 g.cond = sync.NewCond(&g.mu)
6047 }
@@ -63,14 +50,14 @@ func (g *attachmentEffectGate) initLocked() {
6350 }
6451 }
6552
66-func (g *attachmentEffectGate) signalLocked() {
53+func (g *attachmentLifecycle) signalLocked() {
6754 g.initLocked()
6855 close(g.changed)
6956 g.changed = make(chan struct{})
7057 g.cond.Broadcast()
7158 }
7259
73-func (g *attachmentEffectGate) immutableOrder() uint64 {
60+func (g *attachmentLifecycle) immutableOrder() uint64 {
7461 if order := g.order.Load(); order != 0 {
7562 return order
7663 }
@@ -81,7 +68,7 @@ func (g *attachmentEffectGate) immutableOrder() uint64 {
8168 return g.order.Load()
8269 }
8370
84-func (g *attachmentEffectGate) inFlightCount() uint64 {
71+func (g *attachmentLifecycle) inFlightCount() uint64 {
8572 if g == nil {
8673 return 0
8774 }
@@ -90,17 +77,18 @@ func (g *attachmentEffectGate) inFlightCount() uint64 {
9077 return g.inFlight
9178 }
9279
93-// attachmentEffectTicket reserves the exact published capability until End. End is
94-// idempotent so error paths can safely defer it before doing any observable I/O.
95-type attachmentEffectTicket struct {
96- gate *attachmentEffectGate
97- token attachmentConnectionToken
80+// attachmentEffect is an explicit admission of one immutable capability. It
81+// remains valid until End because transitions freeze admission and drain all
82+// existing effects before changing lifecycle identity. End is idempotent.
83+type attachmentEffect struct {
84+ attachmentCapability
85+ lifecycle *attachmentLifecycle
9886 actionDaemon *Daemon
9987 action string
10088 ended atomic.Bool
10189 }
10290
103-func (t *attachmentEffectTicket) bindActionEnd(d *Daemon, action string) {
91+func (t *attachmentEffect) bindActionEnd(d *Daemon, action string) {
10492 if t == nil || t.ended.Load() {
10593 return
10694 }
@@ -108,25 +96,31 @@ func (t *attachmentEffectTicket) bindActionEnd(d *Daemon, action string) {
10896 t.action = action
10997 }
11098
111-func (t *attachmentEffectTicket) connectionToken() attachmentConnectionToken {
99+func (t *attachmentEffect) capability() attachmentCapability {
112100 if t == nil {
113- return attachmentConnectionToken{}
101+ return attachmentCapability{}
114102 }
115- token := t.token
116- token.effect = t
117- return token
103+ return t.attachmentCapability
104+}
105+
106+func (t *attachmentEffect) current() bool {
107+ return t != nil && !t.ended.Load()
108+}
109+
110+func (t *attachmentEffect) currentSessionLocked() bool {
111+ return t.current()
118112 }
119113
120-func (t *attachmentEffectTicket) End() {
121- if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) {
114+func (t *attachmentEffect) End() {
115+ if t == nil || t.lifecycle == nil || !t.ended.CompareAndSwap(false, true) {
122116 return
123117 }
124- g := t.gate
118+ g := t.lifecycle
125119 g.mu.Lock()
126120 g.initLocked()
127121 if g.inFlight == 0 {
128122 g.mu.Unlock()
129- panic("daemon: attachment effect ticket ended without admission")
123+ panic("daemon: attachment effect ended without admission")
130124 }
131125 delete(g.transportEffects, t)
132126 g.inFlight--
@@ -137,7 +131,7 @@ func (t *attachmentEffectTicket) End() {
137131 }
138132 }
139133
140-func (d *Daemon) endActionAttachmentEffect(effect *attachmentEffectTicket, _ string) {
134+func (d *Daemon) endActionAttachmentEffect(effect *attachmentEffect, _ string) {
141135 if effect != nil {
142136 effect.End()
143137 }
@@ -146,11 +140,11 @@ func (d *Daemon) endActionAttachmentEffect(effect *attachmentEffectTicket, _ str
146140 // beginTransportSend marks only the interval spent in an external transport
147141 // send as interruptible. A replacement may close this exact published link to
148142 // release the send without retiring an idle healthy attachment.
149-func (t *attachmentEffectTicket) beginTransportSend(expected transportSnapshot) bool {
150- if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil {
143+func (t *attachmentEffect) beginTransportSend(expected transportSnapshot) bool {
144+ if t == nil || t.lifecycle == nil || t.ended.Load() || expected.transport == nil {
151145 return false
152146 }
153- g := t.gate
147+ g := t.lifecycle
154148 g.mu.Lock()
155149 g.initLocked()
156150 if t.ended.Load() || g.capability.transport.transport != expected.transport ||
@@ -159,7 +153,7 @@ func (t *attachmentEffectTicket) beginTransportSend(expected transportSnapshot)
159153 return false
160154 }
161155 if g.transportEffects == nil {
162- g.transportEffects = make(map[*attachmentEffectTicket]transportSnapshot)
156+ g.transportEffects = make(map[*attachmentEffect]transportSnapshot)
163157 }
164158 g.transportEffects[t] = expected
165159 g.signalLocked()
@@ -167,11 +161,11 @@ func (t *attachmentEffectTicket) beginTransportSend(expected transportSnapshot)
167161 return true
168162 }
169163
170-func (t *attachmentEffectTicket) reportTransportFailure(expected transportSnapshot) {
171- if t == nil || t.gate == nil || expected.transport == nil {
164+func (t *attachmentEffect) reportTransportFailure(expected transportSnapshot) {
165+ if t == nil || t.lifecycle == nil || expected.transport == nil {
172166 return
173167 }
174- g := t.gate
168+ g := t.lifecycle
175169 g.mu.Lock()
176170 if !t.ended.Load() && g.capability.transport.transport == expected.transport &&
177171 g.capability.transport.incarnation == expected.incarnation {
@@ -181,11 +175,11 @@ func (t *attachmentEffectTicket) reportTransportFailure(expected transportSnapsh
181175 g.mu.Unlock()
182176 }
183177
184-func (t *attachmentEffectTicket) endTransportSend() {
185- if t == nil || t.gate == nil {
178+func (t *attachmentEffect) endTransportSend() {
179+ if t == nil || t.lifecycle == nil {
186180 return
187181 }
188- g := t.gate
182+ g := t.lifecycle
189183 g.mu.Lock()
190184 if _, exists := g.transportEffects[t]; exists {
191185 delete(g.transportEffects, t)
@@ -194,45 +188,57 @@ func (t *attachmentEffectTicket) endTransportSend() {
194188 g.mu.Unlock()
195189 }
196190
191+func (t *attachmentEffect) sendControl(frame ports.Frame) error {
192+ if !t.current() || t.ac == nil || t.transport.transport == nil {
193+ return errAttachmentTransition
194+ }
195+ t.ac.sendMu.Lock()
196+ defer t.ac.sendMu.Unlock()
197+ if !t.current() || !t.ac.transportSnapshotCurrent(t.transport) || !t.beginTransportSend(t.transport) {
198+ return errAttachmentTransition
199+ }
200+ err := t.transport.transport.Send(frame)
201+ if err != nil {
202+ t.reportTransportFailure(t.transport)
203+ }
204+ t.endTransportSend()
205+ return err
206+}
207+
197208 // beginAttachmentEffect is the sole attachment-bound effect admission point. The gate is
198209 // held only long enough to reserve the capability; the effect itself must run
199210 // without the gate mutex.
200-func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken) (*attachmentEffectTicket, bool) {
201- if ac == nil || token.ac != ac {
211+func (ac *attachedClient) beginAttachmentEffect(token attachmentCapability) (*attachmentEffect, bool) {
212+ if ac == nil || token.ac != ac || !token.current() {
202213 return nil, false
203214 }
204- g := &ac.attachmentEffects
215+ g := &ac.lifecycle
205216 g.mu.Lock()
206217 g.initLocked()
207- if g.phase != attachmentEffectsStable || !g.capability.matches(token) {
218+ if g.phase != attachmentEffectsStable || !g.capability.sameIdentity(token) {
208219 g.mu.Unlock()
209220 return nil, false
210221 }
211222 g.inFlight++
212223 g.mu.Unlock()
213- captured := token
214- captured.effect = nil
215- return &attachmentEffectTicket{gate: g, token: captured}, true
224+ return &attachmentEffect{attachmentCapability: token, lifecycle: g}, true
216225 }
217226
218227 // beginCurrentAttachmentEffect waits out a transition that already froze ac, then
219228 // admits the capability derived from the session registry. Handshakes use it
220229 // after Welcome has completed so a replacement blocked behind that send can
221230 // publish before readiness or first paint.
222-func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
231+func (ac *attachedClient) beginCurrentAttachmentEffect(sess *session, tr ports.Transport) (attachmentCapability, *attachmentEffect, bool) {
223232 //nolint:contextcheck // This compatibility wrapper deliberately delegates with a fresh context; cancellable callers use the context variant below.
224233 return ac.beginCurrentAttachmentEffectContext(context.Background(), sess, tr)
225234 }
226235
227-func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Context, sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {
228- if ctx == nil {
229- ctx = context.Background()
230- }
231- if ac == nil || sess == nil || tr == nil {
232- return attachmentConnectionToken{}, nil, false
236+func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Context, sess *session, tr ports.Transport) (attachmentCapability, *attachmentEffect, bool) {
237+ if ctx == nil || ac == nil || sess == nil || tr == nil {
238+ return attachmentCapability{}, nil, false
233239 }
234240 for {
235- token := sess.attachmentToken(ac, tr)
241+ token := sess.captureAttachmentCapability(ac, tr)
236242 if token.ac == nil {
237243 return token, nil, false
238244 }
@@ -240,7 +246,7 @@ func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Contex
240246 return token, ticket, true
241247 }
242248
243- g := &ac.attachmentEffects
249+ g := &ac.lifecycle
244250 g.mu.Lock()
245251 g.initLocked()
246252 if g.phase == attachmentEffectsFrozen {
@@ -254,8 +260,8 @@ func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Contex
254260 }
255261 }
256262 g.mu.Unlock()
257- // A stable mismatch is only recoverable when a publication raced token
258- // capture; an already-stale token proves that happened. A current token
263+ // A stable mismatch is only recoverable when a publication raced capability
264+ // capture; an already-stale capability proves that happened. A current capability
259265 // with a stable mismatch violates the publication invariant, so fail
260266 // closed instead of spinning forever.
261267 if token.current() {
@@ -266,58 +272,59 @@ func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Contex
266272 }
267273 }
268274
269-// publishAttachmentCapability is used by direct/headless setup and by transition
270-// publication while the gate is frozen. Production attachment changes publish only
271-// through publishFrozenAttachmentCapability.
272-func (ac *attachedClient) publishAttachmentCapability(token attachmentConnectionToken) {
273- if ac == nil {
274- return
275- }
276- g := &ac.attachmentEffects
275+// installInitialCapability publishes only an empty lifecycle owner. It supports
276+// direct/headless construction without creating a second mutable publication.
277+func (g *attachmentLifecycle) installInitialCapability(capability attachmentCapability) {
277278 g.mu.Lock()
278279 g.initLocked()
279- g.capability = capabilityFromToken(token)
280- g.failedTransport = transportSnapshot{}
280+ if g.phase == attachmentEffectsStable && g.capability.sess == nil {
281+ g.capability = capability
282+ g.failedTransport = transportSnapshot{}
283+ }
281284 g.mu.Unlock()
282285 }
283286
284-// bootstrapAttachmentCapability supports direct/headless session construction. It
285-// never changes an existing or frozen production publication.
286-func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) {
287- if ac == nil || token.sess == nil || token.ac != ac {
288- return
289- }
290- g := &ac.attachmentEffects
287+func (ac *attachedClient) publishFrozenAttachmentCapability(capability attachmentCapability) attachmentCapability {
288+ g := &ac.lifecycle
291289 g.mu.Lock()
292290 g.initLocked()
293- if g.phase == attachmentEffectsStable && g.capability.sess == nil {
294- g.capability = capabilityFromToken(token)
295- g.failedTransport = transportSnapshot{}
291+ if g.phase != attachmentEffectsFrozen {
292+ g.mu.Unlock()
293+ panic("daemon: publishing attachment capability without transition freeze")
296294 }
295+ capability.generation = g.generation.Add(1)
296+ g.capability = capability
297+ g.failedTransport = transportSnapshot{}
297298 g.mu.Unlock()
299+ return capability
298300 }
299301
300-func (ac *attachedClient) publishFrozenAttachmentCapability(token attachmentConnectionToken) {
301- g := &ac.attachmentEffects
302+// invalidateRetiredAttachmentCapability retires parked identity either while
303+// stable with no admitted effects or under a terminal teardown's existing
304+// freeze. Live membership transitions use invalidateFrozenAttachmentCapability.
305+func (ac *attachedClient) invalidateRetiredAttachmentCapability() {
306+ g := &ac.lifecycle
302307 g.mu.Lock()
303308 g.initLocked()
304- if g.phase != attachmentEffectsFrozen {
309+ if g.phase != attachmentEffectsFrozen && (g.phase != attachmentEffectsStable || g.inFlight != 0) {
305310 g.mu.Unlock()
306- panic("daemon: publishing attachment capability without transition freeze")
311+ panic("daemon: invalidating an active attachment capability")
307312 }
308- g.capability = capabilityFromToken(token)
313+ g.generation.Add(1)
314+ g.capability = attachmentCapability{}
309315 g.failedTransport = transportSnapshot{}
310316 g.mu.Unlock()
311317 }
312318
313319 func (ac *attachedClient) invalidateFrozenAttachmentCapability() {
314- g := &ac.attachmentEffects
320+ g := &ac.lifecycle
315321 g.mu.Lock()
316322 g.initLocked()
317323 if g.phase != attachmentEffectsFrozen {
318324 g.mu.Unlock()
319325 panic("daemon: invalidating attachment capability without transition freeze")
320326 }
327+ g.generation.Add(1)
321328 g.capability = attachmentCapability{}
322329 g.failedTransport = transportSnapshot{}
323330 g.mu.Unlock()
M internal/usecase/daemon/attachment_effect_gate_test.go +73 -61
@@ -118,7 +118,7 @@ func (t *teardownBlockingTransport) Closed() bool {
118118 }
119119 }
120120
121-func startBlockedAttachmentSend(t *testing.T, token attachmentConnectionToken) <-chan struct{} {
121+func startBlockedAttachmentSend(t *testing.T, token attachmentCapability) <-chan struct{} {
122122 t.Helper()
123123 ticket, admitted := token.ac.beginAttachmentEffect(token)
124124 require.True(t, admitted)
@@ -133,19 +133,19 @@ func startBlockedAttachmentSend(t *testing.T, token attachmentConnectionToken) <
133133
134134 func requireAttachmentEffectGateRetired(t *testing.T, ac *attachedClient) {
135135 t.Helper()
136- ac.attachmentEffects.mu.Lock()
137- defer ac.attachmentEffects.mu.Unlock()
138- require.Equal(t, attachmentEffectsStable, ac.attachmentEffects.phase)
139- require.Zero(t, ac.attachmentEffects.inFlight)
140- require.Empty(t, ac.attachmentEffects.transportEffects)
136+ ac.lifecycle.mu.Lock()
137+ defer ac.lifecycle.mu.Unlock()
138+ require.Equal(t, attachmentEffectsStable, ac.lifecycle.phase)
139+ require.Zero(t, ac.lifecycle.inFlight)
140+ require.Empty(t, ac.lifecycle.transportEffects)
141141 }
142142
143143 func TestBeginCurrentAttachmentEffectFailsClosedOnStableCapabilityMismatch(t *testing.T) {
144144 _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
145- current := sess.attachmentToken(ac, ac.transport())
145+ current := sess.captureAttachmentCapability(ac, ac.transport())
146146 stale := current
147147 stale.generation++
148- ac.publishAttachmentCapability(stale)
148+ ac.installTestAttachmentCapability(stale)
149149
150150 result := make(chan bool, 1)
151151 go func() {
@@ -163,6 +163,16 @@ func TestBeginCurrentAttachmentEffectFailsClosedOnStableCapabilityMismatch(t *te
163163 }
164164 }
165165
166+func TestBeginCurrentAttachmentEffectContextRejectsNilContext(t *testing.T) {
167+ _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
168+
169+ token, effect, admitted := ac.beginCurrentAttachmentEffectContext(nil, sess, ac.transport())
170+
171+ require.False(t, admitted)
172+ require.Nil(t, effect)
173+ require.Equal(t, attachmentCapability{}, token)
174+}
175+
166176 func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) {
167177 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
168178 rc := d.attachCoordinator(sess, nil, ac, true)
@@ -173,9 +183,9 @@ func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) {
173183 }
174184
175185 func attachmentEffectGatePublicationSnapshot(ac *attachedClient) (attachmentEffectPhase, attachmentCapability) {
176- ac.attachmentEffects.mu.Lock()
177- defer ac.attachmentEffects.mu.Unlock()
178- return ac.attachmentEffects.phase, ac.attachmentEffects.capability
186+ ac.lifecycle.mu.Lock()
187+ defer ac.lifecycle.mu.Unlock()
188+ return ac.lifecycle.phase, ac.lifecycle.capability
179189 }
180190
181191 func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) {
@@ -197,21 +207,21 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
197207 sess.mu.Unlock()
198208
199209 rc := d.attachCoordinator(sess, nil, first, true)
200- firstToken := sess.attachmentToken(first, firstTransport)
210+ firstToken := sess.captureAttachmentCapability(first, firstTransport)
201211 firstToken.lease = rc.attachmentLease(first)
202- first.publishAttachmentCapability(firstToken)
203- blockedToken := sess.attachmentToken(blocked, blockedTransport)
204- blocked.publishAttachmentCapability(blockedToken)
205- laterToken := sess.attachmentToken(later, laterTransport)
206- later.publishAttachmentCapability(laterToken)
212+ first.installTestAttachmentCapability(firstToken)
213+ blockedToken := sess.captureAttachmentCapability(blocked, blockedTransport)
214+ blocked.installTestAttachmentCapability(blockedToken)
215+ laterToken := sess.captureAttachmentCapability(later, laterTransport)
216+ later.installTestAttachmentCapability(laterToken)
207217
208218 // Fix the canonical order so teardown acquires first, waits behind the gate
209219 // owned by this test, and never reaches the still-stable later participant.
210- first.attachmentEffects.immutableOrder()
211- blocked.attachmentEffects.immutableOrder()
212- later.attachmentEffects.immutableOrder()
213- require.Less(t, first.attachmentEffects.order.Load(), blocked.attachmentEffects.order.Load())
214- require.Less(t, blocked.attachmentEffects.order.Load(), later.attachmentEffects.order.Load())
220+ first.lifecycle.immutableOrder()
221+ blocked.lifecycle.immutableOrder()
222+ later.lifecycle.immutableOrder()
223+ require.Less(t, first.lifecycle.order.Load(), blocked.lifecycle.order.Load())
224+ require.Less(t, blocked.lifecycle.order.Load(), later.lifecycle.order.Load())
215225
216226 blockedOwner := freezeAttachmentEffectGates(blocked)
217227 blockedOwnerHeld := true
@@ -227,9 +237,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
227237 require.Equal(t, attachmentEffectsStable, firstPhase)
228238 require.Equal(t, attachmentEffectsFrozen, blockedPhase)
229239 require.Equal(t, attachmentEffectsStable, laterPhase)
230- firstGeneration := first.connectionGeneration.Load()
231- blockedGeneration := blocked.connectionGeneration.Load()
232- laterGeneration := later.connectionGeneration.Load()
240+ firstGeneration := first.lifecycle.generationValue()
241+ blockedGeneration := blocked.lifecycle.generationValue()
242+ laterGeneration := later.lifecycle.generationValue()
233243 firstSnapshot := first.transportSnapshot()
234244 blockedSnapshot := blocked.transportSnapshot()
235245 laterSnapshot := later.transportSnapshot()
@@ -264,9 +274,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
264274 for _, ac := range []*attachedClient{first, blocked, later} {
265275 require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership")
266276 }
267- require.Equal(t, firstGeneration, first.connectionGeneration.Load())
268- require.Equal(t, blockedGeneration, blocked.connectionGeneration.Load())
269- require.Equal(t, laterGeneration, later.connectionGeneration.Load())
277+ require.Equal(t, firstGeneration, first.lifecycle.generationValue())
278+ require.Equal(t, blockedGeneration, blocked.lifecycle.generationValue())
279+ require.Equal(t, laterGeneration, later.lifecycle.generationValue())
270280 require.True(t, first.transportSnapshotCurrent(firstSnapshot))
271281 require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot))
272282 require.True(t, later.transportSnapshotCurrent(laterSnapshot))
@@ -290,7 +300,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
290300 require.True(t, complete.acquired, "rolled-back gate set could not be acquired again")
291301 require.True(t, complete.drained)
292302 complete.unfreeze()
293- for _, token := range []attachmentConnectionToken{firstToken, blockedToken, laterToken} {
303+ for _, token := range []attachmentCapability{firstToken, blockedToken, laterToken} {
294304 ticket, admitted := token.ac.beginAttachmentEffect(token)
295305 require.True(t, admitted, "pre-publication abort changed an attachment capability")
296306 ticket.End()
@@ -303,7 +313,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
303313 snatchedTransport := newTeardownBlockingTransport()
304314 snatched.replaceTransport(snatchedTransport)
305315 d.attachCoordinator(sess, nil, snatched, true)
306- snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))
316+ snatched.installTestAttachmentCapability(sess.captureAttachmentCapability(snatched, snatchedTransport))
307317
308318 activeTransport := newTeardownBlockingTransport()
309319 active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size}
@@ -324,8 +334,8 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
324334 d.mu.Unlock()
325335 d.attachCoordinator(unrelated, nil, unrelatedClient, true)
326336
327- snatchedToken := sess.attachmentToken(snatched, snatchedTransport)
328- activeToken := sess.attachmentToken(active, activeTransport)
337+ snatchedToken := sess.captureAttachmentCapability(snatched, snatchedTransport)
338+ activeToken := sess.captureAttachmentCapability(active, activeTransport)
329339 snatchedDone := startBlockedAttachmentSend(t, snatchedToken)
330340 activeDone := startBlockedAttachmentSend(t, activeToken)
331341 awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block")
@@ -359,9 +369,9 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test
359369 stale := newTeardownBlockingTransport()
360370 ac.replaceTransport(stale)
361371 d.attachCoordinator(sess, nil, ac, true)
362- ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))
372+ ac.installTestAttachmentCapability(sess.captureAttachmentCapability(ac, stale))
363373
364- token := sess.attachmentToken(ac, stale)
374+ token := sess.captureAttachmentCapability(ac, stale)
365375 sendDone := startBlockedAttachmentSend(t, token)
366376 awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block")
367377 fresh := &closeTrackingTransport{}
@@ -388,7 +398,7 @@ func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T
388398 transport := &closeTrackingTransport{}
389399 active.replaceTransport(transport)
390400 d.attachCoordinator(sess, nil, active, true)
391- active.publishAttachmentCapability(sess.attachmentToken(active, transport))
401+ active.installTestAttachmentCapability(sess.captureAttachmentCapability(active, transport))
392402
393403 owner := freezeAttachmentEffectGates(active)
394404 require.True(t, owner.acquired)
@@ -419,9 +429,9 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
419429 blocked := newTeardownBlockingTransport()
420430 active.replaceTransport(blocked)
421431 d.attachCoordinator(sess, nil, active, true)
422- active.publishAttachmentCapability(sess.attachmentToken(active, blocked))
432+ active.installTestAttachmentCapability(sess.captureAttachmentCapability(active, blocked))
423433
424- token := sess.attachmentToken(active, blocked)
434+ token := sess.captureAttachmentCapability(active, blocked)
425435 sendDone := startBlockedAttachmentSend(t, token)
426436 awaitTestCompletion(t, blocked.sendEntered, "active send did not block")
427437
@@ -445,9 +455,9 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileAttachmentEffectGateIsBus
445455 transport := &closeTrackingTransport{}
446456 ac.replaceTransport(transport)
447457 rc := d.attachCoordinator(sess, nil, ac, true)
448- token := sess.attachmentToken(ac, transport)
458+ token := sess.captureAttachmentCapability(ac, transport)
449459 token.lease = rc.attachmentLease(ac)
450- ac.publishAttachmentCapability(token)
460+ ac.installTestAttachmentCapability(token)
451461
452462 clock := &signalClock{timers: make(chan *signalTimer, 1)}
453463 d.clock = clock
@@ -476,9 +486,9 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat
476486 old.replaceTransport(oldTransport)
477487 rc := d.attachCoordinator(sess, nil, old, true)
478488 lease := rc.attachmentLease(old)
479- token := sess.attachmentToken(old, oldTransport)
489+ token := sess.captureAttachmentCapability(old, oldTransport)
480490 token.lease = lease
481- old.publishAttachmentCapability(token)
491+ old.installTestAttachmentCapability(token)
482492
483493 paintDone := make(chan struct{})
484494 go func() {
@@ -530,9 +540,9 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(
530540 oldTransport := newDatagramTestTransport()
531541 old.replaceTransport(oldTransport)
532542 rc := d.attachCoordinator(sess, nil, old, true)
533- token := sess.attachmentToken(old, oldTransport)
543+ token := sess.captureAttachmentCapability(old, oldTransport)
534544 token.lease = rc.attachmentLease(old)
535- old.publishAttachmentCapability(token)
545+ old.installTestAttachmentCapability(token)
536546
537547 ticket, ok := old.beginAttachmentEffect(token)
538548 require.True(t, ok)
@@ -631,15 +641,16 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
631641 old.replaceTransport(oldTransport)
632642 old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
633643 rc := d.attachCoordinator(sess, nil, old, true)
634- token := sess.attachmentToken(old, oldTransport)
644+ token := sess.captureAttachmentCapability(old, oldTransport)
635645 token.lease = rc.attachmentLease(old)
646+ old.installTestAttachmentCapability(token)
636647 if tt.setup != nil {
637648 tt.setup(d, sess, old, pty)
638649 }
639650
640651 admitted := make(chan struct{})
641652 release := make(chan struct{})
642- d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {
653+ d.afterAttachmentEffectAdmitted = func(attachmentCapability) {
643654 close(admitted)
644655 <-release
645656 }
@@ -672,9 +683,9 @@ func TestAttachedRouteSnapshotIsAcceptedAsAnAttachmentValue(t *testing.T) {
672683 transport := &closeTrackingTransport{}
673684 ac.replaceTransport(transport)
674685 rc := d.attachCoordinator(source, nil, ac, true)
675- token := source.attachmentToken(ac, transport)
686+ token := source.captureAttachmentCapability(ac, transport)
676687 token.lease = rc.attachmentLease(ac)
677- ac.publishAttachmentCapability(token)
688+ ac.installTestAttachmentCapability(token)
678689
679690 activeRef := ports.RouteRef{Key: 8, Generation: 4}
680691 activeTarget := testRouteTarget(source.name, 8)
@@ -711,9 +722,9 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
711722 transport := &closeTrackingTransport{}
712723 ac.replaceTransport(transport)
713724 rc := d.attachCoordinator(source, nil, ac, true)
714- token := source.attachmentToken(ac, transport)
725+ token := source.captureAttachmentCapability(ac, transport)
715726 token.lease = rc.attachmentLease(ac)
716- ac.publishAttachmentCapability(token)
727+ ac.installTestAttachmentCapability(token)
717728 payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
718729 Version: ports.ProtocolVersion, RequestID: 1, Slug: "back-session", Attached: true,
719730 })
@@ -762,9 +773,9 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
762773 d.sessions[target.id] = target
763774
764775 rc := d.attachCoordinator(source, nil, ac, true)
765- token := source.attachmentToken(ac, ac.transport())
776+ token := source.captureAttachmentCapability(ac, ac.transport())
766777 token.lease = rc.attachmentLease(ac)
767- ac.publishAttachmentCapability(token)
778+ ac.installTestAttachmentCapability(token)
768779
769780 d.handleAttachmentClientFrame(token, frameInput([]byte("\x1ba")))
770781
@@ -780,9 +791,9 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
780791
781792 oldTransport := old.transport()
782793 rc := d.attachCoordinator(sess, nil, old, true)
783- token := sess.attachmentToken(old, oldTransport)
794+ token := sess.captureAttachmentCapability(old, oldTransport)
784795 token.lease = rc.attachmentLease(old)
785- old.publishAttachmentCapability(token)
796+ old.installTestAttachmentCapability(token)
786797
787798 admissionEnded := make(chan struct{})
788799 releaseAction := make(chan struct{})
@@ -824,9 +835,9 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
824835 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
825836 d.enterPicker(sess, ac)
826837 rc := d.attachCoordinator(sess, nil, ac, true)
827- token := sess.attachmentToken(ac, ac.transport())
838+ token := sess.captureAttachmentCapability(ac, ac.transport())
828839 token.lease = rc.attachmentLease(ac)
829- ac.publishAttachmentCapability(token)
840+ ac.installTestAttachmentCapability(token)
830841
831842 d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
832843
@@ -841,12 +852,13 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test
841852 oldTransport := newDatagramTestTransport()
842853 old.replaceTransport(oldTransport)
843854 rc := d.attachCoordinator(sess, nil, old, true)
844- token := sess.attachmentToken(old, oldTransport)
855+ token := sess.captureAttachmentCapability(old, oldTransport)
845856 token.lease = rc.attachmentLease(old)
857+ old.installTestAttachmentCapability(token)
846858
847859 admitted := make(chan struct{})
848860 release := make(chan struct{})
849- d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {
861+ d.afterAttachmentEffectAdmitted = func(attachmentCapability) {
850862 close(admitted)
851863 <-release
852864 }
@@ -875,7 +887,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
875887 aTransport := newDatagramTestTransport()
876888 a.replaceTransport(aTransport)
877889 d.attachCoordinator(first, nil, a, true)
878- first.attachmentToken(a, aTransport)
890+ first.captureAttachmentCapability(a, aTransport)
879891
880892 bTransport := newDatagramTestTransport()
881893 b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
@@ -886,7 +898,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
886898 d.sessions[second.id] = second
887899 d.mu.Unlock()
888900 d.attachCoordinator(second, nil, b, true)
889- second.attachmentToken(b, bTransport)
901+ second.captureAttachmentCapability(b, bTransport)
890902
891903 start := make(chan struct{})
892904 results := make(chan error, 2)
@@ -933,9 +945,9 @@ func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) {
933945 transport := newDatagramTestTransport()
934946 ac.replaceTransport(transport)
935947 rc := d.attachCoordinator(sess, nil, ac, true)
936- token := sess.attachmentToken(ac, transport)
948+ token := sess.captureAttachmentCapability(ac, transport)
937949 token.lease = rc.attachmentLease(ac)
938- ac.publishAttachmentCapability(token)
950+ ac.installTestAttachmentCapability(token)
939951
940952 frozen := freezeAttachmentEffectGates(ac)
941953 require.True(t, frozen.acquired)
A internal/usecase/daemon/attachment_lifecycle_test.go +117 -0
@@ -0,0 +1,117 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func (ac *attachedClient) installTestAttachmentCapability(capability attachmentCapability) {
10+ ac.lifecycle.mu.Lock()
11+ defer ac.lifecycle.mu.Unlock()
12+ ac.lifecycle.initLocked()
13+ if ac.lifecycle.phase != attachmentEffectsStable || ac.lifecycle.inFlight != 0 {
14+ panic("daemon: installing an active test attachment capability")
15+ }
16+ ac.lifecycle.capability = capability
17+ ac.lifecycle.failedTransport = transportSnapshot{}
18+}
19+
20+func (s *session) detachIfCurrent(ac *attachedClient) bool {
21+ s.mu.Lock()
22+ defer s.mu.Unlock()
23+ if !attachmentRegisteredLocked(s, ac) || ac.currentAttachmentSession() != s {
24+ return false
25+ }
26+ s.unregisterAttachmentLocked(ac)
27+ ac.setSession(nil)
28+ ac.invalidateRetiredAttachmentCapability()
29+ return true
30+}
31+
32+func TestAttachmentCapabilityRejectsIndependentIdentityChanges(t *testing.T) {
33+ tests := []struct {
34+ name string
35+ change func(*session, *attachedClient, *renderCoordinator)
36+ }{
37+ {
38+ name: "session membership removal",
39+ change: func(sess *session, ac *attachedClient, _ *renderCoordinator) {
40+ sess.mu.Lock()
41+ delete(sess.attachments, ac)
42+ sess.mu.Unlock()
43+ },
44+ },
45+ {
46+ name: "attachment session replacement",
47+ change: func(_ *session, ac *attachedClient, _ *renderCoordinator) {
48+ ac.setSession(&session{})
49+ },
50+ },
51+ {
52+ name: "connection generation increment",
53+ change: func(_ *session, ac *attachedClient, _ *renderCoordinator) {
54+ ac.lifecycle.generation.Add(1)
55+ },
56+ },
57+ {
58+ name: "transport replacement",
59+ change: func(_ *session, ac *attachedClient, _ *renderCoordinator) {
60+ ac.replaceTransport(&closeTrackingTransport{})
61+ },
62+ },
63+ {
64+ name: "transport incarnation increment",
65+ change: func(_ *session, ac *attachedClient, _ *renderCoordinator) {
66+ ac.replaceTransport(ac.transport())
67+ },
68+ },
69+ {
70+ name: "render lease rebind",
71+ change: func(_ *session, ac *attachedClient, rc *renderCoordinator) {
72+ rc.mu.Lock()
73+ rc.rebindAttachmentWithReadinessLocked(ac, true)
74+ rc.mu.Unlock()
75+ },
76+ },
77+ }
78+
79+ for _, tt := range tests {
80+ t.Run(tt.name, func(t *testing.T) {
81+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
82+ transport := &closeTrackingTransport{}
83+ ac.replaceTransport(transport)
84+ rc := d.attachCoordinator(sess, nil, ac, true)
85+ capability := sess.captureAttachmentCapability(ac, transport)
86+ capability.lease = rc.attachmentLease(ac)
87+ ac.installTestAttachmentCapability(capability)
88+
89+ require.True(t, capability.current())
90+ tt.change(sess, ac, rc)
91+ require.False(t, capability.current())
92+ effect, admitted := ac.beginAttachmentEffect(capability)
93+ require.False(t, admitted)
94+ require.Nil(t, effect)
95+ })
96+ }
97+}
98+
99+func TestAttachmentCapabilityInvalidationRejectsNewEffects(t *testing.T) {
100+ d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
101+ transport := &closeTrackingTransport{}
102+ ac.replaceTransport(transport)
103+ rc := d.attachCoordinator(sess, nil, ac, true)
104+ capability := sess.captureAttachmentCapability(ac, transport)
105+ capability.lease = rc.attachmentLease(ac)
106+ ac.installTestAttachmentCapability(capability)
107+
108+ frozen := freezeAttachmentEffectGates(ac)
109+ require.True(t, frozen.acquired)
110+ require.True(t, frozen.drained)
111+ ac.invalidateFrozenAttachmentCapability()
112+ frozen.unfreeze()
113+
114+ require.False(t, capability.current())
115+ _, admitted := ac.beginAttachmentEffect(capability)
116+ require.False(t, admitted)
117+}
M internal/usecase/daemon/attachment_output.go +2 -2
@@ -357,7 +357,7 @@ func (s *attachmentOutput) fenceLocked() (uint64, uint64, uint64) {
357357 }
358358 generation, revision := uint64(0), uint64(0)
359359 if s.attachment != nil {
360- generation = s.attachment.connectionGeneration.Load()
360+ generation = s.attachment.lifecycle.generationValue()
361361 revision = s.attachment.view.revision
362362 }
363363 return generation, s.currentEpochLocked(), revision
@@ -375,7 +375,7 @@ func (s *attachmentOutput) preparedCurrentLocked(generation, epoch, viewRevision
375375
376376 func (p *preparedOutput) currentLocked() bool {
377377 return p != nil && p.stream != nil && p.stream.preparedCurrentLocked(p.generation, p.epoch, p.viewRevision, p.base) &&
378- (p.stream.attachment == nil || p.stream.attachment.connectionGeneration.Load() == p.connectionGeneration)
378+ (p.stream.attachment == nil || p.stream.attachment.lifecycle.generationValue() == p.connectionGeneration)
379379 }
380380
381381 func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision uint64, size domain.Size, full bool) (ports.Frame, error) {
M internal/usecase/daemon/attachment_output_test.go +1 -1
@@ -188,7 +188,7 @@ func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) {
188188 name string
189189 change func(*attachedClient)
190190 }{
191- {name: "connection generation", change: func(ac *attachedClient) { ac.connectionGeneration.Add(1) }},
191+ {name: "connection generation", change: func(ac *attachedClient) { ac.lifecycle.generation.Add(1) }},
192192 {name: "view revision", change: func(ac *attachedClient) {
193193 ac.viewMu.Lock()
194194 ac.view.revision++
M internal/usecase/daemon/attachment_parking_test.go +2 -2
@@ -103,7 +103,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
103103 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
104104 require.NoError(t, err)
105105 oldToken := ac.resumeToken
106- oldGeneration := ac.connectionGeneration.Load()
106+ oldGeneration := ac.lifecycle.generationValue()
107107 d.clientGone(sess, ac, oldTransport, false)
108108
109109 newTransport := &closeTrackingTransport{}
@@ -113,7 +113,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
113113 require.Same(t, sess, resumed)
114114 require.Same(t, ac, same)
115115 require.NotEqual(t, oldToken, ac.resumeToken)
116- require.Greater(t, ac.connectionGeneration.Load(), oldGeneration)
116+ require.Greater(t, ac.lifecycle.generationValue(), oldGeneration)
117117 require.True(t, d.commitResumeClaim(ac))
118118 d.mu.Lock()
119119 _, oldRetained := d.parked[oldToken]
M internal/usecase/daemon/attachment_render_test.go +7 -7
@@ -123,13 +123,13 @@ func TestAttachmentResizeKeepsPeerWindowAndExpandsSharedContent(t *testing.T) {
123123 rc := sess.renderCoordinator()
124124 lease := rc.attachmentLease(second)
125125 require.True(t, rc.markAttachmentReady(lease))
126- token := sess.attachmentToken(second, secondTransport)
126+ token := sess.captureAttachmentCapability(second, secondTransport)
127127 token.lease = lease
128- ticket, admitted := second.beginAttachmentEffect(token)
128+ second.installTestAttachmentCapability(token)
129+ effect, admitted := second.beginAttachmentEffect(token)
129130 require.True(t, admitted)
130- token.effect = ticket
131- require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 120, Rows: 50}))
132- ticket.End()
131+ require.True(t, d.resizeAttachmentForLease(effect, domain.Size{Cols: 120, Rows: 50}))
132+ effect.End()
133133
134134 tb.mu.Lock()
135135 require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size)
@@ -166,7 +166,7 @@ func TestAttachmentFirstPaintDoesNotWaitForBlockedPeer(t *testing.T) {
166166 awaitTestCompletion(t, oldTransport.entered, "slow attachment did not begin its blocked output")
167167
168168 require.True(t, rc.markAttachmentReady(freshLease))
169- token := sess.attachmentToken(fresh, newTransport)
169+ token := sess.captureAttachmentCapability(fresh, newTransport)
170170 token.lease = freshLease
171171 painted := make(chan bool, 1)
172172 go func() { painted <- d.firstPaintForTransition(token) }()
@@ -282,7 +282,7 @@ func TestAttachmentResizeUsesLatestClaimedSessionGeometry(t *testing.T) {
282282 require.True(t, rc.markAttachmentReady(firstLease))
283283 require.True(t, rc.markAttachmentReady(secondLease))
284284 resize := func(ac *attachedClient, tr ports.Transport, size domain.Size) {
285- token := sess.attachmentToken(ac, tr)
285+ token := sess.captureAttachmentCapability(ac, tr)
286286 d.handleAttachmentClientFrame(token, ports.Frame{
287287 Type: ports.MsgResize,
288288 Payload: mustMarshalResize(ports.Resize{Size: size}),
M internal/usecase/daemon/attachment_route_identity_test.go +4 -4
@@ -25,9 +25,9 @@ func TestAttachmentTransitionPublishesCommittedRouteIdentity(t *testing.T) {
2525 transport := &closeTrackingTransport{}
2626 ac.replaceTransport(transport)
2727 rc := d.attachCoordinator(source, nil, ac, true)
28- token := source.attachmentToken(ac, transport)
28+ token := source.captureAttachmentCapability(ac, transport)
2929 token.lease = rc.attachmentLease(ac)
30- ac.publishAttachmentCapability(token)
30+ ac.installTestAttachmentCapability(token)
3131 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
3232
3333 target := &session{
@@ -75,9 +75,9 @@ func TestAttachmentTransitionTearsDownAfterCommittedIdentitySendFailure(t *testi
7575 transport := &committedIdentityErrorTransport{}
7676 ac.replaceTransport(transport)
7777 rc := d.attachCoordinator(source, nil, ac, true)
78- token := source.attachmentToken(ac, transport)
78+ token := source.captureAttachmentCapability(ac, transport)
7979 token.lease = rc.attachmentLease(ac)
80- ac.publishAttachmentCapability(token)
80+ ac.installTestAttachmentCapability(token)
8181 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
8282
8383 target := &session{
M internal/usecase/daemon/attachment_transition.go +12 -36
@@ -24,14 +24,15 @@ type attachmentTransitionRequest struct {
2424 target *session
2525 next *attachedClient
2626 expectedTransport transportSnapshot
27- sourceToken *attachmentConnectionToken
27+ sourceCapability *attachmentCapability
28+ sourceEffect *attachmentEffect
2829 action string
2930 activateTargetTab bool
3031 targetTabIndex int
3132 copySourceEnvironment bool
32- // preserveAttachment commits attachment-local navigation state without
33- // changing session membership. The initiating connection token remains the
34- // exact authority for this mutation.
33+ // preserveAttachment commits Attachment-local navigation state without
34+ // changing Session membership. The initiating capability remains the exact
35+ // authority for this mutation.
3536 preserveAttachment bool
3637 expectedSourceTab *tab
3738 // transferExpectedSourceTab permits an installed visible floating pane only
@@ -44,33 +45,8 @@ type attachmentTransitionRequest struct {
4445 attachmentEffectsFrozen bool
4546 }
4647
47-func transitionSourceTokenMatchesRequest(token attachmentConnectionToken, source *session, req attachmentTransitionRequest) bool {
48- return source != nil && token.sess != nil && token.sess == source && token.ac == req.next &&
49- token.generation == req.next.connectionGeneration.Load() &&
50- token.transport.transport == req.expectedTransport.transport &&
51- token.transport.incarnation == req.expectedTransport.incarnation
52-}
53-
54-// transitionSourceTokenCurrentLocked requires source.core().mu and, when a
55-// lease exists, sourceCoordinator.mu. It is the canonical exact-connection
56-// handoff check for client-originated navigation and lifecycle mutations.
57-func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool {
58- if token.sess != source || token.ac != req.next ||
59- token.generation != req.next.connectionGeneration.Load() ||
60- token.sess == nil || token.ac.currentAttachmentSession() != source ||
61- !attachmentRegisteredLocked(source, req.next) ||
62- !req.next.transportSnapshotCurrent(token.transport) {
63- return false
64- }
65- if token.lease == nil {
66- return true
67- }
68- return sourceCoordinator != nil && token.lease.attachment == req.next &&
69- sourceCoordinator.leaseCurrentLocked(token.lease, true)
70-}
71-
7248 type attachmentTransitionResult struct {
73- published attachmentConnectionToken
49+ published attachmentCapability
7450 cleanups []renderLifecycleCleanup
7551 sourceGeometrySession *session
7652 }
@@ -103,7 +79,7 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
10379 d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target {
10480 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
10581 }
106- if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {
82+ if req.sourceCapability != nil && !req.sourceCapability.matchesConnectionSnapshot(source, req.next, req.expectedTransport) {
10783 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
10884 }
10985 req.preflighted = true
@@ -112,7 +88,7 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
11288
11389 // freezeAttachmentTransition drains the snapshotted connection without any
11490 // architecture lock held. The returned guard remains frozen through publication.
115-func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenAttachmentEffectGates, error) {
91+func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (attachmentTransitionGuard, error) {
11692 if d.afterAttachmentEffectParticipantsSnapshotted != nil {
11793 d.afterAttachmentEffectParticipantsSnapshotted(req.action, participants.clients)
11894 }
@@ -131,7 +107,7 @@ func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, par
131107 }
132108
133109 // publishAttachmentTransition revalidates the frozen source, creates an
134-// optional target, and performs exact membership publication under d.mu.
110+// optional target, and performs exact capability publication under d.mu.
135111 func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
136112 d.mu.Lock()
137113 defer d.mu.Unlock()
@@ -162,8 +138,8 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
162138 if err != nil {
163139 return attachmentTransitionResult{}, err
164140 }
165- if req.sourceToken != nil {
166- d.endActionAttachmentEffect(req.sourceToken.effect, req.action)
141+ if req.sourceEffect != nil {
142+ d.endActionAttachmentEffect(req.sourceEffect, req.action)
167143 }
168144
169145 frozen, err := d.freezeAttachmentTransition(req, participants)
@@ -183,7 +159,7 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
183159 if req.ready && result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {
184160 identityErr := errAttachmentTransition
185161 if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted {
186- identityErr = d.sendCommittedRouteIdentityForAttachment(effect.connectionToken())
162+ identityErr = d.sendCommittedRouteIdentityForAttachment(effect)
187163 effect.End()
188164 }
189165 if identityErr != nil {
M internal/usecase/daemon/attachment_transition_prelocked_test.go +2 -2
@@ -55,7 +55,7 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure
5555 require.Equal(t, false, target.attachmentRegistered(next))
5656 require.Equal(t, true, target.attachmentRegistered(old))
5757 require.Same(t, source, next.currentSession())
58- require.Equal(t, uint64(0), next.connectionGeneration.Load())
59- require.Equal(t, uint64(0), old.connectionGeneration.Load())
58+ require.Equal(t, uint64(0), next.lifecycle.generationValue())
59+ require.Equal(t, uint64(0), old.lifecycle.generationValue())
6060 require.Equal(t, 0, testAttachmentTabIndex(target))
6161 }
M internal/usecase/daemon/attachment_transition_publish.go +12 -16
@@ -10,7 +10,7 @@ import (
1010 // effect gate is frozen and d.mu is held. No target is created before this
1111 // succeeds.
1212 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 {
13+ if req.sourceCapability == nil || req.source == nil || req.source.core() == nil || d.sessions[req.source.core().id] != req.source {
1414 return false
1515 }
1616 d.notices.routingMu.Lock()
@@ -22,13 +22,13 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest
2222 coordinator.mu.Lock()
2323 defer coordinator.mu.Unlock()
2424 }
25- if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {
25+ if !req.sourceCapability.currentInSessionAndLeaseLocked(req.source, req.next, coordinator) {
2626 return false
2727 }
2828 return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)
2929 }
3030
31-func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool {
31+func (d *Daemon) sourceAttachmentCapabilityCurrentFrozen(token attachmentCapability) bool {
3232 if token.sess == nil || token.ac == nil {
3333 return false
3434 }
@@ -46,8 +46,7 @@ func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionTo
4646 coordinator.mu.Lock()
4747 defer coordinator.mu.Unlock()
4848 }
49- req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport}
50- return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
49+ return token.currentInSessionAndLeaseLocked(token.sess, token.ac, coordinator)
5150 }
5251
5352 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
@@ -84,7 +83,6 @@ type attachmentPublication struct {
8483 targetCoordinator *renderCoordinator
8584 sourceCoordinator *renderCoordinator
8685 releaseCoordinators func()
87- nextGeneration uint64
8886 }
8987
9088 func (p *attachmentPublication) unlockCoordinators() {
@@ -124,20 +122,20 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
124122 if sourceCore == nil || targetCore == nil || !req.preflighted || !req.attachmentEffectsFrozen ||
125123 req.next == nil || d.closing || d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target ||
126124 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
127- req.preserveAttachment && req.sourceToken == nil {
125+ req.preserveAttachment && req.sourceCapability == nil {
128126 return nil, errAttachmentTransition
129127 }
130128
131129 sourceCoordinator := sourceCore.coordinator.Load()
132130 targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
133- // transitionSourceTokenCurrentLocked also reads the source lease. Hold the
131+ // Capability validation also reads the source lease. Hold the
134132 // source coordinator lock before calling it, following session -> coordinator
135133 // ordering and preventing a lease rebind from racing validation.
136134 releaseCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator)
137135 if d.afterAttachmentTransitionCoordinatorsLocked != nil {
138136 d.afterAttachmentTransitionCoordinatorsLocked()
139137 }
140- if req.sourceToken != nil && !transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) {
138+ if req.sourceCapability != nil && !req.sourceCapability.currentInSessionAndLeaseLocked(source, req.next, sourceCoordinator) {
141139 releaseCoordinators()
142140 return nil, errAttachmentTransition
143141 }
@@ -155,7 +153,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
155153 }
156154 if !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) ||
157155 req.activateTargetTab && !req.target.validTargetTabLocked(req.targetTabIndex) ||
158- req.sourceToken != nil && !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab) {
156+ req.sourceCapability != nil && !transitionSourceTabCurrentForRequestLocked(source, req.expectedSourceTab, req.transferExpectedSourceTab) {
159157 releaseCoordinators()
160158 return nil, errAttachmentTransition
161159 }
@@ -196,7 +194,6 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
196194 // attachment claim with the replacement transport's current size.
197195 req.target.claimGeometryOwnerLocked(req.next, req.next.geometrySnapshot())
198196 }
199- publication.nextGeneration = req.next.connectionGeneration.Add(1)
200197 req.next.setSession(req.target)
201198 }
202199
@@ -214,14 +211,13 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
214211 lease = publication.targetCoordinator.attachWithReadinessLocked(req.next, req.ready)
215212 }
216213 }
217- result.published = attachmentConnectionToken{
214+ result.published = attachmentCapability{
218215 sess: req.target, ac: req.next,
219- generation: publication.nextGeneration,
220- transport: req.expectedTransport, lease: lease,
216+ transport: req.expectedTransport, lease: lease,
221217 rebase: publication.source != req.target,
222218 }
223219 if req.attachmentEffectsFrozen {
224- req.next.publishFrozenAttachmentCapability(result.published)
220+ result.published = req.next.publishFrozenAttachmentCapability(result.published)
225221 }
226222 return result
227223 }
@@ -229,7 +225,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
229225 func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {
230226 if publication.req.preserveAttachment {
231227 d.applyTargetStateLocked(publication)
232- return attachmentTransitionResult{published: *publication.req.sourceToken}
228+ return attachmentTransitionResult{published: *publication.req.sourceCapability}
233229 }
234230 publishAttachmentOwnershipLocked(publication)
235231 d.applyTargetStateLocked(publication)
M internal/usecase/daemon/attention.go +9 -5
@@ -50,16 +50,20 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
5050 // happened — it also covers "no target exists", which is routine and not an
5151 // error. Only a failure to reach a target that does exist is a genuine error.
5252 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
53- return d.jumpAttentionForAttachment(sess, ac, attachmentConnectionToken{})
53+ return d.jumpAttentionForAttachment(sess, ac, nil)
5454 }
5555
56-func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, token attachmentConnectionToken) error {
56+func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, effect *attachmentEffect) error {
5757 if sess == nil || ac == nil {
5858 return nil
5959 }
6060 if idx, ok := oldestAttentionTab(sess); ok {
6161 if sess.switchAttachmentTab(ac, idx) {
62- d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, token.lease)
62+ var lease *attachmentLease
63+ if effect != nil {
64+ lease = effect.lease
65+ }
66+ d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(ac), false, ac, lease)
6367 d.invalidateRender(sess, ac, true, "attention.go")
6468 }
6569 return nil
@@ -70,10 +74,10 @@ func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, t
7074 return nil
7175 }
7276 pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}
73- if token.ac == nil {
77+ if effect == nil {
7478 return d.switchToTarget(sess, ac, pickerTarget)
7579 }
76- return d.switchActiveTargetForAttachment(token, pickerTarget)
80+ return d.switchActiveTargetForAttachment(effect, pickerTarget)
7781 }
7882
7983 func oldestAttentionTab(sess *session) (int, bool) {
M internal/usecase/daemon/client.go +58 -57
@@ -42,14 +42,11 @@ type attachedClient struct {
4242 terminalCapabilities ports.TerminalCapabilities
4343 navigationCapabilities ports.NavigationCapabilities
4444 startupOverlay ports.StartupOverlay
45- // connectionGeneration is the wire-facing capability generation. attachmentEffects is
46- // the linearization gate for every attachment-bound observable operation. A role
47- // transition freezes and drains this gate before changing either generation
48- // or the session/coordinator registries.
49- connectionGeneration atomic.Uint64
50- attachmentEffects attachmentEffectGate
51- resumeCapable bool
52- resumeToken uint64
45+ // lifecycle is the sole authority for attachment capability publication,
46+ // effect admission, transition freeze/drain, and connection generation.
47+ lifecycle attachmentLifecycle
48+ resumeCapable bool
49+ resumeToken uint64
5350 // resumeClaimToken is non-zero only between a parked resume claim and its
5451 // successful Welcome. It lets a failed pre-claim handshake restore the old
5552 // credential instead of consuming it.
@@ -111,9 +108,9 @@ type attachedClient struct {
111108 // routeSessionPurge distinguishes a brand-new session from a restored
112109 // stopped session when failed-handshake cleanup removes the live instance.
113110 routeSessionPurge bool
114- // beforeAttachmentTokenValidation is a deterministic lifecycle-race seam
111+ // beforeAttachmentCapabilityValidation is a deterministic lifecycle-race Seam
115112 // used only by package tests.
116- beforeAttachmentTokenValidation func()
113+ beforeAttachmentCapabilityValidation func()
117114 }
118115
119116 type cursorOut struct {
@@ -474,7 +471,7 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po
474471 // attachment's current transport incarnation and admits ticket's interruptible
475472 // transport send. The caller must already hold ac.sendMu and, on success, owns
476473 // both the send and its matching ticket.endTransportSend.
477-func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSnapshot, ticket *attachmentEffectTicket) error {
474+func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSnapshot, ticket *attachmentEffect) error {
478475 if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {
479476 return errTransportReplaced
480477 }
@@ -484,7 +481,7 @@ func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSna
484481 return nil
485482 }
486483
487-func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportSnapshot, f ports.Frame, ticket *attachmentEffectTicket) error {
484+func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportSnapshot, f ports.Frame, ticket *attachmentEffect) error {
488485 ac.sendMu.Lock()
489486 defer ac.sendMu.Unlock()
490487 // A attachment-bound send requires a live ticket, and reports every rejection,
@@ -862,37 +859,36 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) {
862859 // firstPaintForTransition is the only active post-transition paint entry
863860 // point. It rejects a stale attachment capability before rebasing output and carries
864861 // the exact coordinator lease through every resize and render effect.
865-func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
866- if !token.attachmentCurrent() {
862+func (d *Daemon) firstPaintForTransition(capability attachmentCapability) bool {
863+ if !capability.current() {
867864 return false
868865 }
869- ticket, admitted := token.ac.beginAttachmentEffect(token)
866+ effect, admitted := capability.ac.beginAttachmentEffect(capability)
870867 if !admitted {
871868 return false
872869 }
873- token.effect = ticket
874- defer token.endAttachmentEffect()
870+ defer effect.End()
875871 if d.afterAttachmentEffectAdmitted != nil {
876- d.afterAttachmentEffectAdmitted(token)
872+ d.afterAttachmentEffectAdmitted(effect.capability())
877873 }
878- if token.rebase {
874+ if effect.rebase {
879875 if d.beforeFirstPaintSendWait != nil {
880- d.beforeFirstPaintSendWait(token)
876+ d.beforeFirstPaintSendWait(effect.capability())
881877 }
882- token.ac.sendMu.Lock()
883- if token.ac.renderStages.handoffRebase != nil {
884- token.ac.renderStages.handoffRebase()
878+ effect.ac.sendMu.Lock()
879+ if effect.ac.renderStages.handoffRebase != nil {
880+ effect.ac.renderStages.handoffRebase()
885881 }
886- token.ac.rebaseOutput()
882+ effect.ac.rebaseOutput()
887883 // Captures belong to the old session even when pane-local IDs happen
888884 // to be reused by the destination.
889- token.ac.captureFrames = nil
890- token.ac.sendMu.Unlock()
885+ effect.ac.captureFrames = nil
886+ effect.ac.sendMu.Unlock()
891887 }
892- if token.sess == nil {
888+ if effect.sess == nil {
893889 return false
894890 }
895- d.firstPaintWithLease(token.sess, token.ac, token.lease, true)
891+ d.firstPaintWithLease(effect.sess, effect.ac, effect.lease, true)
896892 return true
897893 }
898894
@@ -958,25 +954,25 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
958954 d.invalidateRender(sess, ac, true, "client.go")
959955 }
960956
961-func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) {
962- if !token.attachmentEffectCurrent() {
957+func (d *Daemon) applyThemeForAttachment(effect *attachmentEffect, msg ports.Theme) {
958+ if !effect.current() {
963959 return
964960 }
965961 clientTheme := themeFromMessage(msg)
966- token.ac.setClientTheme(clientTheme)
967- sess := token.sess
968- if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
962+ effect.ac.setClientTheme(clientTheme)
963+ sess := effect.sess
964+ if sess == nil || !effect.current() || !d.applyHostTheme(sess, effect.ac, clientTheme, false) {
969965 return
970966 }
971- if !token.attachmentEffectCurrent() {
967+ if !effect.current() {
972968 return
973969 }
974970 rc := sess.renderCoordinator()
975- if token.lease == nil || rc == nil {
976- d.invalidateRender(sess, token.ac, true, "client.go")
971+ if effect.lease == nil || rc == nil {
972+ d.invalidateRender(sess, effect.ac, true, "client.go")
977973 return
978974 }
979- rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
975+ rc.invalidateForLease(effect.ac, effect.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
980976 }
981977
982978 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
@@ -987,26 +983,26 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
987983 // attachment the latest shared tab/PTY geometry claimant. Callers hold the
988984 // attachment effect admission; output state is rebased before any redraw so
989985 // every subsequent frame starts a new epoch.
990-func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool {
986+func (d *Daemon) resizeAttachmentForLease(effect *attachmentEffect, size domain.Size) bool {
991987 geometry := domain.Geometry{Size: size}
992- if token.ac != nil {
988+ if effect != nil && effect.ac != nil {
993989 // Direct size-only callers do not have a fresh pixel pair. Preserve the
994990 // attachment's last complete pair instead of downgrading its claim to
995991 // cell-only geometry.
996- geometry = token.ac.geometrySnapshot()
992+ geometry = effect.ac.geometrySnapshot()
997993 geometry.Size = size
998994 }
999- return d.resizeAttachmentGeometryForLease(token, geometry)
995+ return d.resizeAttachmentGeometryForLease(effect, geometry)
1000996 }
1001997
1002-func (d *Daemon) resizeAttachmentGeometryForLease(token attachmentConnectionToken, geometry domain.Geometry) bool {
998+func (d *Daemon) resizeAttachmentGeometryForLease(effect *attachmentEffect, geometry domain.Geometry) bool {
1003999 geometry = geometry.NormalizePixels()
1004- if token.ac == nil || !geometry.Valid() || !token.attachmentEffectCurrent() {
1000+ if !effect.current() || effect.ac == nil || !geometry.Valid() {
10051001 return false
10061002 }
1007- ac := token.ac
1003+ ac := effect.ac
10081004 ac.sendMu.Lock()
1009- if !token.attachmentEffectCurrent() || !ac.transportSnapshotCurrent(token.transport) {
1005+ if !effect.current() || !ac.transportSnapshotCurrent(effect.transport) {
10101006 ac.sendMu.Unlock()
10111007 return false
10121008 }
@@ -1036,7 +1032,7 @@ func (d *Daemon) resizeAttachmentGeometryForLease(token attachmentConnectionToke
10361032 d.registerPreviewForSelection(ac)
10371033 }
10381034
1039- sess := token.sess
1035+ sess := effect.sess
10401036 if sess == nil {
10411037 return false
10421038 }
@@ -1056,8 +1052,8 @@ func (d *Daemon) resizeAttachmentGeometryForLease(token attachmentConnectionToke
10561052 }
10571053 // Rendering is attachment-scoped. Schedule it through the coordinator so
10581054 // rapid resize frames coalesce under the attachment lease and deadline.
1059- return rc.scheduleResizeForLease(geometry.Size, ac, token.lease, func(uint64) {
1060- go d.paint(sess, ac, true, token.lease)
1055+ return rc.scheduleResizeForLease(geometry.Size, ac, effect.lease, func(uint64) {
1056+ go d.paint(sess, ac, true, effect.lease)
10611057 }) != 0
10621058 }
10631059
@@ -1071,37 +1067,42 @@ func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice po
10711067 return
10721068 }
10731069 tr := ac.transport()
1074- token := sess.attachmentToken(ac, tr)
1070+ token := sess.captureAttachmentCapability(ac, tr)
10751071 if token.lease == nil {
10761072 // Direct test/headless callers retain pointer-based routing. Production
10771073 // client frames always carry the exact lease through the attachment path below.
10781074 d.handleClientNoticeDirect(sess, ac, notice)
10791075 return
10801076 }
1081- d.handleClientNoticeForAttachment(token, notice)
1077+ effect, admitted := ac.beginAttachmentEffect(token)
1078+ if !admitted {
1079+ return
1080+ }
1081+ defer effect.End()
1082+ d.handleClientNoticeForAttachment(effect, notice)
10821083 }
10831084
1084-func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken, notice ports.ClientNotice) {
1085+func (d *Daemon) handleClientNoticeForAttachment(effect *attachmentEffect, notice ports.ClientNotice) {
10851086 d.notices.routingMu.Lock()
1086- if !token.attachmentEffectCurrent() {
1087+ if !effect.current() {
10871088 d.notices.routingMu.Unlock()
10881089 return
10891090 }
10901091 if d.notices.beforeClientNoticeMutation != nil {
10911092 d.notices.beforeClientNoticeMutation()
10921093 }
1093- if !token.attachmentEffectCurrent() {
1094+ if !effect.current() {
10941095 d.notices.routingMu.Unlock()
10951096 return
10961097 }
1097- if token.sess == nil {
1098+ if effect.sess == nil {
10981099 d.notices.routingMu.Unlock()
10991100 return
11001101 }
1101- repaint := d.mutateClientNotice(token.sess, token.ac, notice)
1102+ repaint := d.mutateClientNotice(effect.sess, effect.ac, notice)
11021103 d.notices.routingMu.Unlock()
1103- if repaint && token.attachmentEffectCurrent() {
1104- d.repaintForNotice(token.ac)
1104+ if repaint && effect.current() {
1105+ d.repaintForNotice(effect.ac)
11051106 }
11061107 }
11071108
M internal/usecase/daemon/client_frame_routing.go +67 -74
@@ -14,26 +14,26 @@ const connectionSnapshotAttempts = 8
1414 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a
1515 // handoff lands between those reads. The attachment gate still performs the final
1616 // mutation admission after this function returns.
17-func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (*session, attachmentConnectionToken, bool) {
17+func (d *Daemon) currentAttachmentConnection(ac *attachedClient, tr ports.Transport) (*session, attachmentCapability, bool) {
1818 for range connectionSnapshotAttempts {
1919 if !ac.currentTransportIs(tr) {
2020 break
2121 }
2222 sess := ac.currentAttachmentSession()
2323 if sess == nil {
24- return nil, attachmentConnectionToken{}, false
24+ return nil, attachmentCapability{}, false
2525 }
2626 if d.afterConnectionSessionSnapshot != nil {
2727 d.afterConnectionSessionSnapshot(sess)
2828 }
29- token := attachmentToken(sess, ac, tr)
29+ token := captureAttachmentCapability(sess, ac, tr)
3030 if token.ac == nil || ac.currentAttachmentSession() != sess {
3131 runtime.Gosched()
3232 continue
3333 }
3434 return sess, token, true
3535 }
36- return nil, attachmentConnectionToken{}, false
36+ return nil, attachmentCapability{}, false
3737 }
3838
3939 // runConnLoop is the per-connection input router: it pumps client messages
@@ -74,57 +74,56 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
7474 }
7575
7676 // handleAttachmentClientFrame owns the attached-client protocol.
77-func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f ports.Frame) bool {
78- if !token.attachmentEffectCurrent() {
77+func (d *Daemon) handleAttachmentClientFrame(capability attachmentCapability, f ports.Frame) bool {
78+ if !capability.current() {
7979 return false
8080 }
8181 if d.afterAttachmentFrameDispatch != nil {
82- d.afterAttachmentFrameDispatch(token)
82+ d.afterAttachmentFrameDispatch(capability)
8383 }
84- ticket, admitted := token.ac.beginAttachmentEffect(token)
84+ effect, admitted := capability.ac.beginAttachmentEffect(capability)
8585 if !admitted {
8686 return false
8787 }
88- token.effect = ticket
89- defer token.endAttachmentEffect()
88+ defer effect.End()
9089 if d.afterAttachmentEffectAdmitted != nil {
91- d.afterAttachmentEffectAdmitted(token)
90+ d.afterAttachmentEffectAdmitted(effect.capability())
9291 }
9392 switch f.Type {
9493 case ports.MsgInput:
9594 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
96- d.handleSequencedInputForAttachment(token, in.InputSeq, in.Data)
95+ d.handleSequencedInputForAttachment(effect, in.InputSeq, in.Data)
9796 }
9897 case ports.MsgResize:
99- if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() {
100- d.resizeAttachmentGeometryForLease(token, rz.Geometry())
98+ if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && effect.current() {
99+ d.resizeAttachmentGeometryForLease(effect, rz.Geometry())
101100 }
102101 case ports.MsgTheme:
103102 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
104- d.applyThemeForAttachment(token, th)
103+ d.applyThemeForAttachment(effect, th)
105104 }
106105 case ports.MsgImagePush:
107106 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
108- d.handleSequencedImagePushForAttachment(token, ip.InputSeq, ip)
107+ d.handleSequencedImagePushForAttachment(effect, ip.InputSeq, ip)
109108 }
110109 case ports.MsgClientNotice:
111110 if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
112- d.handleClientNoticeForAttachment(token, notice)
111+ d.handleClientNoticeForAttachment(effect, notice)
113112 } else {
114113 d.log.Warn("malformed client notice", "err", derr)
115114 }
116115 case ports.MsgDetach:
117- if token.attachmentEffectCurrent() {
118- d.clientGoneForAttachment(token, true)
116+ if effect.current() {
117+ d.clientGoneForAttachment(effect, true)
119118 return true
120119 }
121120 case ports.MsgAck:
122121 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
123- d.ackOutput(token, ack.Epoch, ack.State)
122+ d.ackOutput(effect, ack.Epoch, ack.State)
124123 }
125124 case ports.MsgOutputResetRequest:
126125 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {
127- d.resetOutput(token)
126+ d.resetOutput(effect)
128127 }
129128 case ports.MsgSamePeerSwitchRequest:
130129 request, derr := ports.UnmarshalSamePeerSwitchRequest(f.Payload)
@@ -132,27 +131,27 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
132131 d.log.Warn("malformed same-peer switch request", "err", derr)
133132 break
134133 }
135- d.switchSamePeerForAttachment(token, request)
134+ d.switchSamePeerForAttachment(effect, request)
136135 case ports.MsgParkedRouteRequest:
137136 request, derr := ports.UnmarshalParkedRouteRequest(f.Payload)
138137 if derr != nil {
139138 d.log.Warn("malformed parked-route request", "err", derr)
140139 break
141140 }
142- d.handleParkedRouteRequest(token, request)
141+ d.handleParkedRouteRequest(effect, request)
143142 case ports.MsgRecentRouteSnapshot:
144143 snapshot, derr := ports.UnmarshalRecentRouteSnapshot(f.Payload)
145144 if derr != nil {
146145 d.log.Warn("malformed recent route snapshot", "err", derr)
147146 break
148147 }
149- replayIdentity := token.ac.setRouteSnapshot(snapshot)
150- d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-snapshot")
148+ replayIdentity := effect.ac.setRouteSnapshot(snapshot)
149+ d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-snapshot")
151150 if !replayIdentity {
152151 break
153152 }
154- if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil {
155- d.detachOnAttachmentSendError(token, token.transport.transport)
153+ if err := d.sendCommittedRouteIdentityForAttachment(effect); err != nil {
154+ d.detachOnAttachmentSendError(effect.capability(), effect.transport.transport)
156155 }
157156 case ports.MsgRouteAttentionSubscription:
158157 subscription, derr := ports.UnmarshalRouteAttentionSubscription(f.Payload)
@@ -160,8 +159,8 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
160159 d.log.Warn("malformed route attention subscription", "err", derr)
161160 break
162161 }
163- token.ac.setRouteAttentionSubscription(subscription)
164- d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-attention")
162+ effect.ac.setRouteAttentionSubscription(subscription)
163+ d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-attention")
165164 case ports.MsgRouteNavigationFailure:
166165 failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload)
167166 if derr != nil {
@@ -181,29 +180,29 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
181180 case ports.RouteFailureNoSuchRoute:
182181 message = "that recent route no longer exists"
183182 }
184- d.notify(token.sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil)
183+ d.notify(effect.sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil)
185184 case ports.MsgCommand:
186185 request, derr := ports.UnmarshalCommandRequest(f.Payload)
187186 if derr != nil {
188187 return false
189188 }
190- result := d.executeAttachedCommand(token, request)
191- resultToken, ok := d.commandResultToken(token)
189+ result := d.executeAttachedCommand(effect, request)
190+ resultEffect, ok := d.commandResultEffect(effect)
192191 if !ok {
193192 // A remote handoff or explicit detach deliberately retired the old
194193 // attachment; it must not be treated as a stale-result send error.
195194 return true
196195 }
197- if err := resultToken.sendControl(frameCommandResult(result)); err != nil {
198- resultToken.endAttachmentEffect()
199- d.detachOnAttachmentSendError(resultToken, resultToken.transport.transport)
196+ if err := resultEffect.sendControl(frameCommandResult(result)); err != nil {
197+ resultEffect.End()
198+ d.detachOnAttachmentSendError(resultEffect.capability(), resultEffect.transport.transport)
200199 return true
201200 }
202- resultToken.endAttachmentEffect()
201+ resultEffect.End()
203202 case ports.MsgPing:
204- if err := token.sendControl(framePong()); err != nil {
205- token.endAttachmentEffect()
206- d.detachOnAttachmentSendError(token, token.transport.transport)
203+ if err := effect.sendControl(framePong()); err != nil {
204+ effect.End()
205+ d.detachOnAttachmentSendError(effect.capability(), effect.transport.transport)
207206 return true
208207 }
209208 default:
@@ -213,34 +212,28 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
213212 return false
214213 }
215214
216-// commandResultToken renews the attachment effect after a local navigation
217-// transition ends the command's initiating effect. A retired transport/session
218-// means the command deliberately handed off or detached and has no result to
219-// send on the old carriage.
220-func (d *Daemon) commandResultToken(token attachmentConnectionToken) (attachmentConnectionToken, bool) {
221- if token.ac == nil || !token.ac.currentTransportIs(token.transport.transport) {
222- return attachmentConnectionToken{}, false
215+// commandResultEffect renews admission after a local navigation transition
216+// ends the command's initiating effect. A retired Transport or Session means
217+// the command deliberately handed off or detached and has no result carriage.
218+func (d *Daemon) commandResultEffect(effect *attachmentEffect) (*attachmentEffect, bool) {
219+ if effect == nil || effect.ac == nil || !effect.ac.currentTransportIs(effect.transport.transport) {
220+ return nil, false
223221 }
224- sess := token.ac.currentAttachmentSession()
225- if sess == nil {
226- return attachmentConnectionToken{}, false
227- }
228- resultToken := attachmentToken(sess, token.ac, token.transport.transport)
229- if resultToken.ac == nil {
230- return attachmentConnectionToken{}, false
222+ if effect.current() && effect.attachmentCapability.current() {
223+ return effect, true
231224 }
232- if token.effect != nil && !token.effect.ended.Load() && token.current() {
233- return token, true
225+ sess := effect.ac.currentAttachmentSession()
226+ if sess == nil {
227+ return nil, false
234228 }
235- ticket, admitted := token.ac.beginAttachmentEffect(resultToken)
236- if !admitted {
237- return attachmentConnectionToken{}, false
229+ capability := captureAttachmentCapability(sess, effect.ac, effect.transport.transport)
230+ if capability.ac == nil {
231+ return nil, false
238232 }
239- resultToken.effect = ticket
240- return resultToken, true
233+ return effect.ac.beginAttachmentEffect(capability)
241234 }
242235
243-func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request ports.CommandRequest) ports.CommandResult {
236+func (d *Daemon) executeAttachedCommand(effect *attachmentEffect, request ports.CommandRequest) ports.CommandResult {
244237 result := ports.CommandResult{RequestID: request.RequestID}
245238 if request.Version != ports.ProtocolVersion {
246239 result.Code = ports.ErrInvalidCommandArgs
@@ -267,8 +260,8 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
267260 result.Text = "attached commands cannot override their active session target"
268261 return result
269262 }
270- sess := token.sess
271- if sess == nil || !token.attachmentEffectCurrent() {
263+ sess := effect.sess
264+ if sess == nil || !effect.current() {
272265 result.Code = ports.ErrNoSuchTarget
273266 result.Text = "attached session is no longer active"
274267 return result
@@ -292,7 +285,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
292285 // The frame's attachment token is the target capability. No registry/name lookup is
293286 // performed, so the command cannot escape to another remote session.
294287 err := sess.runMutation(func() error {
295- return cmd.Run(paletteExec{d: d, sess: sess, ac: token.ac, effect: token.effect}, request.Args)
288+ return cmd.Run(paletteExec{d: d, sess: sess, ac: effect.ac, effect: effect}, request.Args)
296289 })
297290 if err == nil {
298291 result.OK = true
@@ -311,13 +304,13 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
311304 // resetOutput rebases only the exact attachment and transport admitted
312305 // by the frame router. sendMu serializes the rebase with every output send;
313306 // transport revalidation under that lock rejects a link replaced while waiting.
314-func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
315- ac := token.ac
316- if ac == nil || token.effect == nil || token.effect.ended.Load() {
307+func (d *Daemon) resetOutput(effect *attachmentEffect) bool {
308+ if !effect.current() || effect.ac == nil {
317309 return false
318310 }
311+ ac := effect.ac
319312 ac.sendMu.Lock()
320- if token.effect.ended.Load() || !token.attachmentCurrent() || ac.output == nil {
313+ if !effect.current() || ac.output == nil {
321314 ac.sendMu.Unlock()
322315 return false
323316 }
@@ -325,15 +318,15 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
325318 ac.pipelineCache = composeCacheInput{}
326319 ac.pipelineScratch = composeCacheInput{}
327320 ac.sendMu.Unlock()
328- go d.paint(token.sess, ac, true, token.lease)
321+ go d.paint(effect.sess, ac, true, effect.lease)
329322 return true
330323 }
331324
332-func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64) bool {
333- ac := token.ac
334- if token.effect == nil || token.effect.ended.Load() {
325+func (d *Daemon) ackOutput(effect *attachmentEffect, epoch, state uint64) bool {
326+ if !effect.current() || effect.ac == nil {
335327 return false
336328 }
329+ ac := effect.ac
337330 ac.sendMu.Lock()
338331 if ac.output == nil {
339332 ac.sendMu.Unlock()
@@ -342,8 +335,8 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64)
342335 acknowledged := ac.output.ack(epoch, state)
343336 ac.sendMu.Unlock()
344337 if acknowledged {
345- if rc := token.sess.renderCoordinator(); rc != nil {
346- rc.notifyAckForLease(token.lease)
338+ if rc := effect.sess.renderCoordinator(); rc != nil {
339+ rc.notifyAckForLease(effect.lease)
347340 }
348341 }
349342 return acknowledged
M internal/usecase/daemon/client_transport_errors.go +13 -12
@@ -39,25 +39,26 @@ func (d *Daemon) clientGoneWithNotice(sess *session, ac *attachedClient, failed
3939 d.finishClientGone(sess, ac, failed, explicit, notice)
4040 }
4141
42-func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explicit bool) bool {
43- if token.effect == nil {
42+func (d *Daemon) clientGoneForAttachment(effect *attachmentEffect, explicit bool) bool {
43+ if effect == nil {
4444 return false
4545 }
46- token.effect.bindActionEnd(d, "detach")
47- token.effect.End()
48- sess := token.sess
46+ effect.bindActionEnd(d, "detach")
47+ effect.End()
48+ capability := effect.capability()
49+ sess := capability.sess
4950 if sess == nil {
5051 return false
5152 }
5253 var parkingToken uint64
5354 if !explicit {
54- parkingToken = d.markParkingInFlight(sess, token.ac)
55+ parkingToken = d.markParkingInFlight(sess, capability.ac)
5556 }
56- if !d.detachIfAttachmentCurrent(token) {
57- d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)
57+ if !d.detachIfAttachmentCurrent(capability) {
58+ d.clearParkingInFlightIfAbandoned(sess, capability.ac, parkingToken)
5859 return false
5960 }
60- d.finishClientGone(sess, token.ac, token.transport.transport, explicit, true)
61+ d.finishClientGone(sess, capability.ac, capability.transport.transport, explicit, true)
6162 return true
6263 }
6364
@@ -131,11 +132,11 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
131132 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
132133 }
133134
134-func (d *Daemon) detachOnAttachmentSendError(token attachmentConnectionToken, failed ports.Transport) {
135+func (d *Daemon) detachOnAttachmentSendError(token attachmentCapability, failed ports.Transport) {
135136 d.detachOnAttachmentSendErrorUntil(token, failed, nil)
136137 }
137138
138-func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToken, failed ports.Transport, done func() <-chan struct{}) {
139+func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentCapability, failed ports.Transport, done func() <-chan struct{}) {
139140 // A delayed sender may report after the client has rebound. Only the exact
140141 // transport captured by this attachment is allowed to detach either lifecycle.
141142 if failed == nil || failed != token.transport.transport {
@@ -156,7 +157,7 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
156157 // reserveAttachmentSendErrorCleanup accounts for cleanup before End releases the
157158 // attachment gate. This closes the WaitGroup Add/Wait race with terminal teardown;
158159 // the returned launch function must be invoked immediately after ticket End.
159-func (d *Daemon) reserveAttachmentSendErrorCleanup(token attachmentConnectionToken, failed ports.Transport) func() {
160+func (d *Daemon) reserveAttachmentSendErrorCleanup(token attachmentCapability, failed ports.Transport) func() {
160161 d.attachmentCleanupWg.Add(1)
161162 return func() {
162163 go func() {
M internal/usecase/daemon/clipboard.go +24 -24
@@ -26,27 +26,27 @@ var (
2626 clipPasteCloseMarker = []byte("\x1b[201~")
2727 )
2828
29-func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {
30- if !token.attachmentEffectCurrent() {
29+func (d *Daemon) handleSequencedImagePushForAttachment(effect *attachmentEffect, _ uint64, ip ports.ImagePush) {
30+ if !effect.current() {
3131 return
3232 }
33- d.handleImagePushForAttachment(token, ip)
33+ d.handleImagePushForAttachment(effect, ip)
3434 }
3535
36-func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {
37- if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {
36+func (d *Daemon) handleImagePushForAttachment(effect *attachmentEffect, ip ports.ImagePush) {
37+ if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !effect.current() {
3838 return
3939 }
40- path, err := d.writeClipboardImageForAttachment(token, ip)
40+ path, err := d.writeClipboardImageForAttachment(effect, ip)
4141 if err != nil {
4242 d.log.Error("writing clipboard image failed", "err", err)
4343 return
4444 }
45- if !token.attachmentEffectCurrent() {
45+ if !effect.current() {
4646 _ = os.Remove(path)
4747 return
4848 }
49- d.injectClipboardPathForAttachment(token, path)
49+ d.injectClipboardPathForAttachment(effect, path)
5050 }
5151
5252 // handleImagePush writes an ImagePush's bytes to a temp file and injects the
@@ -84,18 +84,18 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
8484 return path, nil
8585 }
8686
87-func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {
87+func (d *Daemon) writeClipboardImageForAttachment(effect *attachmentEffect, ip ports.ImagePush) (string, error) {
8888 path, err := d.createClipboardImage(ip)
8989 if err != nil {
9090 return "", err
9191 }
92- sess := token.sess
92+ sess := effect.sess
9393 if sess == nil {
9494 _ = os.Remove(path)
9595 return "", errAttachmentTransition
9696 }
9797 sess.mu.Lock()
98- if !token.attachmentEffectCurrentSessionLocked() {
98+ if !effect.currentSessionLocked() {
9999 sess.mu.Unlock()
100100 _ = os.Remove(path)
101101 return "", errAttachmentTransition
@@ -134,18 +134,18 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
134134 d.injectClipboardPathToTarget(sess, path, nil)
135135 }
136136
137-func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {
138- sess := token.sess
139- if sess == nil || !token.attachmentEffectCurrent() {
137+func (d *Daemon) injectClipboardPathForAttachment(effect *attachmentEffect, path string) {
138+ sess := effect.sess
139+ if sess == nil || !effect.current() {
140140 return
141141 }
142- d.injectClipboardPathToTarget(sess, path, &token)
142+ d.injectClipboardPathToTarget(sess, path, effect)
143143 }
144144
145-func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentConnectionToken) {
145+func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, effect *attachmentEffect) {
146146 var ac *attachedClient
147- if token != nil {
148- ac = token.ac
147+ if effect != nil {
148+ ac = effect.ac
149149 }
150150 var tb *tab
151151 if ac != nil {
@@ -175,7 +175,7 @@ func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *
175175 wrapped = append(wrapped, clipPasteCloseMarker...)
176176 data = wrapped
177177 }
178- if token != nil && !token.attachmentEffectCurrent() {
178+ if effect != nil && !effect.current() {
179179 return
180180 }
181181 d.writeToPane(sess, p, data)
@@ -202,7 +202,7 @@ func clipboardExt(mime string) string {
202202
203203 type clipboardForward struct {
204204 owner paneEffectLease
205- token attachmentConnectionToken
205+ token attachmentCapability
206206 seq []byte
207207 }
208208
@@ -237,8 +237,8 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
237237 items := make([]clipboardForward, 0, len(attachments))
238238 for _, ac := range attachments {
239239 transport := ac.transport()
240- token := sess.attachmentToken(ac, transport)
241- if token.attachmentCurrent() {
240+ token := sess.captureAttachmentCapability(ac, transport)
241+ if token.current() {
242242 items = append(items, clipboardForward{owner: owner, token: token, seq: seq})
243243 }
244244 }
@@ -254,7 +254,7 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
254254 sess.mu.Unlock()
255255 }
256256
257-func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *attachmentEffectTicket) (ports.Transport, error) {
257+func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *attachmentEffect) (ports.Transport, error) {
258258 token := item.token
259259 if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil {
260260 return token.transport.transport, errAttachmentTransition
@@ -300,7 +300,7 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta
300300 // admission and marks the transport interval while ownership publication is
301301 // excluded. Publication therefore orders either before this check (the send is
302302 // dropped) or after transport admission (the send belongs to the old owner).
303-func beginClipboardOwnerSend(lease paneEffectLease, ticket *attachmentEffectTicket, expected transportSnapshot) bool {
303+func beginClipboardOwnerSend(lease paneEffectLease, ticket *attachmentEffect, expected transportSnapshot) bool {
304304 if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil {
305305 return false
306306 }
M internal/usecase/daemon/clipboard_test.go +3 -3
@@ -204,9 +204,9 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
204204
205205 func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) {
206206 rc := d.attachCoordinator(sess, nil, ac, true)
207- token := sess.attachmentToken(ac, tr)
207+ token := sess.captureAttachmentCapability(ac, tr)
208208 token.lease = rc.attachmentLease(ac)
209- ac.publishAttachmentCapability(token)
209+ ac.installTestAttachmentCapability(token)
210210 }
211211
212212 func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {
@@ -419,7 +419,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
419419 close(workerDone)
420420 }()
421421 require.Eventually(t, func() bool {
422- return ac.attachmentEffects.inFlightCount() == 1
422+ return ac.lifecycle.inFlightCount() == 1
423423 }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")
424424
425425 sourceTab := testAttachmentTab(sess)
M internal/usecase/daemon/control.go +17 -17
@@ -300,7 +300,7 @@ type daemonActionTarget struct {
300300 type daemonActionRequest struct {
301301 kind daemonActionKind
302302 target daemonActionTarget
303- effect *attachmentEffectTicket
303+ effect *attachmentEffect
304304 direction layout.Direction
305305 axis layout.Axis
306306 delta int
@@ -513,21 +513,21 @@ func (e controlExec) MovePane(destinationSession, destinationTabID string) error
513513 return errMovePaneInvalid
514514 }
515515 attachment := e.target.attachment
516- token := attachmentConnectionToken{}
516+ token := attachmentCapability{}
517517 if attachment != nil {
518- token = e.sess.attachmentToken(attachment, attachment.transport())
518+ token = e.sess.captureAttachmentCapability(attachment, attachment.transport())
519519 if token.ac == nil {
520520 return errMoveStaleTarget
521521 }
522522 }
523523 return e.d.movePane(movePaneRequest{
524- Attachment: attachment,
525- AttachmentToken: token,
526- Source: sessionMoveLocator(e.sess),
527- SourceTabID: domain.TabStableID(e.tab.stableID),
528- SourcePaneID: domain.PaneStableID(e.target.pane.stableID),
529- Destination: sessionMoveLocator(destination),
530- DestinationTabID: domain.TabStableID(destinationTabID),
524+ Attachment: attachment,
525+ AttachmentCapability: token,
526+ Source: sessionMoveLocator(e.sess),
527+ SourceTabID: domain.TabStableID(e.tab.stableID),
528+ SourcePaneID: domain.PaneStableID(e.target.pane.stableID),
529+ Destination: sessionMoveLocator(destination),
530+ DestinationTabID: domain.TabStableID(destinationTabID),
531531 })
532532 }
533533
@@ -540,19 +540,19 @@ func (e controlExec) MoveTab(destinationSession string) error {
540540 return errMovePaneInvalid
541541 }
542542 attachment := e.target.attachment
543- token := attachmentConnectionToken{}
543+ token := attachmentCapability{}
544544 if attachment != nil {
545- token = e.sess.attachmentToken(attachment, attachment.transport())
545+ token = e.sess.captureAttachmentCapability(attachment, attachment.transport())
546546 if token.ac == nil {
547547 return errMoveStaleTarget
548548 }
549549 }
550550 return e.d.moveTab(moveTabRequest{
551- Attachment: attachment,
552- AttachmentToken: token,
553- Source: sessionMoveLocator(e.sess),
554- SourceTabID: domain.TabStableID(e.tab.stableID),
555- Destination: sessionMoveLocator(destination),
551+ Attachment: attachment,
552+ AttachmentCapability: token,
553+ Source: sessionMoveLocator(e.sess),
554+ SourceTabID: domain.TabStableID(e.tab.stableID),
555+ Destination: sessionMoveLocator(destination),
556556 })
557557 }
558558
M internal/usecase/daemon/daemon.go +6 -6
@@ -161,7 +161,7 @@ type Daemon struct {
161161 afterConnectionSessionSnapshot func(*session)
162162 // afterAttachmentFrameDispatch is a deterministic test seam after the
163163 // connection loop snapshots a token and before a decoded frame takes effect.
164- afterAttachmentFrameDispatch func(attachmentConnectionToken)
164+ afterAttachmentFrameDispatch func(attachmentCapability)
165165 // afterAttachmentEffectParticipantsSnapshotted observes immutable role-gate
166166 // participants after architecture preflight locks are released and before
167167 // the globally ordered freeze begins.
@@ -214,10 +214,10 @@ type Daemon struct {
214214 beforeResumeParkedSendMu func()
215215 // afterAttachmentEffectAdmitted is a deterministic test seam after a frame/paint
216216 // reserves its exact capability and before its first observable mutation.
217- afterAttachmentEffectAdmitted func(attachmentConnectionToken)
217+ afterAttachmentEffectAdmitted func(attachmentCapability)
218218 // beforeFirstPaintSendWait is a test-only seam immediately before a
219219 // transition first paint waits for the attachment send lock.
220- beforeFirstPaintSendWait func(attachmentConnectionToken)
220+ beforeFirstPaintSendWait func(attachmentCapability)
221221 // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a
222222 // fresh exact capability before producing PTY, action, or overlay effects.
223223 afterDelayedKeyEffectAttempt func(bool)
@@ -226,7 +226,7 @@ type Daemon struct {
226226 afterActionAttachmentEffectEnded func(string)
227227 // beforeAttachmentSendErrorCleanup pauses asynchronous render-failure retirement
228228 // after the render ticket ends and before exact lifecycle validation.
229- beforeAttachmentSendErrorCleanup func(attachmentConnectionToken)
229+ beforeAttachmentSendErrorCleanup func(attachmentCapability)
230230 afterAttachmentSendErrorCleanup func()
231231 ptys ports.PTYFactory
232232 clock ports.Clock
@@ -1217,7 +1217,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
12171217 return
12181218 }
12191219 expected := ac.transportSnapshot()
1220- welcomeToken := sess.attachmentToken(ac, tr)
1220+ welcomeToken := sess.captureAttachmentCapability(ac, tr)
12211221 welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
12221222 if expected.transport != tr || !admitted || welcomeToken.ac == nil {
12231223 if admitted {
@@ -1267,7 +1267,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
12671267 failAttachment()
12681268 return
12691269 }
1270- paintToken := sess.attachmentToken(ac, tr)
1270+ paintToken := sess.captureAttachmentCapability(ac, tr)
12711271 painted := make(chan bool, 1)
12721272 paintDone, paintErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
12731273 //nolint:contextcheck // firstPaintForTransition is capability-bounded; the surrounding handshake operation owns cancellation.
M internal/usecase/daemon/final_acceptance_test.go +18 -12
@@ -42,19 +42,22 @@ func TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(t *test
4242 d.ptys = newFactory(t, newQuietPTY())
4343 rc := d.attachCoordinator(sess, nil, first, true)
4444 d.attachCoordinator(sess, nil, second, true)
45- firstToken := sess.attachmentToken(first, first.transport())
45+ firstToken := sess.captureAttachmentCapability(first, first.transport())
4646 firstToken.lease = rc.attachmentLease(first)
47- first.publishAttachmentCapability(firstToken)
48- secondToken := sess.attachmentToken(second, second.transport())
47+ first.installTestAttachmentCapability(firstToken)
48+ secondToken := sess.captureAttachmentCapability(second, second.transport())
4949 secondToken.lease = rc.attachmentLease(second)
50- second.publishAttachmentCapability(secondToken)
50+ second.installTestAttachmentCapability(secondToken)
5151
5252 firstView := first.viewSnapshot()
5353 secondView := second.viewSnapshot()
5454 first.sendMu.Lock()
5555 firstEpoch := first.output.currentEpoch()
5656 first.sendMu.Unlock()
57- require.True(t, d.resizeAttachmentForLease(secondToken, domain.Size{Cols: 120, Rows: 40}))
57+ secondEffect, admitted := second.beginAttachmentEffect(secondToken)
58+ require.True(t, admitted)
59+ require.True(t, d.resizeAttachmentForLease(secondEffect, domain.Size{Cols: 120, Rows: 40}))
60+ secondEffect.End()
5861
5962 d.enterPalette(sess, second)
6063 d.handlePaletteInput(second, []byte("CNT\r"))
@@ -136,12 +139,12 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing
136139
137140 rc := d.attachCoordinator(sess, nil, first, true)
138141 d.attachCoordinator(sess, nil, second, true)
139- firstToken := sess.attachmentToken(first, first.transport())
142+ firstToken := sess.captureAttachmentCapability(first, first.transport())
140143 firstToken.lease = rc.attachmentLease(first)
141- first.publishAttachmentCapability(firstToken)
142- secondToken := sess.attachmentToken(second, second.transport())
144+ first.installTestAttachmentCapability(firstToken)
145+ secondToken := sess.captureAttachmentCapability(second, second.transport())
143146 secondToken.lease = rc.attachmentLease(second)
144- second.publishAttachmentCapability(secondToken)
147+ second.installTestAttachmentCapability(secondToken)
145148 firstEpoch := first.output.currentEpoch()
146149 secondEpoch := second.output.currentEpoch()
147150
@@ -152,7 +155,10 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing
152155 require.Greater(t, second.output.currentEpoch(), secondEpoch)
153156 require.Equal(t, firstEpoch, first.output.currentEpoch(), "peer output reset crossed attachment boundary")
154157
155- require.True(t, d.resizeAttachmentForLease(secondToken, domain.Size{Cols: 120, Rows: 40}))
158+ secondEffect, admitted := second.beginAttachmentEffect(secondToken)
159+ require.True(t, admitted)
160+ require.True(t, d.resizeAttachmentForLease(secondEffect, domain.Size{Cols: 120, Rows: 40}))
161+ secondEffect.End()
156162 require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size)
157163 require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary")
158164 sess.mu.Lock()
@@ -183,9 +189,9 @@ func TestAcceptanceDirectAttachmentSupportsVerticalSplit(t *testing.T) {
183189 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
184190 d.ptys = newFactory(t, newQuietPTY())
185191 rc := d.attachCoordinator(sess, nil, ac, true)
186- token := sess.attachmentToken(ac, ac.transport())
192+ token := sess.captureAttachmentCapability(ac, ac.transport())
187193 token.lease = rc.attachmentLease(ac)
188- ac.publishAttachmentCapability(token)
194+ ac.installTestAttachmentCapability(token)
189195
190196 d.enterPalette(sess, ac)
191197 d.handlePaletteInput(ac, []byte("SPD\r"))
M internal/usecase/daemon/handshake_test.go +1 -1
@@ -117,7 +117,7 @@ func TestHandshakeFirstPaintCancellationDoesNotRacePaintResult(t *testing.T) {
117117 paintStarted := make(chan struct{})
118118 releasePaint := make(chan struct{})
119119 paintDone := make(chan struct{})
120- d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {
120+ d.afterAttachmentEffectAdmitted = func(attachmentCapability) {
121121 close(paintStarted)
122122 <-ctx.Done()
123123 <-releasePaint
M internal/usecase/daemon/input.go +24 -29
@@ -20,11 +20,11 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
2020 d.handleInput(sess, ac, data)
2121 }
2222
23-func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {
24- if !token.attachmentEffectCurrent() {
23+func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, _ uint64, data []byte) {
24+ if !effect.current() {
2525 return
2626 }
27- d.handleInputForAttachment(token, data)
27+ d.handleInputForAttachment(effect, data)
2828 }
2929
3030 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
@@ -40,26 +40,26 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
4040 )
4141 }
4242
43-func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data []byte) {
44- ac := token.ac
43+func (d *Daemon) handleInputForAttachment(effect *attachmentEffect, data []byte) {
44+ ac := effect.ac
4545 ac.initOverlays()
4646 ac.mouseScan.Scan(data,
4747 func(ev mouse.Event) {
48- if token.attachmentEffectCurrent() {
48+ if effect.current() {
4949 d.handleMouse(ac, ev)
5050 }
5151 },
5252 func(b []byte) {
53- if !token.attachmentEffectCurrent() {
53+ if !effect.current() {
5454 return
5555 }
56- if ac.overlays.HandleInput(d, b, token.effect) {
56+ if ac.overlays.HandleInput(d, b, effect) {
5757 return
5858 }
59- if !token.attachmentEffectCurrent() {
59+ if !effect.current() {
6060 return
6161 }
62- ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, connectionToken: token})
62+ ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, effect: effect})
6363 },
6464 )
6565 }
@@ -371,21 +371,21 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
371371 }
372372
373373 type daemonKeyHandler struct {
374- d *Daemon
375- ac *attachedClient
376- actions daemonActionRunner
377- connectionToken attachmentConnectionToken
374+ d *Daemon
375+ ac *attachedClient
376+ actions daemonActionRunner
377+ effect *attachmentEffect
378378 }
379379
380380 // acquireAttachmentEffect preserves a synchronous frame's existing admission and
381381 // gives delayed router callbacks a fresh ticket for the exact captured attachment.
382382 // Direct/headless callers without an attachment token retain their existing behavior.
383-func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {
384- if h.connectionToken.ac != nil {
385- if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {
386- return h.connectionToken.sess, effect, false
383+func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect, bool) {
384+ if h.effect != nil && h.effect.ac != nil {
385+ if h.effect.current() {
386+ return h.effect.sess, h.effect, false
387387 }
388- effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)
388+ effect, admitted := h.effect.ac.beginAttachmentEffect(h.effect.capability())
389389 if h.d.afterDelayedKeyEffectAttempt != nil {
390390 h.d.afterDelayedKeyEffectAttempt(admitted)
391391 }
@@ -393,16 +393,13 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
393393 return nil, nil, false
394394 }
395395 if h.d.afterAttachmentEffectAdmitted != nil {
396- token := h.connectionToken
397- token.effect = effect
398- h.d.afterAttachmentEffectAdmitted(token)
396+ h.d.afterAttachmentEffectAdmitted(effect.capability())
399397 }
400- // Keep this defensive check even though token admission normally requires a session.
401- if h.connectionToken.sess == nil {
398+ if effect.sess == nil {
402399 effect.End()
403400 return nil, nil, false
404401 }
405- return h.connectionToken.sess, effect, true
402+ return effect.sess, effect, true
406403 }
407404 sess := h.ac.currentSession()
408405 if sess == nil {
@@ -475,9 +472,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
475472 return
476473 }
477474 effect.bindActionEnd(h.d, "jump-attention")
478- token := h.connectionToken
479- token.effect = effect
480- if err := h.d.jumpAttentionForAttachment(sess, h.ac, token); err != nil {
475+ if err := h.d.jumpAttentionForAttachment(sess, h.ac, effect); err != nil {
481476 h.d.reportError(sess, err)
482477 }
483478 case keys.ActionToggleFloatingPane:
@@ -521,7 +516,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
521516 }
522517 }
523518
524-func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) {
519+func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffect) {
525520 err := h.d.focusDir(sess, h.ac, dir, effect)
526521 if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) {
527522 h.d.reportError(sess, err)
M internal/usecase/daemon/input_test.go +3 -4
@@ -783,13 +783,12 @@ func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) {
783783 defer releaseAll(releases)
784784 transport := ac.transport()
785785 rc := d.attachCoordinator(current, nil, ac, true)
786- token := current.attachmentToken(ac, transport)
786+ token := current.captureAttachmentCapability(ac, transport)
787787 token.lease = rc.attachmentLease(ac)
788- ac.publishAttachmentCapability(token)
788+ ac.installTestAttachmentCapability(token)
789789 effect, admitted := ac.beginAttachmentEffect(token)
790790 require.True(t, admitted)
791791 defer effect.End()
792- token.effect = effect
793792 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
794793 Generation: 3,
795794 Active: ports.RouteRef{Key: 3, Generation: 3},
@@ -797,7 +796,7 @@ func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) {
797796 Entries: []ports.RecentRouteEntry{testRouteEntry(2, 2, "recent", 2, ports.RouteKindLocal)},
798797 })
799798
800- require.NoError(t, d.backSessionForAttachment(token))
799+ require.NoError(t, d.backSessionForAttachment(effect))
801800 frame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
802801 action, err := ports.UnmarshalRouteNavigationAction(frame.Payload)
803802 require.NoError(t, err)
M internal/usecase/daemon/lifecycle_fence_test.go +6 -6
@@ -21,7 +21,7 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation
2121 sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{})
2222 source.installRenderCoordinator(sourceCoordinator)
2323 sourceCoordinator.attach(ac)
24- token := source.attachmentToken(ac, transport)
24+ token := source.captureAttachmentCapability(ac, transport)
2525 require.NotNil(t, token.lease)
2626 d.sessions[source.id] = source
2727 d.sessions[target.id] = target
@@ -37,7 +37,7 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation
3737 }
3838 req := attachmentTransitionRequest{
3939 source: source, target: target, next: ac,
40- expectedTransport: ac.transportSnapshot(), sourceToken: &token,
40+ expectedTransport: ac.transportSnapshot(), sourceCapability: &token,
4141 preflighted: true, attachmentEffectsFrozen: true,
4242 }
4343 d.mu.Lock()
@@ -56,7 +56,7 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation
5656 require.NotNil(t, publication)
5757 }
5858
59-func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T) {
59+func TestAttachmentCapabilityRevalidatesTheCapturedTransportIncarnation(t *testing.T) {
6060 first := &closeTrackingTransport{}
6161 second := &closeTrackingTransport{}
6262 ac := &attachedClient{tr: first}
@@ -69,12 +69,12 @@ func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T)
6969
7070 captured := make(chan struct{})
7171 release := make(chan struct{})
72- ac.beforeAttachmentTokenValidation = func() {
72+ ac.beforeAttachmentCapabilityValidation = func() {
7373 close(captured)
7474 <-release
7575 }
76- result := make(chan attachmentConnectionToken, 1)
77- go func() { result <- sess.attachmentToken(ac, first) }()
76+ result := make(chan attachmentCapability, 1)
77+ go func() { result <- sess.captureAttachmentCapability(ac, first) }()
7878 <-captured
7979 ac.replaceTransport(second)
8080 close(release)
M internal/usecase/daemon/move.go +13 -13
@@ -10,21 +10,21 @@ import (
1010 // immutable identity. Session names are advisory; the ID and incarnation are
1111 // the commit-time authority.
1212 type movePaneRequest struct {
13- Attachment *attachedClient
14- AttachmentToken attachmentConnectionToken
15- Source moveSessionLocator
16- SourceTabID domain.TabStableID
17- SourcePaneID domain.PaneStableID
18- Destination moveSessionLocator
19- DestinationTabID domain.TabStableID
13+ Attachment *attachedClient
14+ AttachmentCapability attachmentCapability
15+ Source moveSessionLocator
16+ SourceTabID domain.TabStableID
17+ SourcePaneID domain.PaneStableID
18+ Destination moveSessionLocator
19+ DestinationTabID domain.TabStableID
2020 }
2121
2222 type moveTabRequest struct {
23- Attachment *attachedClient
24- AttachmentToken attachmentConnectionToken
25- Source moveSessionLocator
26- SourceTabID domain.TabStableID
27- Destination moveSessionLocator
23+ Attachment *attachedClient
24+ AttachmentCapability attachmentCapability
25+ Source moveSessionLocator
26+ SourceTabID domain.TabStableID
27+ Destination moveSessionLocator
2828 }
2929
3030 // movePane resolves, reserves, validates, and commits one pane relocation. All
@@ -115,7 +115,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
115115 // A final source tab is retired without transferring any attachment to the
116116 // destination. Freeze every affected source connection before publication so
117117 // teardown invalidates exact generations rather than racing input or render.
118- var frozen frozenAttachmentEffectGates
118+ var frozen attachmentTransitionGuard
119119 var sourceEffectsFrozen bool
120120 if finalSourceTab && source != destination && len(admission.sourceAttachments) != 0 {
121121 interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))
M internal/usecase/daemon/move_attachments.go +0 -7
@@ -1,11 +1,5 @@
11 package daemon
22
3-func moveAttachmentTokenCurrentLocked(token attachmentConnectionToken, sess *session) bool {
4- return token.sess == sess && token.ac != nil && token.generation == token.ac.connectionGeneration.Load() &&
5- token.ac.currentAttachmentSession() == sess && token.ac.transportSnapshotCurrent(token.transport) &&
6- attachmentRegisteredLocked(sess, token.ac)
7-}
8-
93 // sameMoveAttachmentsLocked reports whether the source membership is unchanged
104 // since admission. Caller holds the source session lock.
115 func sameMoveAttachmentsLocked(sess *session, admitted []*attachedClient) bool {
@@ -33,7 +27,6 @@ func detachMoveAttachmentsLocked(sess *session, transports map[*attachedClient]t
3327 retired := make([]detachedAttachmentSnapshot, 0, len(attachments))
3428 for _, ac := range attachments {
3529 retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: transports[ac]})
36- ac.connectionGeneration.Add(1)
3730 ac.setSession(nil)
3831 ac.invalidateFrozenAttachmentCapability()
3932 }
M internal/usecase/daemon/move_cleanup_test.go +1 -1
@@ -103,7 +103,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
103103 require.False(t, awaitTestValue(t, resizeDone, "resize commit did not drop stale owner"))
104104 }
105105
106-func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {
106+func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) {
107107 movedPTY, releaseMoved := newBlockingPTY(t)
108108 remainingPTY, releaseRemaining := newBlockingPTY(t)
109109 destinationPTY, releaseDestination := newBlockingPTY(t)
M internal/usecase/daemon/move_gate_race_test.go +2 -2
@@ -194,11 +194,11 @@ func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
194194 sess.mu.Unlock()
195195 d.attachCoordinator(sess, nil, other, true)
196196
197- currentToken := sess.attachmentToken(current, current.transport())
197+ currentToken := sess.captureAttachmentCapability(current, current.transport())
198198 currentEffect, admitted := current.beginAttachmentEffect(currentToken)
199199 require.True(t, admitted)
200200 t.Cleanup(currentEffect.End)
201- otherToken := sess.attachmentToken(other, otherTransport)
201+ otherToken := sess.captureAttachmentCapability(other, otherTransport)
202202 otherEffect, admitted := other.beginAttachmentEffect(otherToken)
203203 require.True(t, admitted)
204204 t.Cleanup(otherEffect.End)
M internal/usecase/daemon/move_pane_admission.go +1 -1
@@ -32,7 +32,7 @@ func (d *Daemon) snapshotMovePaneAdmission(req movePaneRequest, source, destinat
3232 if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) {
3333 return nil, errMoveStaleTarget
3434 }
35- if req.AttachmentToken.ac != nil && (req.AttachmentToken.ac != req.Attachment || !moveAttachmentTokenCurrentLocked(req.AttachmentToken, source)) {
35+ if req.AttachmentCapability.ac != nil && (req.AttachmentCapability.ac != req.Attachment || !req.AttachmentCapability.currentInSessionLocked(source)) {
3636 return nil, errMoveStaleTarget
3737 }
3838
M internal/usecase/daemon/move_pane_commit.go +2 -2
@@ -17,7 +17,7 @@ type movePaneCommit struct {
1717 sourceTransports map[*attachedClient]transportSnapshot
1818 sourceGeneration uint64
1919 destinationGeneration uint64
20- frozenEffects frozenAttachmentEffectGates
20+ frozenEffects attachmentTransitionGuard
2121 err error
2222
2323 syncCleanup syncTimerCleanup
@@ -51,7 +51,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
5151 if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) {
5252 return false
5353 }
54- if c.req.AttachmentToken.ac != nil && (c.req.AttachmentToken.ac != c.req.Attachment || !moveAttachmentTokenCurrentLocked(c.req.AttachmentToken, c.source)) {
54+ if c.req.AttachmentCapability.ac != nil && (c.req.AttachmentCapability.ac != c.req.Attachment || !c.req.AttachmentCapability.currentInSessionLocked(c.source)) {
5555 return false
5656 }
5757
M internal/usecase/daemon/move_pane_postcommit.go +1 -1
@@ -29,7 +29,7 @@ type movePanePostcommitPlan struct {
2929 operation string
3030 sourceAttachments []*attachedClient
3131 syncCleanup syncTimerCleanup
32- frozenEffects frozenAttachmentEffectGates
32+ frozenEffects attachmentTransitionGuard
3333 effectsFrozen bool
3434 unlockDispatch func()
3535 reservation *moveLifecycleReservation
M internal/usecase/daemon/move_picker.go +20 -20
@@ -16,7 +16,7 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
1616 }
1717 sess.mu.Lock()
1818 defer sess.mu.Unlock()
19- if source.AttachmentToken.ac != nil && !moveAttachmentTokenCurrentLocked(source.AttachmentToken, sess) {
19+ if source.AttachmentCapability.ac != nil && !source.AttachmentCapability.currentInSessionLocked(sess) {
2020 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid)
2121 }
2222 if source.Attachment != nil && !attachmentRegisteredLocked(sess, source.Attachment) {
@@ -52,11 +52,11 @@ type moveSessionLocator struct {
5252 }
5353
5454 type moveSourceLocator struct {
55- Session moveSessionLocator
56- TabID domain.TabStableID
57- PaneID domain.PaneStableID
58- Attachment *attachedClient
59- AttachmentToken attachmentConnectionToken
55+ Session moveSessionLocator
56+ TabID domain.TabStableID
57+ PaneID domain.PaneStableID
58+ Attachment *attachedClient
59+ AttachmentCapability attachmentCapability
6060 }
6161
6262 // enterPickerForIntent returns errNoMoveDestination only for move intents.
@@ -64,8 +64,8 @@ func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent
6464 if intent != pickerNavigate && !sess.capabilities().yieldsMoves() {
6565 return errSessionCannotYieldMoves
6666 }
67- if source.Attachment != nil && source.AttachmentToken.ac == nil {
68- source.AttachmentToken = sess.attachmentToken(source.Attachment, source.Attachment.transport())
67+ if source.Attachment != nil && source.AttachmentCapability.ac == nil {
68+ source.AttachmentCapability = sess.captureAttachmentCapability(source.Attachment, source.Attachment.transport())
6969 }
7070 model := d.newPickerModel(sess, ac, intent, source, picker.SourceFilter{})
7171 if intent != pickerNavigate {
@@ -97,21 +97,21 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
9797 return errMovePaneInvalid
9898 }
9999 return d.movePane(movePaneRequest{
100- Attachment: source.Attachment,
101- AttachmentToken: source.AttachmentToken,
102- Source: source.Session,
103- SourceTabID: source.TabID,
104- SourcePaneID: source.PaneID,
105- Destination: destination,
106- DestinationTabID: target.TabID,
100+ Attachment: source.Attachment,
101+ AttachmentCapability: source.AttachmentCapability,
102+ Source: source.Session,
103+ SourceTabID: source.TabID,
104+ SourcePaneID: source.PaneID,
105+ Destination: destination,
106+ DestinationTabID: target.TabID,
107107 })
108108 case pickerMoveTab:
109109 return d.moveTab(moveTabRequest{
110- Attachment: source.Attachment,
111- AttachmentToken: source.AttachmentToken,
112- Source: source.Session,
113- SourceTabID: source.TabID,
114- Destination: destination,
110+ Attachment: source.Attachment,
111+ AttachmentCapability: source.AttachmentCapability,
112+ Source: source.Session,
113+ SourceTabID: source.TabID,
114+ Destination: destination,
115115 })
116116 default:
117117 return errMovePaneInvalid
M internal/usecase/daemon/move_tab.go +4 -4
@@ -28,7 +28,7 @@ type moveTabCommit struct {
2828 destination *session
2929 admission moveTabAdmission
3030 sourceEffectsFrozen bool
31- frozenEffects frozenAttachmentEffectGates
31+ frozenEffects attachmentTransitionGuard
3232 syncCleanup syncTimerCleanup
3333 oldTabCancel context.CancelFunc
3434 sourceEmpty bool
@@ -110,7 +110,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
110110 d.afterMoveTabSourceSnapshot()
111111 }
112112 commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}
113- var frozen frozenAttachmentEffectGates
113+ var frozen attachmentTransitionGuard
114114 if admission.finalSource && len(admission.sourceAttachments) != 0 {
115115 interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))
116116 for _, ac := range admission.sourceAttachments {
@@ -190,7 +190,7 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
190190 if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) {
191191 return nil, errMoveStaleTarget
192192 }
193- if req.AttachmentToken.ac != nil && (req.AttachmentToken.ac != req.Attachment || !moveAttachmentTokenCurrentLocked(req.AttachmentToken, source)) {
193+ if req.AttachmentCapability.ac != nil && (req.AttachmentCapability.ac != req.Attachment || !req.AttachmentCapability.currentInSessionLocked(source)) {
194194 return nil, errMoveStaleTarget
195195 }
196196 moved := findMoveTabLocked(source, req.SourceTabID)
@@ -251,7 +251,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
251251 if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) {
252252 return false
253253 }
254- if c.req.AttachmentToken.ac != nil && (c.req.AttachmentToken.ac != c.req.Attachment || !moveAttachmentTokenCurrentLocked(c.req.AttachmentToken, c.source)) {
254+ if c.req.AttachmentCapability.ac != nil && (c.req.AttachmentCapability.ac != c.req.Attachment || !c.req.AttachmentCapability.currentInSessionLocked(c.source)) {
255255 return false
256256 }
257257 moved := c.admission.tab
M internal/usecase/daemon/move_tab_test.go +3 -3
@@ -408,12 +408,12 @@ func (r *moveTabPurgeRepository) DeleteIncarnation(_ context.Context, id domain.
408408 sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()
409409 destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()
410410 tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()
411- roleUnlocked := r.client.attachmentEffects.mu.TryLock()
412- roleStable := roleUnlocked && r.client.attachmentEffects.phase == attachmentEffectsStable
411+ roleUnlocked := r.client.lifecycle.mu.TryLock()
412+ roleStable := roleUnlocked && r.client.lifecycle.phase == attachmentEffectsStable
413413 r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&
414414 sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable
415415 if roleUnlocked {
416- r.client.attachmentEffects.mu.Unlock()
416+ r.client.lifecycle.mu.Unlock()
417417 }
418418 if tabFenceUnlocked {
419419 r.moved.layoutApplyMu.Unlock()
M internal/usecase/daemon/navigation_attachment_handoff_test.go +15 -15
@@ -17,25 +17,25 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
1717 name string
1818 releasedAction string
1919 wantErr bool
20- run func(*Daemon, *session, *session, *attachedClient, *attachmentEffectTicket) error
20+ run func(*Daemon, *session, *session, *attachedClient, *attachmentEffect) error
2121 check func(*testing.T, *Daemon, *session, *session)
2222 }{
2323 {
2424 name: "picker selection", releasedAction: "detach",
25- run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
25+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffect) error {
2626 d.enterPicker(source, ac)
27- return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
27+ return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "picker-select")
2828 },
2929 },
3030 {
3131 name: "palette session", releasedAction: "detach",
32- run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
33- return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
32+ run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error {
33+ return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
3434 },
3535 },
3636 {
3737 name: "session overflow", releasedAction: "detach",
38- run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffectTicket) error {
38+ run: func(d *Daemon, source, target *session, ac *attachedClient, effect *attachmentEffect) error {
3939 source.mu.Lock()
4040 source.name = "alpha"
4141 source.mu.Unlock()
@@ -48,13 +48,13 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
4848 },
4949 {
5050 name: "stopped session resume", releasedAction: "detach",
51- run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {
51+ run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error {
5252 d.mu.Lock()
5353 delete(d.sessions, target.id)
5454 d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 9, incarnation: domain.IncarnationID{2}, state: ports.SessionDown}
5555 d.mu.Unlock()
5656 expectedCreatedAt := int64(9)
57- return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
57+ return d.switchToTargetForAttachment(effect, picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "stopped-session")
5858 },
5959 check: func(t *testing.T, d *Daemon, _, _ *session) {
6060 d.mu.Lock()
@@ -67,8 +67,8 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
6767 },
6868 {
6969 name: "transition prompt creation", releasedAction: "create-session", wantErr: true,
70- run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffectTicket) error {
71- return d.createSessionAndSwitchForAttachment(effect.connectionToken(), "created")
70+ run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffect) error {
71+ return d.createSessionAndSwitchForAttachment(effect, "created")
7272 },
7373 check: func(t *testing.T, d *Daemon, _, _ *session) {
7474 d.mu.Lock()
@@ -93,9 +93,9 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
9393 d.mu.Unlock()
9494
9595 rc := d.attachCoordinator(source, nil, old, true)
96- token := source.attachmentToken(old, old.transport())
96+ token := source.captureAttachmentCapability(old, old.transport())
9797 token.lease = rc.attachmentLease(old)
98- old.publishAttachmentCapability(token)
98+ old.installTestAttachmentCapability(token)
9999 effect, admitted := old.beginAttachmentEffect(token)
100100 require.True(t, admitted)
101101
@@ -153,9 +153,9 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
153153 d.mu.Unlock()
154154
155155 rc := d.attachCoordinator(source, nil, old, true)
156- token := source.attachmentToken(old, old.transport())
156+ token := source.captureAttachmentCapability(old, old.transport())
157157 token.lease = rc.attachmentLease(old)
158- old.publishAttachmentCapability(token)
158+ old.installTestAttachmentCapability(token)
159159 effect, admitted := old.beginAttachmentEffect(token)
160160 require.True(t, admitted)
161161
@@ -171,7 +171,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
171171 expectedCreatedAt := int64(7)
172172 done := make(chan error, 1)
173173 go func() {
174- done <- d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped")
174+ done <- d.switchToTargetForAttachment(effect, picker.Target{Name: "stopped", Stopped: true, ExpectedCreatedAt: &expectedCreatedAt}, sessionHandoffGuard{}, "picker-stopped")
175175 }()
176176 select {
177177 case <-ended:
M internal/usecase/daemon/navigation_attachment_incarnation_test.go +8 -8
@@ -19,9 +19,9 @@ func TestNavigationHandoffRejectsReplacedTargetLifecycleAtPublication(t *testing
1919 d.mu.Unlock()
2020
2121 rc := d.attachCoordinator(source, nil, ac, true)
22- token := source.attachmentToken(ac, ac.transport())
22+ token := source.captureAttachmentCapability(ac, ac.transport())
2323 token.lease = rc.attachmentLease(ac)
24- ac.publishAttachmentCapability(token)
24+ ac.installTestAttachmentCapability(token)
2525 effect, admitted := ac.beginAttachmentEffect(token)
2626 require.True(t, admitted)
2727
@@ -30,7 +30,7 @@ func TestNavigationHandoffRejectsReplacedTargetLifecycleAtPublication(t *testing
3030 // transition's lifecycle fence is validated.
3131 target.incarnation = domain.SessionLifecycleID{2}
3232 }
33- err := d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{
33+ err := d.switchToTargetForAttachment(effect, picker.Target{
3434 Session: target.id,
3535 Incarnation: domain.SessionLifecycleID{1},
3636 TabID: domain.TabStableID(target.tabs[1].stableID),
@@ -69,9 +69,9 @@ func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testi
6969 name: "transport incarnation rebound on same attachment",
7070 rebind: func(t *testing.T, _ *Daemon, source *session, ac *attachedClient) {
7171 ac.replaceTransport(&closeTrackingTransport{})
72- current := source.attachmentToken(ac, ac.transport())
72+ current := source.captureAttachmentCapability(ac, ac.transport())
7373 current.lease = source.renderCoordinator().attachmentLease(ac)
74- ac.publishAttachmentCapability(current)
74+ ac.installTestAttachmentCapability(current)
7575 },
7676 },
7777 }
@@ -88,9 +88,9 @@ func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testi
8888 d.mu.Unlock()
8989
9090 rc := d.attachCoordinator(source, nil, ac, true)
91- token := source.attachmentToken(ac, ac.transport())
91+ token := source.captureAttachmentCapability(ac, ac.transport())
9292 token.lease = rc.attachmentLease(ac)
93- ac.publishAttachmentCapability(token)
93+ ac.installTestAttachmentCapability(token)
9494 effect, admitted := ac.beginAttachmentEffect(token)
9595 require.True(t, admitted)
9696
@@ -104,7 +104,7 @@ func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testi
104104 }
105105 done := make(chan error, 1)
106106 go func() {
107- done <- d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff")
107+ done <- d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "test-handoff")
108108 }()
109109 awaitTestCompletion(t, ended, "test handoff did not release its role ticket")
110110 tt.rebind(t, d, source, ac)
M internal/usecase/daemon/overlay_runtime.go +3 -3
@@ -53,7 +53,7 @@ type overlayRuntime struct {
5353 promptMu sync.Mutex
5454 prompt *promptui.Model
5555 promptSubmit func(string) error
56- promptTransitionSubmit func(string, attachmentConnectionToken) error
56+ promptTransitionSubmit func(string, *attachmentEffect) error
5757 promptPending []byte
5858
5959 resizeMu sync.Mutex
@@ -247,12 +247,12 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
247247 return active || prePublication
248248 }
249249
250-func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attachmentEffectTicket) bool {
250+func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attachmentEffect) bool {
251251 if rt == nil || rt.ac == nil {
252252 return false
253253 }
254254 ac := rt.ac
255- var effect *attachmentEffectTicket
255+ var effect *attachmentEffect
256256 if len(effects) != 0 {
257257 effect = effects[0]
258258 }
M internal/usecase/daemon/palette.go +22 -16
@@ -329,7 +329,7 @@ func (d *Daemon) recordPaletteUse(code string) {
329329 d.paletteRecent = recent
330330 }
331331
332-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
332+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
333333 entry := ac.currentAttachmentSession()
334334 if entry == nil {
335335 return
@@ -350,7 +350,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
350350 var rawQuery string
351351 changed, cancel, execute := false, false, false
352352 var closedDiscovery remoteDiscoveryInstance
353- var effect *attachmentEffectTicket
353+ var effect *attachmentEffect
354354 if len(effects) != 0 {
355355 effect = effects[0]
356356 }
@@ -490,7 +490,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
490490 RemoteKey: &remoteKey, RemoteTarget: &remoteTarget, RemoteHost: remoteKey.Host,
491491 UnavailableReason: remoteUnavailableReason, TabIndex: -1,
492492 }
493- err := d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-remote-session")
493+ err := d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{}, "palette-remote-session")
494494 if err == nil {
495495 d.closeExecutedPalette(ac, generation, rawQuery)
496496 return
@@ -529,7 +529,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
529529 }
530530 var err error
531531 if effect != nil {
532- err = d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{allowSamePeer: true}, "palette-session")
532+ err = d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{allowSamePeer: true}, "palette-session")
533533 } else {
534534 err = d.switchToTarget(sess, ac, target)
535535 }
@@ -582,7 +582,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
582582 sess.dispatchMu.Unlock()
583583 if attachmentHandoff {
584584 if current := ac.currentSession(); current != nil {
585- currentToken := current.attachmentToken(ac, ac.transport())
585+ currentToken := current.captureAttachmentCapability(ac, ac.transport())
586586 fresh, admitted := ac.beginAttachmentEffect(currentToken)
587587 if d.closeExecutedPalette(ac, generation, rawQuery) {
588588 d.invalidateRender(current, ac, true, "palette.go")
@@ -681,7 +681,7 @@ type paletteExec struct {
681681 ac *attachedClient
682682 routeSnapshot ports.RecentRouteSnapshot
683683 actions daemonActionRunner
684- effect *attachmentEffectTicket
684+ effect *attachmentEffect
685685 redrawClosedPalette bool
686686 }
687687
@@ -719,14 +719,14 @@ func (e paletteExec) CreateSession() error {
719719 if entry == nil {
720720 entry = e.sess
721721 }
722- e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error {
723- if token.ac == nil {
722+ e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, effect *attachmentEffect) error {
723+ if effect == nil || effect.ac == nil {
724724 if e.sess == nil {
725725 return errAttachmentTransition
726726 }
727727 return e.d.createSessionAndSwitch(e.sess, e.ac, name)
728728 }
729- return e.d.createSessionAndSwitchForAttachment(token, name)
729+ return e.d.createSessionAndSwitchForAttachment(effect, name)
730730 })
731731 return nil
732732 }
@@ -735,14 +735,14 @@ func (e paletteExec) CreateSessionNamed(name string) error {
735735 if e.effect == nil {
736736 return e.d.createSessionAndSwitch(e.sess, e.ac, name)
737737 }
738- return e.d.createSessionAndSwitchForAttachment(e.effect.connectionToken(), name)
738+ return e.d.createSessionAndSwitchForAttachment(e.effect, name)
739739 }
740740
741741 func (e paletteExec) CreateEphemeralSession() error {
742742 if e.effect == nil {
743743 return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)
744744 }
745- return e.d.createEphemeralSessionAndSwitchForAttachment(e.effect.connectionToken())
745+ return e.d.createEphemeralSessionAndSwitchForAttachment(e.effect)
746746 }
747747
748748 func (e paletteExec) CloseTab() error {
@@ -839,7 +839,7 @@ func (e paletteExec) ToggleFloatingPane() error {
839839
840840 func (e paletteExec) BackSession() error {
841841 if e.effect != nil {
842- return e.d.backSessionForAttachment(e.effect.connectionToken())
842+ return e.d.backSessionForAttachment(e.effect)
843843 }
844844 e.d.backSession(e.sess, e.ac)
845845 return nil
@@ -847,7 +847,7 @@ func (e paletteExec) BackSession() error {
847847
848848 func (e paletteExec) Detach() error {
849849 if e.effect != nil {
850- if !e.d.clientGoneForAttachment(e.effect.connectionToken(), true) {
850+ if !e.d.clientGoneForAttachment(e.effect, true) {
851851 return errAttachmentTransition
852852 }
853853 return nil
@@ -912,7 +912,7 @@ func (e paletteExec) RenameTabTo(name string) error {
912912
913913 func (e paletteExec) OpenSessionPicker() error {
914914 if e.ac != nil && e.ac.navigationCapabilities&ports.NavigationCapabilityHomePicker != 0 && e.effect != nil {
915- return e.d.sendNavigationActionForAttachment(e.effect.connectionToken(), ports.NavigationOpenHomePicker)
915+ return e.d.sendNavigationActionForAttachment(e.effect, ports.NavigationOpenHomePicker)
916916 }
917917 e.d.enterPicker(e.sess, e.ac)
918918 return nil
@@ -957,12 +957,18 @@ func (e paletteExec) NavigateRecentRoute(action ports.RouteNavigationAction) err
957957 e.d.beforeRecentSessionHandoff()
958958 }
959959 if e.effect != nil {
960- return e.d.sendRecentRouteNavigationActionForAttachment(e.effect.connectionToken(), action)
960+ return e.d.sendRecentRouteNavigationActionForAttachment(e.effect, action)
961961 }
962962 if e.ac == nil || e.attachment == nil {
963963 return errAttachmentTransition
964964 }
965- return e.d.sendRecentRouteNavigationActionForAttachment(e.attachment.attachmentToken(e.ac, e.ac.transport()), action)
965+ capability := e.attachment.captureAttachmentCapability(e.ac, e.ac.transport())
966+ effect, admitted := e.ac.beginAttachmentEffect(capability)
967+ if !admitted {
968+ return errAttachmentTransition
969+ }
970+ defer effect.End()
971+ return e.d.sendRecentRouteNavigationActionForAttachment(effect, action)
966972 }
967973
968974 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {
M internal/usecase/daemon/palette_test.go +8 -9
@@ -32,18 +32,17 @@ func testRecentRouteSnapshot() ports.RecentRouteSnapshot {
3232 }
3333 }
3434
35-func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *attachedClient) attachmentConnectionToken {
35+func beginRecentRoutePaletteEffect(t *testing.T, d *Daemon, sess *session, ac *attachedClient) *attachmentEffect {
3636 t.Helper()
3737 transport := ac.transport()
3838 rc := d.attachCoordinator(sess, nil, ac, true)
39- token := sess.attachmentToken(ac, transport)
39+ token := sess.captureAttachmentCapability(ac, transport)
4040 token.lease = rc.attachmentLease(ac)
41- ac.publishAttachmentCapability(token)
41+ ac.installTestAttachmentCapability(token)
4242 effect, admitted := ac.beginAttachmentEffect(token)
4343 require.True(t, admitted)
4444 t.Cleanup(effect.End)
45- token.effect = effect
46- return token
45+ return effect
4746 }
4847
4948 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {
@@ -355,14 +354,14 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
355354
356355 d.handleInput(current, ac, []byte("\x1b "))
357356 awaitFrame(t, sends, ports.MsgOutput)
358- generation := ac.connectionGeneration.Load()
357+ generation := ac.lifecycle.generationValue()
359358 d.handleInput(current, ac, []byte("stopped\r"))
360359
361360 resumed := ac.currentSession()
362361 require.Equal(t, "stopped", resumed.name)
363362 require.Equal(t, int64(42), resumed.createdAt)
364363 require.Equal(t, true, resumed.attachmentRegistered(ac))
365- require.Greater(t, ac.connectionGeneration.Load(), generation, "stopped-session handoff must publish through the attachment transition")
364+ require.Greater(t, ac.lifecycle.generationValue(), generation, "stopped-session handoff must publish through the attachment transition")
366365 require.False(t, ac.overlays.paletteActive())
367366 firstPaint := awaitFrame(t, sends, ports.MsgOutput)
368367 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
@@ -1004,7 +1003,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
10041003 require.True(t, ac.overlays.promptActive())
10051004 require.Contains(t, string(promptOutput.Data), "Create session")
10061005
1007- generation := ac.connectionGeneration.Load()
1006+ generation := ac.lifecycle.generationValue()
10081007 d.handleInput(sess, ac, []byte("scratch\r"))
10091008 // The submit first paints the newly attached session while the prompt is
10101009 // still open, then handlePromptInput closes the prompt and repaints the
@@ -1026,7 +1025,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
10261025 require.False(t, newSess.ephemeral)
10271026 require.Contains(t, newSess.snapshotAttachments(), ac)
10281027 require.Equal(t, true, newSess.attachmentRegistered(ac))
1029- require.Greater(t, ac.connectionGeneration.Load(), generation, "new-session handoff must publish through the attachment transition")
1028+ require.Greater(t, ac.lifecycle.generationValue(), generation, "new-session handoff must publish through the attachment transition")
10301029 require.Contains(t, string(finalOutput.Data), "scratch")
10311030 require.NotContains(t, string(finalOutput.Data), "Create session")
10321031 }
M internal/usecase/daemon/pane_actions.go +3 -3
@@ -348,7 +348,7 @@ func (d *Daemon) closePaneLocked(sess *session, tb *tab, id layout.PaneID, ac *a
348348 return d.closePaneLockedWithEffect(sess, tb, id, ac, repaint, nil)
349349 }
350350
351-func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool, effect *attachmentEffectTicket) error {
351+func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.PaneID, ac *attachedClient, repaint bool, effect *attachmentEffect) error {
352352 if tb == nil {
353353 return layout.ErrNotFound
354354 }
@@ -406,7 +406,7 @@ func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.Pan
406406 return nil
407407 }
408408
409-func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *attachmentEffectTicket) error {
409+func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Direction, effect *attachmentEffect) error {
410410 target := resolveDaemonActionTargetForAttachment(sess, ac)
411411 oldFocus := layout.PaneID("")
412412 if target.pane != nil {
@@ -434,7 +434,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
434434 return errNoNeighbor
435435 }
436436 if effect != nil {
437- return d.switchToTargetForAttachment(effect.connectionToken(), sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
437+ return d.switchToTargetForAttachment(effect, sessionTarget, sessionHandoffGuard{expectedSource: target.tab}, "overflow-session")
438438 }
439439 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
440440 }
M internal/usecase/daemon/parked_route.go +85 -60
@@ -50,9 +50,12 @@ func stopParkedRouteLeaseLocked(lease *parkedRouteLease) {
5050 }
5151 }
5252
53-func (d *Daemon) armParkedRoute(token attachmentConnectionToken) (ports.ParkedRouteLeaseID, error) {
54- ac := token.ac
55- if ac == nil || !token.attachmentCurrent() || ac.navigationCapabilities&ports.NavigationCapabilityHomePicker == 0 {
53+func (d *Daemon) armParkedRoute(effect *attachmentEffect) (ports.ParkedRouteLeaseID, error) {
54+ if !effect.current() {
55+ return ports.ParkedRouteLeaseID{}, errAttachmentTransition
56+ }
57+ ac := effect.ac
58+ if ac == nil || ac.navigationCapabilities&ports.NavigationCapabilityHomePicker == 0 {
5659 return ports.ParkedRouteLeaseID{}, errAttachmentTransition
5760 }
5861 id, err := newParkedRouteLeaseID()
@@ -60,7 +63,7 @@ func (d *Daemon) armParkedRoute(token attachmentConnectionToken) (ports.ParkedRo
6063 return ports.ParkedRouteLeaseID{}, err
6164 }
6265 lease := &parkedRouteLease{
63- id: id, generation: token.generation, transport: token.transport,
66+ id: id, generation: effect.generation, transport: effect.transport,
6467 expiresAt: d.clock.Now().Add(parkedRouteLeaseTTL),
6568 timer: d.clock.NewTimer(parkedRouteLeaseTTL), stop: make(chan struct{}),
6669 }
@@ -103,7 +106,7 @@ func (d *Daemon) expireParkedRoute(ac *attachedClient, lease *parkedRouteLease)
103106 ac.parkedRouteOutput.Store(false)
104107 ac.parkedRouteFullPending.Store(false)
105108 ac.parkedRouteMu.Unlock()
106- if ac.connectionGeneration.Load() == lease.generation && ac.transportSnapshotCurrent(lease.transport) {
109+ if ac.lifecycle.generationValue() == lease.generation && ac.transportSnapshotCurrent(lease.transport) {
107110 _ = ac.closeCapturedTransport(lease.transport.transport)
108111 }
109112 }
@@ -120,8 +123,8 @@ func (ac *attachedClient) clearParkedRoute() {
120123 ac.parkedRouteMu.Unlock()
121124 }
122125
123-func (ac *attachedClient) parkedRouteStatus(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time, suspended bool) ports.ParkedRouteStatus {
124- if ac == nil || !token.attachmentCurrent() {
126+func (ac *attachedClient) parkedRouteStatus(token attachmentCapability, id ports.ParkedRouteLeaseID, now time.Time, suspended bool) ports.ParkedRouteStatus {
127+ if ac == nil || !token.current() {
125128 return ports.ParkedRouteRejected
126129 }
127130 ac.parkedRouteMu.Lock()
@@ -143,7 +146,7 @@ func (ac *attachedClient) parkedRouteStatus(token attachmentConnectionToken, id
143146 return 0
144147 }
145148
146-func (ac *attachedClient) prepareParkedRoute(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
149+func (ac *attachedClient) prepareParkedRoute(token attachmentCapability, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
147150 if status := ac.parkedRouteStatus(token, id, now, false); status != 0 {
148151 return status
149152 }
@@ -158,7 +161,7 @@ func (ac *attachedClient) prepareParkedRoute(token attachmentConnectionToken, id
158161 return 0
159162 }
160163
161-func (ac *attachedClient) consumeParkedRoute(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
164+func (ac *attachedClient) consumeParkedRoute(token attachmentCapability, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
162165 if status := ac.parkedRouteStatus(token, id, now, true); status != 0 {
163166 return status
164167 }
@@ -173,8 +176,8 @@ func (ac *attachedClient) consumeParkedRoute(token attachmentConnectionToken, id
173176 return 0
174177 }
175178
176-func (ac *attachedClient) beginParkedRouteSwitch(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
177- if ac == nil || !token.attachmentCurrent() {
179+func (ac *attachedClient) beginParkedRouteSwitch(token attachmentCapability, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
180+ if ac == nil || !token.current() {
178181 return ports.ParkedRouteRejected
179182 }
180183 ac.parkedRouteMu.Lock()
@@ -194,7 +197,7 @@ func (ac *attachedClient) beginParkedRouteSwitch(token attachmentConnectionToken
194197 return 0
195198 }
196199
197-func (ac *attachedClient) rejectParkedRouteSwitch(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
200+func (ac *attachedClient) rejectParkedRouteSwitch(token attachmentCapability, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
198201 if ac == nil {
199202 return ports.ParkedRouteRejected
200203 }
@@ -215,8 +218,8 @@ func (ac *attachedClient) rejectParkedRouteSwitch(token attachmentConnectionToke
215218 return 0
216219 }
217220
218-func (ac *attachedClient) consumePublishedParkedRoute(previous, published attachmentConnectionToken, id ports.ParkedRouteLeaseID) ports.ParkedRouteStatus {
219- if ac == nil || published.ac != ac || !published.attachmentCurrent() {
221+func (ac *attachedClient) consumePublishedParkedRoute(previous, published attachmentCapability, id ports.ParkedRouteLeaseID) ports.ParkedRouteStatus {
222+ if ac == nil || published.ac != ac || !published.current() {
220223 return ports.ParkedRouteRejected
221224 }
222225 ac.parkedRouteMu.Lock()
@@ -241,12 +244,12 @@ func parkedRouteResponseFrame(response ports.ParkedRouteResponse) (ports.Frame,
241244 return ports.Frame{Type: ports.MsgParkedRouteResponse, Payload: payload}, nil
242245 }
243246
244-func (d *Daemon) sendParkedRouteResponse(token attachmentConnectionToken, response ports.ParkedRouteResponse) error {
247+func (d *Daemon) sendParkedRouteResponse(effect *attachmentEffect, response ports.ParkedRouteResponse) error {
245248 frame, err := parkedRouteResponseFrame(response)
246249 if err != nil {
247250 return err
248251 }
249- return token.sendControl(frame)
252+ return effect.sendControl(frame)
250253 }
251254
252255 // sendParkedRouteResponseLocked preserves response -> full-output ordering.
@@ -270,77 +273,98 @@ func (ac *attachedClient) releaseParkedOutputLocked() {
270273 ac.parkedRouteOutput.Store(false)
271274 }
272275
273-func (d *Daemon) parkedRouteResponseFailed(token attachmentConnectionToken) {
274- if token.effect == nil || token.effect.ended.Load() {
276+func (d *Daemon) parkedRouteResponseFailed(effect *attachmentEffect) {
277+ if effect == nil {
275278 return
276279 }
277- launch := d.reserveAttachmentSendErrorCleanup(token, token.transport.transport)
278- token.effect.End()
280+ capability := effect.capability()
281+ launch := d.reserveAttachmentSendErrorCleanup(capability, capability.transport.transport)
282+ effect.End()
279283 launch()
280284 }
281285
282-func (d *Daemon) respondParkedRoute(token attachmentConnectionToken, response ports.ParkedRouteResponse) bool {
283- if err := d.sendParkedRouteResponse(token, response); err != nil {
284- d.parkedRouteResponseFailed(token)
286+func (d *Daemon) respondParkedRoute(effect *attachmentEffect, response ports.ParkedRouteResponse) bool {
287+ if effect == nil || effect.ac == nil {
288+ return false
289+ }
290+ sender := effect
291+ owned := false
292+ if !sender.current() {
293+ var admitted bool
294+ sender, admitted = effect.ac.beginAttachmentEffect(effect.capability())
295+ if !admitted {
296+ return false
297+ }
298+ owned = true
299+ }
300+ if owned {
301+ defer sender.End()
302+ }
303+ if err := d.sendParkedRouteResponse(sender, response); err != nil {
304+ d.parkedRouteResponseFailed(sender)
285305 return false
286306 }
287307 return true
288308 }
289309
290-func (d *Daemon) handleParkedRouteRequest(token attachmentConnectionToken, request ports.ParkedRouteRequest) {
291- if ports.ValidateParkedRouteRequest(request) != nil || token.ac == nil {
310+func (d *Daemon) handleParkedRouteRequest(effect *attachmentEffect, request ports.ParkedRouteRequest) {
311+ if ports.ValidateParkedRouteRequest(request) != nil || !effect.current() || effect.ac == nil {
292312 return
293313 }
314+ capability := effect.capability()
294315 switch request.Action {
295316 case ports.ParkedRoutePrepare:
296- status := token.ac.prepareParkedRoute(token, request.LeaseID, d.clock.Now())
317+ status := effect.ac.prepareParkedRoute(capability, request.LeaseID, d.clock.Now())
297318 if status == 0 {
298319 status = ports.ParkedRouteReady
299320 }
300- d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status})
321+ d.respondParkedRoute(effect, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status})
301322 case ports.ParkedRouteResume:
302- d.resumeParkedRoute(token, request)
323+ d.resumeParkedRoute(effect, request)
303324 case ports.ParkedRouteSwitch:
304- d.switchParkedRoute(token, request)
325+ d.switchParkedRoute(effect, request)
305326 }
306327 }
307328
308-func (d *Daemon) resumeParkedRoute(token attachmentConnectionToken, request ports.ParkedRouteRequest) {
309- status := token.ac.consumeParkedRoute(token, request.LeaseID, d.clock.Now())
329+func (d *Daemon) resumeParkedRoute(effect *attachmentEffect, request ports.ParkedRouteRequest) {
330+ capability := effect.capability()
331+ status := effect.ac.consumeParkedRoute(capability, request.LeaseID, d.clock.Now())
310332 if status != 0 {
311- d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status})
333+ d.respondParkedRoute(effect, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status})
312334 return
313335 }
314- ac := token.ac
336+ ac := effect.ac
315337 ac.sendMu.Lock()
316- err := d.sendParkedRouteResponseLocked(ac, token.transport, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: ports.ParkedRouteResumed})
338+ err := d.sendParkedRouteResponseLocked(ac, effect.transport, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: ports.ParkedRouteResumed})
317339 if err == nil {
318340 ac.releaseParkedOutputLocked()
319341 }
320342 ac.sendMu.Unlock()
321343 if err != nil {
322- d.parkedRouteResponseFailed(token)
344+ d.parkedRouteResponseFailed(effect)
323345 return
324346 }
325- if token.sess != nil {
326- d.firstPaintWithLease(token.sess, ac, token.lease, true)
347+ if effect.sess != nil {
348+ d.firstPaintWithLease(effect.sess, ac, effect.lease, true)
327349 }
328350 }
329351
330-func (d *Daemon) rejectParkedRouteSwitch(token attachmentConnectionToken, request ports.ParkedRouteRequest, fallback ports.ParkedRouteStatus) {
331- status := token.ac.rejectParkedRouteSwitch(token, request.LeaseID, d.clock.Now())
352+func (d *Daemon) rejectParkedRouteSwitch(effect *attachmentEffect, request ports.ParkedRouteRequest, fallback ports.ParkedRouteStatus) {
353+ capability := effect.capability()
354+ status := effect.ac.rejectParkedRouteSwitch(capability, request.LeaseID, d.clock.Now())
332355 if status == 0 {
333356 status = fallback
334357 }
335- if d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired {
336- _ = token.ac.closeCapturedTransport(token.transport.transport)
358+ if d.respondParkedRoute(effect, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired {
359+ _ = effect.ac.closeCapturedTransport(effect.transport.transport)
337360 }
338361 }
339362
340-func (d *Daemon) switchParkedRoute(token attachmentConnectionToken, request ports.ParkedRouteRequest) {
341- if status := token.ac.beginParkedRouteSwitch(token, request.LeaseID, d.clock.Now()); status != 0 {
342- if d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired {
343- _ = token.ac.closeCapturedTransport(token.transport.transport)
363+func (d *Daemon) switchParkedRoute(effect *attachmentEffect, request ports.ParkedRouteRequest) {
364+ capability := effect.capability()
365+ if status := effect.ac.beginParkedRouteSwitch(capability, request.LeaseID, d.clock.Now()); status != 0 {
366+ if d.respondParkedRoute(effect, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired {
367+ _ = effect.ac.closeCapturedTransport(effect.transport.transport)
344368 }
345369 return
346370 }
@@ -350,34 +374,34 @@ func (d *Daemon) switchParkedRoute(token attachmentConnectionToken, request port
350374 ctx = context.Background()
351375 }
352376 if err := d.waitForParkedTargetRestore(ctx, target.SessionName); err != nil {
353- d.rejectParkedRouteSwitch(token, request, ports.ParkedRouteStaleTarget)
377+ d.rejectParkedRouteSwitch(effect, request, ports.ParkedRouteStaleTarget)
354378 return
355379 }
356380
357- transition, targetSession, err := d.transitionParkedRouteTarget(token, target)
381+ transition, targetSession, err := d.transitionParkedRouteTarget(effect, target)
358382 if err != nil {
359- d.rejectParkedRouteSwitch(token, request, ports.ParkedRouteStaleTarget)
383+ d.rejectParkedRouteSwitch(effect, request, ports.ParkedRouteStaleTarget)
360384 return
361385 }
362- if status := token.ac.consumePublishedParkedRoute(token, transition.published, request.LeaseID); status != 0 {
386+ if status := effect.ac.consumePublishedParkedRoute(capability, transition.published, request.LeaseID); status != 0 {
363387 d.abortPublishedAttachmentTransition(transition)
364388 return
365389 }
366- fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
390+ fresh, admitted := effect.ac.beginAttachmentEffect(transition.published)
367391 if !admitted {
368392 d.abortPublishedAttachmentTransition(transition)
369393 return
370394 }
371- freshToken := fresh.connectionToken()
372- ac := token.ac
395+ freshCapability := fresh.capability()
396+ ac := effect.ac
373397 ac.sendMu.Lock()
374- responseErr := d.sendParkedRouteResponseLocked(ac, freshToken.transport, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: ports.ParkedRouteSwitched})
398+ responseErr := d.sendParkedRouteResponseLocked(ac, freshCapability.transport, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: ports.ParkedRouteSwitched})
375399 if responseErr == nil {
376400 ac.releaseParkedOutputLocked()
377401 }
378402 ac.sendMu.Unlock()
379403 if responseErr != nil {
380- launch := d.reserveAttachmentSendErrorCleanup(freshToken, freshToken.transport.transport)
404+ launch := d.reserveAttachmentSendErrorCleanup(freshCapability, freshCapability.transport.transport)
381405 fresh.End()
382406 launch()
383407 d.deferAttachmentTransitionCleanups(transition)
@@ -409,7 +433,8 @@ func (d *Daemon) waitForParkedTargetRestore(parent context.Context, name string)
409433 return err
410434 }
411435
412-func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, target domain.RemoteSessionTarget) (attachmentTransitionResult, *session, error) {
436+func (d *Daemon) transitionParkedRouteTarget(effect *attachmentEffect, target domain.RemoteSessionTarget) (attachmentTransitionResult, *session, error) {
437+ capability := effect.capability()
413438 d.mu.Lock()
414439 if d.closing {
415440 d.mu.Unlock()
@@ -430,8 +455,8 @@ func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, ta
430455 live.mu.Unlock()
431456 d.mu.Unlock()
432457 transition, err := d.transitionAttachment(attachmentTransitionRequest{
433- source: token.sess, target: live, next: token.ac,
434- expectedTransport: token.transport, sourceToken: &token, action: "parked-route-switch",
458+ source: effect.sess, target: live, next: effect.ac,
459+ expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: "parked-route-switch",
435460 expectedTargetLifecycle: &attachmentLifecycleFence{
436461 name: target.SessionName, incarnation: target.LifecycleID, checkIncarnation: true,
437462 tabID: tabID, tabIndex: tabIndex, checkTab: true,
@@ -455,8 +480,8 @@ func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, ta
455480
456481 var created *session
457482 transition, err := d.transitionAttachment(attachmentTransitionRequest{
458- source: token.sess, next: token.ac,
459- expectedTransport: token.transport, sourceToken: &token, action: "parked-route-restore",
483+ source: effect.sess, next: effect.ac,
484+ expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: "parked-route-restore",
460485 activateTargetTab: true, targetTabIndex: tabIndex, ready: true,
461486 createTargetLocked: func() (*session, error) {
462487 current, ok := d.inactive[target.SessionName]
@@ -470,7 +495,7 @@ func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, ta
470495 env := copyEnvironment(d.baseEnv)
471496 cwd := d.dirOrHome(current.cwd)
472497 var createErr error
473- created, createErr = d.resumeRemoteInactiveSessionLocked(target, cwd, token.ac.geometrySnapshot(), env, current)
498+ created, createErr = d.resumeRemoteInactiveSessionLocked(target, cwd, effect.ac.geometrySnapshot(), env, current)
474499 return created, createErr
475500 },
476501 })
M internal/usecase/daemon/parked_route_test.go +18 -16
@@ -64,18 +64,17 @@ func (c *parkedRouteClock) setNow(now time.Time) {
6464 func armAndPrepareParkedRoute(t *testing.T, d *Daemon, source *session, ac *attachedClient, sends chan ports.Frame) ports.ParkedRouteLeaseID {
6565 t.Helper()
6666 ac.navigationCapabilities |= ports.NavigationCapabilityHomePicker
67- token := source.attachmentToken(ac, ac.transport())
67+ token := source.captureAttachmentCapability(ac, ac.transport())
6868 effect, admitted := ac.beginAttachmentEffect(token)
6969 require.True(t, admitted)
70- token.effect = effect
71- require.NoError(t, d.sendNavigationActionForAttachment(token, ports.NavigationOpenHomePicker))
70+ require.NoError(t, d.sendNavigationActionForAttachment(effect, ports.NavigationOpenHomePicker))
7271 effect.End()
7372 directiveFrame := awaitFrame(t, sends, ports.MsgNavigationAction)
7473 directive, err := ports.UnmarshalNavigationDirective(directiveFrame.Payload)
7574 require.NoError(t, err)
7675
7776 prepare := ports.ParkedRouteRequest{RequestID: 1, LeaseID: directive.LeaseID, Action: ports.ParkedRoutePrepare}
78- require.False(t, d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(prepare)}))
77+ require.False(t, d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(prepare)}))
7978 readyFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
8079 ready, err := ports.UnmarshalParkedRouteResponse(readyFrame.Payload)
8180 require.NoError(t, err)
@@ -108,7 +107,7 @@ func TestParkedRouteSwitchesExactLiveTargetOnRetainedTransport(t *testing.T) {
108107 SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID),
109108 }
110109 switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &targetRequest}
111- require.False(t, d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)}))
110+ require.False(t, d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)}))
112111
113112 require.Same(t, target, ac.currentAttachmentSession())
114113 require.False(t, ac.parkedRouteOutput.Load())
@@ -147,7 +146,7 @@ func TestParkedRouteSwitchAcceptedBeforeLeaseExpiryCommitsAtomically(t *testing.
147146 SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID),
148147 }
149148 switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &targetRequest}
150- d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)})
149+ d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)})
151150
152151 require.Same(t, target, ac.currentAttachmentSession())
153152 responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
@@ -185,7 +184,7 @@ func TestParkedRouteRestoresExactStoppedTargetWithDaemonEnvironment(t *testing.T
185184 SessionName: "stopped", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-b"),
186185 }
187186 switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &target}
188- d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)})
187+ d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)})
189188
190189 restored := ac.currentAttachmentSession()
191190 require.NotNil(t, restored)
@@ -210,7 +209,7 @@ func TestParkedRouteStaleTargetLeavesSourceResumable(t *testing.T) {
210209 SessionName: "missing", LiveTabID: "tab-1",
211210 }
212211 switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &missing}
213- d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)})
212+ d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(switchRequest)})
214213 staleFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
215214 stale, err := ports.UnmarshalParkedRouteResponse(staleFrame.Payload)
216215 require.NoError(t, err)
@@ -219,7 +218,7 @@ func TestParkedRouteStaleTargetLeavesSourceResumable(t *testing.T) {
219218 require.True(t, ac.parkedRouteOutput.Load())
220219
221220 resume := ports.ParkedRouteRequest{RequestID: 3, LeaseID: leaseID, Action: ports.ParkedRouteResume}
222- d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(resume)})
221+ d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(resume)})
223222 resumedFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
224223 resumed, err := ports.UnmarshalParkedRouteResponse(resumedFrame.Payload)
225224 require.NoError(t, err)
@@ -245,7 +244,7 @@ func TestParkedRouteDoesNotReplayOneShotEffectsAfterResume(t *testing.T) {
245244 }
246245
247246 resume := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteResume}
248- d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(resume)})
247+ d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(resume)})
249248 _ = awaitFrame(t, sends, ports.MsgParkedRouteResponse)
250249 outputFrame := awaitFrame(t, sends, ports.MsgOutput)
251250 output, err := ports.UnmarshalOutput(outputFrame.Payload)
@@ -270,7 +269,7 @@ func TestParkedRouteExpiredLeaseFailsClosed(t *testing.T) {
270269 ac.parkedRoute.expiresAt = d.clock.Now()
271270 ac.parkedRouteMu.Unlock()
272271 resume := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteResume}
273- d.handleAttachmentClientFrame(source.attachmentToken(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(resume)})
272+ d.handleAttachmentClientFrame(source.captureAttachmentCapability(ac, ac.transport()), ports.Frame{Type: ports.MsgParkedRouteRequest, Payload: ports.MarshalParkedRouteRequest(resume)})
274273 responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
275274 response, err := ports.UnmarshalParkedRouteResponse(responseFrame.Payload)
276275 require.NoError(t, err)
@@ -288,7 +287,7 @@ func TestParkedRouteResumeRejectsSwitchInFlight(t *testing.T) {
288287 ac.parkedRouteMu.Lock()
289288 ac.parkedRoute.inFlight = true
290289 ac.parkedRouteMu.Unlock()
291- status := ac.consumeParkedRoute(source.attachmentToken(ac, ac.transport()), leaseID, d.clock.Now())
290+ status := ac.consumeParkedRoute(source.captureAttachmentCapability(ac, ac.transport()), leaseID, d.clock.Now())
292291
293292 require.Equal(t, ports.ParkedRouteRejected, status)
294293 ac.parkedRouteMu.Lock()
@@ -335,7 +334,11 @@ func TestParkedRouteMalformedSwitchRequestsFailClosed(t *testing.T) {
335334 {RequestID: 2, Action: ports.ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}},
336335 {RequestID: 3, LeaseID: leaseID, Action: 99},
337336 } {
338- d.handleParkedRouteRequest(source.attachmentToken(ac, ac.transport()), request)
337+ capability := source.captureAttachmentCapability(ac, ac.transport())
338+ effect, admitted := ac.beginAttachmentEffect(capability)
339+ require.True(t, admitted)
340+ d.handleParkedRouteRequest(effect, request)
341+ effect.End()
339342 }
340343 require.Same(t, source, ac.currentAttachmentSession())
341344 select {
@@ -349,14 +352,13 @@ func TestParkedRouteResponseFailureDoesNotDrainItsOwnFrameEffect(t *testing.T) {
349352 d, source, ac, _, releases := newManualTabSession(t, 1)
350353 defer releaseAll(releases)
351354 ac.replaceTransport(parkedRouteFailTransport{})
352- token := source.attachmentToken(ac, ac.transport())
355+ token := source.captureAttachmentCapability(ac, ac.transport())
353356 effect, admitted := ac.beginAttachmentEffect(token)
354357 require.True(t, admitted)
355- token.effect = effect
356358
357359 result := make(chan bool, 1)
358360 go func() {
359- result <- d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})
361+ result <- d.respondParkedRoute(effect, ports.ParkedRouteResponse{RequestID: 1, Status: ports.ParkedRouteReady})
360362 }()
361363 require.False(t, awaitTestValue(t, result, "parked-route send failure cleanup"))
362364 d.attachmentCleanupWg.Wait()
M internal/usecase/daemon/picker.go +83 -76
@@ -291,7 +291,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
291291 }
292292 }
293293
294-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
294+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
295295 sess := ac.currentAttachmentSession()
296296 if sess == nil {
297297 return
@@ -344,7 +344,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
344344 return
345345 }
346346 if result.action == 'x' {
347- var effect *attachmentEffectTicket
347+ var effect *attachmentEffect
348348 if len(effects) != 0 {
349349 effect = effects[0]
350350 }
@@ -352,7 +352,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
352352 var err error
353353 if effect != nil {
354354 effect.bindActionEnd(d, "picker-delete")
355- err = d.killPickerTargetForAttachment(target, effect.connectionToken())
355+ err = d.killPickerTargetForAttachment(target, effect)
356356 } else {
357357 err = d.killPickerTarget(target)
358358 }
@@ -360,8 +360,8 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
360360 d.reportAttachmentError(sess, err)
361361 }
362362 }
363- if effect != nil && effect.ended.Load() {
364- fresh, admitted := ac.beginAttachmentEffect(effect.token)
363+ if effect != nil && !effect.current() {
364+ fresh, admitted := ac.beginAttachmentEffect(effect.capability())
365365 if !admitted {
366366 return
367367 }
@@ -379,20 +379,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
379379 if result.exit && !committing {
380380 back := ac.startupOverlay == ports.StartupOverlaySessionPicker && ac.navigationCapabilities&ports.NavigationCapabilityBack != 0
381381 if back {
382- var token attachmentConnectionToken
383- if len(effects) != 0 && effects[0] != nil {
384- token = effects[0].connectionToken()
385- } else {
386- var effect *attachmentEffectTicket
387- var admitted bool
388- token, effect, admitted = ac.beginCurrentAttachmentEffect(sess, ac.transport())
389- if !admitted {
390- d.invalidateRender(sess, ac, true, "picker.go")
391- return
392- }
393- defer effect.End()
382+ if len(effects) == 0 || effects[0] == nil {
383+ d.invalidateRender(sess, ac, true, "picker.go")
384+ return
394385 }
395- if err := d.sendNavigationActionForAttachment(token, ports.NavigationBack); err != nil {
386+ effect := effects[0]
387+ if err := d.sendNavigationActionForAttachment(effect, ports.NavigationBack); err != nil {
396388 d.reportAttachmentError(sess, err)
397389 d.invalidateRender(sess, ac, true, "picker.go")
398390 return
@@ -423,7 +415,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
423415 }
424416 var err error
425417 if len(effects) != 0 && effects[0] != nil {
426- err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select")
418+ err = d.switchToTargetForAttachment(effects[0], target, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select")
427419 } else {
428420 d.closePicker(ac)
429421 err = d.switchToTarget(sess, ac, target)
@@ -793,8 +785,8 @@ type sessionHandoffGuard struct {
793785 // centralized transition. The transition releases admission at its freeze seam
794786 // and revalidates the exact initiating token before changing target focus or
795787 // attachment ownership.
796-func (d *Daemon) switchActiveTargetForAttachment(token attachmentConnectionToken, target picker.Target) error {
797- return d.switchActiveTargetForAttachmentGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
788+func (d *Daemon) switchActiveTargetForAttachment(effect *attachmentEffect, target picker.Target) error {
789+ return d.switchActiveTargetForAttachmentGuarded(effect, target, sessionHandoffGuard{}, "jump-attention")
798790 }
799791
800792 func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
@@ -819,43 +811,44 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence
819811 return fence
820812 }
821813
822-func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
823- if token.sess == nil || token.ac == nil {
814+func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, action string) error {
815+ if !effect.current() || effect.sess == nil || effect.ac == nil {
824816 return nil
825817 }
826818 d.mu.Lock()
827819 targetSess := d.sessions[target.Session]
828820 d.mu.Unlock()
829821 if targetSess == nil {
830- if !token.attachmentCurrent() {
822+ if !effect.current() {
831823 return nil
832824 }
833- d.invalidateRender(token.sess, token.ac, true, "picker.go")
825+ d.invalidateRender(effect.sess, effect.ac, true, "picker.go")
834826 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
835827 }
836- if targetSess == token.sess {
828+ if targetSess == effect.sess {
837829 return nil
838830 }
839831
832+ capability := effect.capability()
840833 transition, err := d.transitionAttachment(attachmentTransitionRequest{
841- source: token.sess, target: targetSess, next: token.ac,
834+ source: effect.sess, target: targetSess, next: effect.ac,
842835
843- expectedTransport: token.transport, sourceToken: &token, action: action,
836+ expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: action,
844837 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
845838 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
846839 })
847840 if err != nil {
848841 // Losing the exact source role is a benign stale action, not a notice for
849842 // whichever attachment replaced the initiator.
850- if !token.attachmentCurrent() {
843+ if !capability.current() {
851844 return nil
852845 }
853846 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
854847 }
855848 if guard.closePicker {
856- fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
849+ fresh, admitted := effect.ac.beginAttachmentEffect(transition.published)
857850 if admitted {
858- d.closePicker(token.ac)
851+ d.closePicker(effect.ac)
859852 fresh.End()
860853 }
861854 }
@@ -876,21 +869,21 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
876869 // intent captures the exact initiating capability before admission is released;
877870 // every active, stopped, and same-session target then uses transitionAttachment
878871 // for frozen, atomic source-token preflight.
879-func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
880- if token.sess == nil || token.ac == nil || token.effect == nil {
872+func (d *Daemon) switchToTargetForAttachment(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, action string) error {
873+ if !effect.current() || effect.sess == nil || effect.ac == nil {
881874 return nil
882875 }
883876 if target.RemoteTarget != nil || target.RemoteKey != nil {
884- return d.sendRemoteAttachTargetForAttachment(token, target, guard, action)
877+ return d.sendRemoteAttachTargetForAttachment(effect, target, guard, action)
885878 }
886- return d.sendLocalAttachTargetForAttachment(token, target, guard, action)
879+ return d.sendLocalAttachTargetForAttachment(effect, target, guard, action)
887880 }
888881
889882 // sendLocalAttachTargetForAttachment offers an endpoint-empty, exact local
890883 // target on the current authenticated connection. A current client confirms it
891884 // with MsgSamePeerSwitchRequest; an interrupted or older client retains the
892885 // existing close-and-redial fallback without any daemon-origin inference.
893-func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
886+func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, action string) error {
894887 d.mu.Lock()
895888 var targetSess *session
896889 var sessionName string
@@ -915,13 +908,13 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
915908 targetSess.mu.Unlock()
916909 }
917910 d.mu.Unlock()
918- if !matches || !token.attachmentCurrent() {
911+ if !matches || !effect.current() {
919912 return errAttachmentTransition
920913 }
921914 if targetSess != nil && target.TabID != "" {
922915 // An explicit tab row is a direct user choice, not route memory; retain
923916 // the established daemon-side transition so it opens exactly that tab.
924- return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
917+ return d.switchToTargetGuardedForAttachment(effect.sess, effect.ac, target, guard, effect, action)
925918 }
926919
927920 // An active session row with no explicit non-default tab is on the
@@ -943,15 +936,15 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
943936 // same-peer transition keeps the attachment and its namespace; the target
944937 // scene diff deletes and replaces the source session's placements.
945938 if !samePeerEligible {
946- if err := d.cleanupAttachmentOutput(token.ac); err != nil {
939+ if err := d.cleanupAttachmentOutput(effect.ac); err != nil {
947940 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't clean up attachment output before local handoff", err)
948941 }
949942 } else {
950- token.ac.offerSamePeerTarget(*exactTarget)
943+ effect.ac.offerSamePeerTarget(*exactTarget)
951944 }
952- if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
945+ if err := effect.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
953946 if samePeerEligible {
954- token.ac.clearSamePeerOffer()
947+ effect.ac.clearSamePeerOffer()
955948 }
956949 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't offer local session switch", err)
957950 }
@@ -961,10 +954,10 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
961954 // Keep the source link open until the client receives the ordered cleanup
962955 // and handoff frames, then let the client's close drive ordinary parking.
963956 if guard.closePicker {
964- d.closePicker(token.ac)
957+ d.closePicker(effect.ac)
965958 }
966- token.effect.bindActionEnd(d, "detach")
967- token.effect.End()
959+ effect.bindActionEnd(d, "detach")
960+ effect.End()
968961 }
969962 return nil
970963 }
@@ -973,10 +966,10 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
973966 // endpoint to the thin client. The daemon owns no remote session shadow: after
974967 // the target is sent, the local attachment is detached and the client opens a
975968 // fresh connection to the owning daemon.
976-func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, _ string) error {
969+func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, _ string) error {
977970 failUnavailable := func() error {
978- if token.attachmentCurrent() {
979- d.notifyRemotePickerUnavailable(token.sess, target)
971+ if effect.current() {
972+ d.notifyRemotePickerUnavailable(effect.sess, target)
980973 }
981974 return errAttachmentTransition
982975 }
@@ -999,13 +992,13 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionT
999992 if payload == nil {
1000993 return failUnavailable()
1001994 }
1002- if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
995+ if err := effect.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
1003996 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)
1004997 }
1005998 if guard.closePicker {
1006- d.closePicker(token.ac)
999+ d.closePicker(effect.ac)
10071000 }
1008- d.clientGoneForAttachment(token, false)
1001+ d.clientGoneForAttachment(effect, false)
10091002 return nil
10101003 }
10111004
@@ -1054,14 +1047,19 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target
10541047 return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")
10551048 }
10561049
1057-func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) error {
1050+func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceEffect *attachmentEffect, action string) error {
1051+ var sourceCapability *attachmentCapability
1052+ if sourceEffect != nil {
1053+ capability := sourceEffect.capability()
1054+ sourceCapability = &capability
1055+ }
10581056 if target.Name != "" {
10591057 ctx := d.serveCtx
10601058 if ctx == nil {
10611059 ctx = context.Background()
10621060 }
10631061 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
1064- if sourceToken != nil && !sourceToken.attachmentCurrent() {
1062+ if sourceCapability != nil && !sourceCapability.current() {
10651063 return errAttachmentTransition
10661064 }
10671065 d.invalidateRender(from, ac, true, "picker.go")
@@ -1080,22 +1078,22 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
10801078 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
10811079 if ok {
10821080 if isStopped {
1083- targetSess, transition, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, sourceToken, guard, action)
1081+ targetSess, transition, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, sourceEffect, guard, action)
10841082 } else {
10851083 // The snapshot ID may describe the stopped representation. The
10861084 // locked name/lifecycle resolver chose this active incarnation.
10871085 resolvedTarget := target
10881086 resolvedTarget.Session = active.id
1089- targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)
1087+ targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceEffect, action)
10901088 }
10911089 }
10921090 } else if active := d.sessions[target.Session]; active != nil {
1093- targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)
1091+ targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceEffect, action)
10941092 }
10951093 d.mu.Unlock()
10961094
10971095 if !switched {
1098- if sourceToken != nil && !sourceToken.attachmentCurrent() {
1096+ if sourceCapability != nil && !sourceCapability.current() {
10991097 return errAttachmentTransition
11001098 }
11011099 if guard.expectedSource != nil {
@@ -1105,7 +1103,7 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
11051103 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
11061104 }
11071105 if guard.closePicker {
1108- if sourceToken != nil {
1106+ if sourceEffect != nil {
11091107 fresh, admitted := ac.beginAttachmentEffect(transition.published)
11101108 if admitted {
11111109 d.closePicker(ac)
@@ -1117,7 +1115,7 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
11171115 }
11181116 d.deferAttachmentTransitionCleanups(transition)
11191117 if targetSess == from {
1120- if sourceToken != nil {
1118+ if sourceEffect != nil {
11211119 fresh, admitted := ac.beginAttachmentEffect(transition.published)
11221120 if admitted {
11231121 d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)
@@ -1156,12 +1154,17 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
11561154
11571155 // switchToActiveTargetLocked commits an active target handoff through the
11581156 // centralized attachment transition. Caller holds d.mu.
1159-func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) (*session, attachmentTransitionResult, bool) {
1157+func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceEffect *attachmentEffect, action string) (*session, attachmentTransitionResult, bool) {
1158+ var sourceCapability *attachmentCapability
1159+ if sourceEffect != nil {
1160+ capability := sourceEffect.capability()
1161+ sourceCapability = &capability
1162+ }
11601163 if d.sessions[target.Session] != targetSess {
11611164 return nil, attachmentTransitionResult{}, false
11621165 }
11631166 if targetSess == from {
1164- if sourceToken == nil {
1167+ if sourceEffect == nil {
11651168 targetSess.mu.Lock()
11661169 defer targetSess.mu.Unlock()
11671170 _, attached := targetSess.attachments[ac]
@@ -1177,8 +1180,9 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
11771180 transition, err := d.transitionAttachment(attachmentTransitionRequest{
11781181 source: from, target: targetSess, next: ac,
11791182
1180- expectedTransport: sourceToken.transport,
1181- sourceToken: sourceToken,
1183+ expectedTransport: sourceCapability.transport,
1184+ sourceCapability: sourceCapability,
1185+ sourceEffect: sourceEffect,
11821186 action: action,
11831187 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
11841188 activateTargetTab: true,
@@ -1215,8 +1219,8 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
12151219 // d.mu and revalidates both registered lifecycles for publication.
12161220 d.mu.Unlock()
12171221 expectedTransport := ac.transportSnapshot()
1218- if sourceToken != nil {
1219- expectedTransport = sourceToken.transport
1222+ if sourceCapability != nil {
1223+ expectedTransport = sourceCapability.transport
12201224 }
12211225 transition, err := d.transitionAttachment(attachmentTransitionRequest{
12221226 source: from,
@@ -1224,7 +1228,8 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
12241228 next: ac,
12251229
12261230 expectedTransport: expectedTransport,
1227- sourceToken: sourceToken,
1231+ sourceCapability: sourceCapability,
1232+ sourceEffect: sourceEffect,
12281233 action: action,
12291234 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
12301235 expectedSourceTab: guard.expectedSource,
@@ -1244,7 +1249,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
12441249 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
12451250 // the handoff while d.mu is held. Creation failure leaves the source client
12461251 // and stopped record untouched.
1247-func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped inactiveSession, sourceToken *attachmentConnectionToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
1252+func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped inactiveSession, sourceEffect *attachmentEffect, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
12481253 if stopped.broken() {
12491254 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
12501255 }
@@ -1252,12 +1257,13 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
12521257 return nil, attachmentTransitionResult{}, false, errAttachmentTransition
12531258 }
12541259
1255- if sourceToken != nil {
1260+ if sourceEffect != nil {
1261+ sourceCapability := sourceEffect.capability()
12561262 var targetSess *session
12571263 d.mu.Unlock()
12581264 transition, err := d.transitionAttachment(attachmentTransitionRequest{
12591265 source: from, next: ac,
1260- expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
1266+ expectedTransport: sourceCapability.transport, sourceCapability: &sourceCapability, sourceEffect: sourceEffect, action: action,
12611267 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
12621268 createTargetLocked: func() (*session, error) {
12631269 current, ok := d.inactive[target.Name]
@@ -1359,19 +1365,20 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
13591365 return true
13601366 }
13611367
1362-func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attachmentConnectionToken) error {
1363- if token.ac == nil {
1368+func (d *Daemon) killPickerTargetForAttachment(target picker.Target, effect *attachmentEffect) error {
1369+ if effect == nil || effect.ac == nil {
13641370 return d.killPickerTarget(target)
13651371 }
13661372 if target.RemoteKey != nil {
13671373 return nil
13681374 }
1375+ capability := effect.capability()
13691376 if target.Stopped {
1370- token.effect.bindActionEnd(d, "picker-delete")
1371- token.effect.End()
1372- frozen := freezeAttachmentEffectGates(token.ac)
1377+ effect.bindActionEnd(d, "picker-delete")
1378+ effect.End()
1379+ frozen := freezeAttachmentEffectGates(effect.ac)
13731380 defer frozen.unfreeze()
1374- if !d.sourceAttachmentTokenCurrentFrozen(token) {
1381+ if !d.sourceAttachmentCapabilityCurrentFrozen(capability) {
13751382 return nil
13761383 }
13771384 return d.killPickerTarget(target)
@@ -1382,7 +1389,7 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attac
13821389 if targetSess == nil {
13831390 return nil
13841391 }
1385- return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
1392+ return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, effect, "picker-delete")
13861393 }
13871394
13881395 func (d *Daemon) killPickerTarget(target picker.Target) error {
M internal/usecase/daemon/picker_test.go +5 -6
@@ -900,13 +900,12 @@ func TestPickerNavigationBackSendFailureKeepsPickerOpen(t *testing.T) {
900900 ac.overlays.pickerMu.Unlock()
901901
902902 rc := d.attachCoordinator(sess, nil, ac, true)
903- token := sess.attachmentToken(ac, tr)
903+ token := sess.captureAttachmentCapability(ac, tr)
904904 token.lease = rc.attachmentLease(ac)
905- ac.publishAttachmentCapability(token)
905+ ac.installTestAttachmentCapability(token)
906906 effect, admitted := ac.beginAttachmentEffect(token)
907907 require.True(t, admitted)
908908 defer effect.End()
909- token.effect = effect
910909
911910 d.handlePickerInput(ac, []byte("\r"), effect)
912911 require.True(t, ac.overlays.pickerActive(), "failed navigation send must leave the picker open")
@@ -1063,7 +1062,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
10631062 case <-time.After(2 * time.Second):
10641063 t.Fatal("session switch waited for in-flight transport send")
10651064 }
1066- require.True(t, result.published.attachmentCurrent())
1065+ require.True(t, result.published.current())
10671066 select {
10681067 case <-handoffAtSendMu:
10691068 t.Fatal("output rebase ran during architecture publication")
@@ -1584,9 +1583,9 @@ func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testi
15841583 require.True(t, ok)
15851584 require.Equal(t, target.id, selected.Session)
15861585
1587- token := current.attachmentToken(ac, ac.transport())
1586+ token := current.captureAttachmentCapability(ac, ac.transport())
15881587 token.lease = current.renderCoordinator().attachmentLease(ac)
1589- ac.publishAttachmentCapability(token)
1588+ ac.installTestAttachmentCapability(token)
15901589 effect, admitted := ac.beginAttachmentEffect(token)
15911590 require.True(t, admitted)
15921591 d.handlePickerInput(ac, []byte("x"), effect)
M internal/usecase/daemon/prompt.go +7 -7
@@ -23,7 +23,7 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s
2323 d.invalidateRender(sess, ac, true, "prompt.go")
2424 }
2525
26-func (d *Daemon) enterTransitionPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string, attachmentConnectionToken) error) {
26+func (d *Daemon) enterTransitionPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string, *attachmentEffect) error) {
2727 d.closePrompt(ac)
2828 ac.overlays.promptMu.Lock()
2929 ac.overlays.prompt = promptui.New(title, initial)
@@ -50,7 +50,7 @@ func promptValidationError(err error) bool {
5050 errors.Is(err, errSessionNameInUse)
5151 }
5252
53-func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
53+func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
5454 sess := ac.currentAttachmentSession()
5555 if sess == nil {
5656 return
@@ -66,7 +66,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
6666 changed := false
6767 exit := false
6868 var submit func(string) error
69- var transitionSubmit func(string, attachmentConnectionToken) error
69+ var transitionSubmit func(string, *attachmentEffect) error
7070 var submittedPrompt *promptui.Model
7171 var value string
7272
@@ -94,11 +94,11 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
9494 if submit != nil || transitionSubmit != nil {
9595 var err error
9696 if transitionSubmit != nil {
97- var token attachmentConnectionToken
98- if len(effects) != 0 && effects[0] != nil {
99- token = effects[0].connectionToken()
97+ var effect *attachmentEffect
98+ if len(effects) != 0 {
99+ effect = effects[0]
100100 }
101- err = transitionSubmit(value, token)
101+ err = transitionSubmit(value, effect)
102102 } else {
103103 err = submit(value)
104104 }
M internal/usecase/daemon/recent_route_snapshot_test.go +4 -5
@@ -64,8 +64,8 @@ func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) {
6464
6565 func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) {
6666 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
67- token := sess.attachmentToken(ac, ac.transport())
68- ac.publishAttachmentCapability(token)
67+ token := sess.captureAttachmentCapability(ac, ac.transport())
68+ ac.installTestAttachmentCapability(token)
6969 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
7070
7171 payload, err := ports.MarshalRecentRouteSnapshot(ports.RecentRouteSnapshot{Generation: 2})
@@ -93,13 +93,12 @@ func TestPaletteRecentRouteSelectionSendsTypedClientAction(t *testing.T) {
9393 transport := &closeTrackingTransport{}
9494 ac.replaceTransport(transport)
9595 rc := d.attachCoordinator(source, nil, ac, true)
96- token := source.attachmentToken(ac, transport)
96+ token := source.captureAttachmentCapability(ac, transport)
9797 token.lease = rc.attachmentLease(ac)
98- ac.publishAttachmentCapability(token)
98+ ac.installTestAttachmentCapability(token)
9999 effect, admitted := ac.beginAttachmentEffect(token)
100100 require.True(t, admitted)
101101 defer effect.End()
102- token.effect = effect
103102
104103 exec := paletteExec{
105104 d: d, sess: source, attachment: source, ac: ac,
M internal/usecase/daemon/remote_attach.go +13 -13
@@ -102,11 +102,11 @@ func (d *Daemon) resumeRemoteInactiveSessionLocked(target domain.RemoteSessionTa
102102 return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, geometry, env, &expected, validate, expected.tabNames)
103103 }
104104
105-func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {
105+func (d *Daemon) sendNavigationActionForAttachment(effect *attachmentEffect, action ports.NavigationAction) error {
106106 directive := ports.NavigationDirective{Action: action}
107107 armed := false
108108 if action == ports.NavigationOpenHomePicker {
109- leaseID, err := d.armParkedRoute(token)
109+ leaseID, err := d.armParkedRoute(effect)
110110 if err != nil {
111111 return err
112112 }
@@ -115,7 +115,7 @@ func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionTok
115115 }
116116 rollback := func() {
117117 if armed {
118- token.ac.clearParkedRoute()
118+ effect.ac.clearParkedRoute()
119119 }
120120 }
121121 payload := ports.MarshalNavigationDirective(directive)
@@ -123,36 +123,36 @@ func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionTok
123123 rollback()
124124 return errAttachmentTransition
125125 }
126- if err := token.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload}); err != nil {
126+ if err := effect.sendControl(ports.Frame{Type: ports.MsgNavigationAction, Payload: payload}); err != nil {
127127 rollback()
128128 return err
129129 }
130130 return nil
131131 }
132132
133-func (d *Daemon) sendRecentRouteNavigationActionForAttachment(token attachmentConnectionToken, action ports.RouteNavigationAction) error {
133+func (d *Daemon) sendRecentRouteNavigationActionForAttachment(effect *attachmentEffect, action ports.RouteNavigationAction) error {
134134 payload, err := ports.MarshalRouteNavigationAction(action)
135135 if err != nil {
136136 return errAttachmentTransition
137137 }
138- return token.sendControl(ports.Frame{Type: ports.MsgNavigateRecentRoute, Payload: payload})
138+ return effect.sendControl(ports.Frame{Type: ports.MsgNavigateRecentRoute, Payload: payload})
139139 }
140140
141-func (d *Daemon) sendCommittedRouteIdentityForAttachment(token attachmentConnectionToken) error {
142- if token.sess == nil {
141+func (d *Daemon) sendCommittedRouteIdentityForAttachment(effect *attachmentEffect) error {
142+ if !effect.current() || effect.sess == nil {
143143 return errAttachmentTransition
144144 }
145- token.sess.mu.Lock()
145+ effect.sess.mu.Lock()
146146 identity := ports.CommittedRouteIdentity{
147- Target: ports.ExactSessionTarget{LifecycleID: token.sess.incarnation, SessionName: token.sess.name},
148- Ephemeral: token.sess.ephemeral,
147+ Target: ports.ExactSessionTarget{LifecycleID: effect.sess.incarnation, SessionName: effect.sess.name},
148+ Ephemeral: effect.sess.ephemeral,
149149 }
150- token.sess.mu.Unlock()
150+ effect.sess.mu.Unlock()
151151 payload, err := ports.MarshalCommittedRouteIdentity(identity)
152152 if err != nil {
153153 return errAttachmentTransition
154154 }
155- return token.sendControl(ports.Frame{Type: ports.MsgCommittedRouteIdentity, Payload: payload})
155+ return effect.sendControl(ports.Frame{Type: ports.MsgCommittedRouteIdentity, Payload: payload})
156156 }
157157
158158 func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {
M internal/usecase/daemon/remote_attach_test.go +10 -15
@@ -22,13 +22,12 @@ func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) {
2222 source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source")
2323 target, _, _ := addRemoteRefreshPickerOwner(t, d, "target")
2424 target.incarnation = remoteLifecycleForTest()
25- token := source.attachmentToken(ac, ac.transport())
25+ token := source.captureAttachmentCapability(ac, ac.transport())
2626 effect, admitted := ac.beginAttachmentEffect(token)
2727 require.True(t, admitted)
28- token.effect = effect
2928 defer effect.End()
3029
31- require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{Session: target.id}, sessionHandoffGuard{allowSamePeer: true}, "picker-select"))
30+ require.NoError(t, d.switchToTargetForAttachment(effect, picker.Target{Session: target.id}, sessionHandoffGuard{allowSamePeer: true}, "picker-select"))
3231 frame := receiveRemotePicker(t, sends, "local attach target")
3332 got, err := ports.UnmarshalAttachTarget(frame.Payload)
3433 require.NoError(t, err)
@@ -51,13 +50,12 @@ func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) {
5150 graphics.mayHaveEmitted = true
5251 ac.output.graphicsOutput = graphics
5352
54- token := source.attachmentToken(ac, ac.transport())
53+ token := source.captureAttachmentCapability(ac, ac.transport())
5554 effect, admitted := ac.beginAttachmentEffect(token)
5655 require.True(t, admitted)
57- token.effect = effect
5856 defer effect.End()
5957
60- require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{
58+ require.NoError(t, d.switchToTargetForAttachment(effect, picker.Target{
6159 Name: "stopped", Incarnation: lifecycle,
6260 }, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select"))
6361
@@ -85,15 +83,14 @@ func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
8583 d.remoteCatalog.status["arch"] = remoteHostFresh
8684 d.remoteCatalog.mu.Unlock()
8785 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
88- token := sess.attachmentToken(ac, ac.transport())
86+ token := sess.captureAttachmentCapability(ac, ac.transport())
8987 effect, admitted := ac.beginAttachmentEffect(token)
9088 require.True(t, admitted)
91- token.effect = effect
9289 defer effect.End()
9390 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
9491 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
9592 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
96- require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
93+ require.NoError(t, d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
9794 frame := receiveRemotePicker(t, sends, "rich attach target")
9895 got, err := ports.UnmarshalAttachTarget(frame.Payload)
9996 require.NoError(t, err)
@@ -128,10 +125,9 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) {
128125 d.remoteCatalog.status["arch"] = remoteHostFresh
129126 d.remoteCatalog.mu.Unlock()
130127 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
131- token := sess.attachmentToken(ac, ac.transport())
128+ token := sess.captureAttachmentCapability(ac, ac.transport())
132129 effect, admitted := ac.beginAttachmentEffect(token)
133130 require.True(t, admitted)
134- token.effect = effect
135131 defer effect.End()
136132
137133 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
@@ -139,7 +135,7 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) {
139135 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
140136 test.mutate(&target)
141137
142- require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
138+ require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
143139 for {
144140 select {
145141 case frame := <-sends:
@@ -166,15 +162,14 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
166162 d.remoteCatalog.status["arch"] = remoteHostFresh
167163 d.remoteCatalog.mu.Unlock()
168164 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
169- token := sess.attachmentToken(ac, ac.transport())
165+ token := sess.captureAttachmentCapability(ac, ac.transport())
170166 effect, admitted := ac.beginAttachmentEffect(token)
171167 require.True(t, admitted)
172- token.effect = effect
173168 defer effect.End()
174169 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
175170 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
176171 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
177- require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
172+ require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
178173 for {
179174 select {
180175 case frame := <-sends:
M internal/usecase/daemon/remote_picker_test.go +11 -15
@@ -686,14 +686,13 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
686686 d.remoteCatalog.status["arch"] = remoteHostFresh
687687 d.remoteCatalog.mu.Unlock()
688688 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
689- token := sess.attachmentToken(ac, ac.transport())
689+ token := sess.captureAttachmentCapability(ac, ac.transport())
690690 effect, admitted := ac.beginAttachmentEffect(token)
691691 require.True(t, admitted)
692- token.effect = effect
693692 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
694693 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
695694 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
696- require.NoError(t, d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
695+ require.NoError(t, d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{closePicker: true}, "picker-select"))
697696
698697 frame := receiveRemotePicker(t, sends, "attach target")
699698 require.Equal(t, ports.MsgAttachTarget, frame.Type)
@@ -742,11 +741,10 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
742741 local.remoteCatalog.status["arch"] = remoteHostFresh
743742 local.remoteCatalog.mu.Unlock()
744743 localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local")
745- token := localSession.attachmentToken(localAttachment, localAttachment.transport())
744+ token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport())
746745 effect, admitted := localAttachment.beginAttachmentEffect(token)
747746 require.True(t, admitted)
748- token.effect = effect
749- require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, sessionHandoffGuard{closePicker: true}, "picker-select"))
747+ require.NoError(t, local.sendRemoteAttachTargetForAttachment(effect, selected, sessionHandoffGuard{closePicker: true}, "picker-select"))
750748
751749 frame := receiveRemotePicker(t, sends, "stopped remote target")
752750 handoff, err := ports.UnmarshalAttachTarget(frame.Payload)
@@ -814,11 +812,10 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing
814812 local.remoteCatalog.status["arch"] = remoteHostFresh
815813 local.remoteCatalog.mu.Unlock()
816814 localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local")
817- token := localSession.attachmentToken(localAttachment, localAttachment.transport())
815+ token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport())
818816 effect, admitted := localAttachment.beginAttachmentEffect(token)
819817 require.True(t, admitted)
820- token.effect = effect
821- require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, sessionHandoffGuard{closePicker: true}, "picker-select"))
818+ require.NoError(t, local.sendRemoteAttachTargetForAttachment(effect, selected, sessionHandoffGuard{closePicker: true}, "picker-select"))
822819
823820 frame := receiveRemotePicker(t, sends, "stopped remote target without tab metadata")
824821 handoff, err := ports.UnmarshalAttachTarget(frame.Payload)
@@ -858,11 +855,11 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
858855 if tt.action == ports.NavigationOpenHomePicker {
859856 ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
860857 }
861- token := sess.attachmentToken(ac, ac.transport())
858+ token := sess.captureAttachmentCapability(ac, ac.transport())
862859 effect, admitted := ac.beginAttachmentEffect(token)
863860 require.True(t, admitted)
864- token.effect = effect
865- require.NoError(t, d.sendNavigationActionForAttachment(token, tt.action))
861+ defer effect.End()
862+ require.NoError(t, d.sendNavigationActionForAttachment(effect, tt.action))
866863 frame := receiveRemotePicker(t, sends, "navigation action")
867864 require.Equal(t, ports.MsgNavigationAction, frame.Type)
868865 directive, err := ports.UnmarshalNavigationDirective(frame.Payload)
@@ -901,15 +898,14 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
901898
902899 gone := make(chan struct{})
903900 d.afterClientGoneDetach = func() { close(gone) }
904- token := sess.attachmentToken(ac, tr)
901+ token := sess.captureAttachmentCapability(ac, tr)
905902 effect, admitted := ac.beginAttachmentEffect(token)
906903 require.True(t, admitted)
907- token.effect = effect
908904 defer effect.End()
909905 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
910906 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
911907 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
912- err := d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{closePicker: true}, "picker-select")
908+ err := d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{closePicker: true}, "picker-select")
913909 var userErr *domain.UserError
914910 require.ErrorAs(t, err, &userErr)
915911 require.Equal(t, "couldn't attach to remote session", userErr.Msg)
M internal/usecase/daemon/remote_preview_cache_test.go +2 -3
@@ -457,12 +457,11 @@ func TestRemotePickerPreviewRefreshesAfterAttachmentResize(t *testing.T) {
457457 client.release = nil
458458 client.startedSignal = false
459459 client.mu.Unlock()
460- token := sess.attachmentToken(ac, ac.transport())
460+ token := sess.captureAttachmentCapability(ac, ac.transport())
461461 effect, admitted := ac.beginAttachmentEffect(token)
462462 require.True(t, admitted)
463- token.effect = effect
464463 defer effect.End()
465- require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 100, Rows: 30}))
464+ require.True(t, d.resizeAttachmentForLease(effect, domain.Size{Cols: 100, Rows: 30}))
466465 <-secondStarted
467466 wantWidth, wantHeight := remotePickerPreviewSize(domain.Size{Cols: 100, Rows: 30})
468467 gotWidth, gotHeight := client.LastSize()
M internal/usecase/daemon/render.go +19 -18
@@ -294,7 +294,7 @@ func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid
294294 type runtimeMarkBatch struct {
295295 observer ports.RuntimeObserver
296296 marks []ports.RuntimeMark
297- attachmentEffect *attachmentEffectTicket
297+ attachmentEffect *attachmentEffect
298298 }
299299
300300 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
@@ -338,13 +338,15 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
338338 sess := entry
339339 local := sess != nil
340340 marks := d.newRuntimeMarkBatch()
341+ var paintEffect *attachmentEffect
341342 if lease != nil {
342- token := attachmentToken(entry, ac, ac.transport())
343+ token := captureAttachmentCapability(entry, ac, ac.transport())
343344 token.lease = lease
344345 ticket, admitted := ac.beginAttachmentEffect(token)
345346 if !admitted {
346347 return paintRejected
347348 }
349+ paintEffect = ticket
348350 marks.attachmentEffect = ticket
349351 defer ticket.End()
350352 }
@@ -357,25 +359,24 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
357359 }
358360
359361 ac.sendMu.Lock()
360- // sendMu is the attachment ownership boundary. Check the session's
361- // published identity while holding it so a deadline captured before an
362- // attach/replace cannot emit on either the old or new output chain.
363- entry.core().mu.Lock()
364- _, owned := entry.core().attachments[ac]
365- entry.core().mu.Unlock()
366- if !owned || ac.currentAttachmentSession() != entry || ac.parkedRouteOutput.Load() {
367- ac.sendMu.Unlock()
368- return paintRejected
369- }
370- if ac.parkedRouteFullPending.Load() {
371- reset = true
372- }
373- if lease != nil {
374- rc := attachmentRenderCoordinator(entry)
375- if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) {
362+ if paintEffect != nil {
363+ if !paintEffect.current() || ac.parkedRouteOutput.Load() {
376364 ac.sendMu.Unlock()
377365 return paintRejected
378366 }
367+ } else {
368+ // Direct/headless rendering has no Attachment effect and retains its
369+ // membership-only fixture path.
370+ entry.core().mu.Lock()
371+ _, owned := entry.core().attachments[ac]
372+ entry.core().mu.Unlock()
373+ if !owned || ac.currentAttachmentSession() != entry || ac.parkedRouteOutput.Load() {
374+ ac.sendMu.Unlock()
375+ return paintRejected
376+ }
377+ }
378+ if ac.parkedRouteFullPending.Load() {
379+ reset = true
379380 }
380381 // Capacity is checked before any destructive capture. Refresh the atomic
381382 // readiness snapshot while sendMu is held so direct test/setup mutations of
M internal/usecase/daemon/render_pipeline.go +16 -9
@@ -424,16 +424,23 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
424424 ActiveTabID: state.view.tabID,
425425 }
426426 entry.core().mu.Unlock()
427- if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
428- ac.sendMu.Unlock()
429- return false
430- }
431- if state.lease != nil {
432- rc := attachmentRenderCoordinator(entry)
433- if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
427+ if marks.attachmentEffect != nil {
428+ if !marks.attachmentEffect.current() || state.attachment != ac {
429+ ac.sendMu.Unlock()
430+ return false
431+ }
432+ } else {
433+ if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
434434 ac.sendMu.Unlock()
435435 return false
436436 }
437+ if state.lease != nil {
438+ rc := attachmentRenderCoordinator(entry)
439+ if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
440+ ac.sendMu.Unlock()
441+ return false
442+ }
443+ }
437444 }
438445 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
439446 prepared, err := ac.output.prepareFrame(d, state, composed.frame, composed.damage, composed.reset, composed.cursor)
@@ -581,8 +588,8 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
581588 // Capture the exact admitted capability, including its coordinator
582589 // lease, before End permits a new attachment publication. Reserve
583590 // cleanup accounting before End so terminal Wait cannot race a later Add.
584- token := marks.attachmentEffect.connectionToken()
585- launchCleanup := d.reserveAttachmentSendErrorCleanup(token, sendTr)
591+ capability := marks.attachmentEffect.capability()
592+ launchCleanup := d.reserveAttachmentSendErrorCleanup(capability, sendTr)
586593 marks.attachmentEffect.End()
587594 launchCleanup()
588595 }
M internal/usecase/daemon/render_pipeline_test.go +4 -4
@@ -686,12 +686,12 @@ func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
686686
687687 func TestEmitFrameSkipsTransportSendWhenAttachmentEffectFenceRejects(t *testing.T) {
688688 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
689- token := sess.attachmentToken(ac, ac.transport())
689+ token := sess.captureAttachmentCapability(ac, ac.transport())
690690 effect, admitted := ac.beginAttachmentEffect(token)
691691 require.True(t, admitted)
692- ac.attachmentEffects.mu.Lock()
693- ac.attachmentEffects.capability.transport = transportSnapshot{}
694- ac.attachmentEffects.mu.Unlock()
692+ ac.lifecycle.mu.Lock()
693+ ac.lifecycle.capability.transport = transportSnapshot{}
694+ ac.lifecycle.mu.Unlock()
695695
696696 state := cacheState("stale", 1)
697697 state.attachment = ac
M internal/usecase/daemon/resize_publication.go +8 -14
@@ -11,7 +11,7 @@ type resizeCommitPublication struct {
1111 attachment *attachedClient
1212 lease *attachmentLease
1313 epoch uint64
14- connection attachmentConnectionToken
14+ effect *attachmentEffect
1515 coordinator *renderCoordinator
1616 observer ports.RuntimeObserver
1717 mark ports.RuntimeMark
@@ -24,19 +24,13 @@ func (p resizeCommitPublication) current() bool {
2424 if p.attachment == nil {
2525 return true
2626 }
27- // Attached resize publication is admitted only through a live attachment effect ticket.
28- if p.connection.effect == nil {
27+ // Attached resize publication retains the exact admitted effect; lifecycle
28+ // identity cannot change until that effect ends.
29+ if !p.effect.current() || p.effect.sess != p.session ||
30+ p.effect.ac != p.attachment || p.effect.lease != p.lease || p.coordinator == nil {
2931 return false
3032 }
31- if p.connection.effect.ended.Load() || p.connection.sess != p.session ||
32- p.connection.ac != p.attachment || p.connection.lease != p.lease ||
33- p.connection.generation != p.attachment.connectionGeneration.Load() ||
34- !p.attachment.transportSnapshotCurrent(p.connection.transport) ||
35- p.attachment.currentSession() != p.session || p.coordinator == nil {
36- return false
37- }
38- registered := attachmentRegistered(p.session, p.attachment)
39- return registered && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
33+ return p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
4034 }
4135
4236 // emit performs observer work only after every resize-owner fence and
@@ -57,7 +51,7 @@ func (p resizeCommitPublication) emit() bool {
5751 // reservation with pane ownership. sendMu is acquired before resize fences, so
5852 // the canonical rule never acquires an attachment lock while a pane fence is
5953 // held. Observer callbacks run only after both lock families are released.
60-func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *attachmentEffectTicket, size domain.Size) bool {
54+func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *attachmentEffect, size domain.Size) bool {
6155 if ac != nil && ticket == nil {
6256 return false
6357 }
@@ -85,7 +79,7 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *
8579 mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),
8680 }
8781 if ticket != nil {
88- publication.connection = ticket.connectionToken()
82+ publication.effect = ticket
8983 publication.coordinator = sess.renderCoordinator()
9084 }
9185 if !publication.current() {
M internal/usecase/daemon/resume.go +18 -3
@@ -362,16 +362,32 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
362362 d.mu.Unlock()
363363 return false
364364 }
365+ d.mu.Unlock()
366+
367+ // A successful claim may already have published active membership. Freeze
368+ // and drain without architecture locks before restoring parked identity.
369+ frozen := freezeAttachmentEffectGates(ac)
370+ defer frozen.unfreeze()
371+
372+ d.mu.Lock()
373+ if d.parked[token] != parked || !parked.claimed || ac.resumeClaimToken != token {
374+ d.mu.Unlock()
375+ return false
376+ }
365377 parked.claimed = false
366378 ac.resumeClaimToken = 0
367379 ac.resumeToken = token
368380 ac.parked = true
369- ac.connectionGeneration.Add(1)
370381 sess := parked.sess
371382 if sess != nil {
372383 sess.mu.Lock()
373384 sess.unregisterAttachmentLocked(ac)
385+ ac.setSession(nil)
386+ ac.invalidateFrozenAttachmentCapability()
374387 sess.mu.Unlock()
388+ } else {
389+ ac.setSession(nil)
390+ ac.invalidateFrozenAttachmentCapability()
375391 }
376392 // Recreate the parked entry and its watcher instead of restoring a timer
377393 // that may already have fired while the claim held the old entry. This also
@@ -390,7 +406,6 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
390406 }
391407 d.parked[token] = rearmed
392408 captured := ac.transportSnapshot().transport
393- ac.setSession(nil)
394409 d.mu.Unlock()
395410 if sess != nil {
396411 d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
@@ -414,8 +429,8 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
414429 d.discardAttachmentOutputLocked(parked.ac)
415430 parked.ac.resumeToken = 0
416431 parked.ac.parked = false
417- parked.ac.connectionGeneration.Add(1)
418432 parked.ac.setSession(nil)
433+ parked.ac.invalidateRetiredAttachmentCapability()
419434 return parkedAttachmentRetirement{
420435 parked: parked,
421436 pickerGeneration: parked.pickerGeneration,
M internal/usecase/daemon/resume_test.go +2 -2
@@ -1150,7 +1150,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
11501150 token := ac.resumeToken
11511151 require.True(t, sess.detachIfCurrent(ac))
11521152 require.True(t, d.parkAttachment(sess, ac))
1153- generation := ac.connectionGeneration.Load()
1153+ generation := ac.lifecycle.generationValue()
11541154
11551155 newTr := &closeTrackingTransport{}
11561156 resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})
@@ -1158,7 +1158,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
11581158 require.True(t, ok)
11591159 require.Same(t, sess, resumedSess)
11601160 require.Same(t, ac, resumedAC)
1161- require.Greater(t, ac.connectionGeneration.Load(), generation, "resume must publish active ownership through the attachment transition")
1161+ require.Greater(t, ac.lifecycle.generationValue(), generation, "resume must publish active ownership through the attachment transition")
11621162
11631163 _ = ac.closeCapturedTransport(oldTr)
11641164 require.True(t, oldTr.Closed(), "old transport is closed")
M internal/usecase/daemon/resume_wake_incarnation_test.go +2 -2
@@ -88,7 +88,7 @@ func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testi
8888 }()
8989 <-wakeDispatched
9090
91- oldGeneration := ac.connectionGeneration.Load()
91+ oldGeneration := ac.lifecycle.generationValue()
9292 result, err := d.transitionAttachment(attachmentTransitionRequest{
9393 source: sess, target: sess, next: ac,
9494
@@ -96,7 +96,7 @@ func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testi
9696 })
9797 require.NoError(t, err)
9898 d.deferAttachmentTransitionCleanups(result)
99- require.Greater(t, ac.connectionGeneration.Load(), oldGeneration)
99+ require.Greater(t, ac.lifecycle.generationValue(), oldGeneration)
100100 freshLease := rc.attachmentLease(ac)
101101 require.NotSame(t, oldLease, freshLease)
102102
M internal/usecase/daemon/route_position_test.go +1 -1
@@ -89,7 +89,7 @@ func TestPaintPublishesChangedAttachmentRoutePosition(t *testing.T) {
8989 require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
9090 sess.repairAttachmentView(ac)
9191
92- require.True(t, d.firstPaintForTransition(sess.attachmentToken(ac, tr)))
92+ require.True(t, d.firstPaintForTransition(sess.captureAttachmentCapability(ac, tr)))
9393 sess.selectAttachmentTab(ac, "tab-2")
9494 d.paint(sess, ac, false, nil)
9595
M internal/usecase/daemon/same_peer_switch.go +28 -17
@@ -33,27 +33,29 @@ func (ac *attachedClient) consumeSamePeerOffer(target ports.ExactSessionTarget)
3333 // switchSamePeerForAttachment commits one client-confirmed endpoint-empty
3434 // target. The request never names an endpoint: its authority is the current
3535 // authenticated attachment plus the target's exact lifecycle identity.
36-func (d *Daemon) switchSamePeerForAttachment(token attachmentConnectionToken, request ports.SamePeerSwitchRequest) {
37- if err := request.Validate(); err != nil || token.sess == nil || token.ac == nil {
36+func (d *Daemon) switchSamePeerForAttachment(effect *attachmentEffect, request ports.SamePeerSwitchRequest) {
37+ if err := request.Validate(); err != nil || !effect.current() || effect.sess == nil || effect.ac == nil {
3838 return
3939 }
40- if !token.ac.consumeSamePeerOffer(request.Target) {
41- d.sendSamePeerSwitchFailure(token, request.RequestID, ports.SamePeerSwitchStaleTarget)
40+ if !effect.ac.consumeSamePeerOffer(request.Target) {
41+ d.sendSamePeerSwitchFailure(effect, request.RequestID, ports.SamePeerSwitchStaleTarget)
4242 return
4343 }
4444
4545 target, targetTabIndex, ok := d.samePeerTarget(request)
46- if !ok || target == token.sess {
47- d.sendSamePeerSwitchFailure(token, request.RequestID, ports.SamePeerSwitchStaleTarget)
46+ if !ok || target == effect.sess {
47+ d.sendSamePeerSwitchFailure(effect, request.RequestID, ports.SamePeerSwitchStaleTarget)
4848 return
4949 }
5050
51+ capability := effect.capability()
5152 transition, err := d.transitionAttachment(attachmentTransitionRequest{
52- source: token.sess,
53+ source: effect.sess,
5354 target: target,
54- next: token.ac,
55- expectedTransport: token.transport,
56- sourceToken: &token,
55+ next: effect.ac,
56+ expectedTransport: effect.transport,
57+ sourceCapability: &capability,
58+ sourceEffect: effect,
5759 action: "same-peer-switch",
5860 expectedTargetLifecycle: &attachmentLifecycleFence{
5961 name: request.Target.SessionName,
@@ -65,12 +67,12 @@ func (d *Daemon) switchSamePeerForAttachment(token attachmentConnectionToken, re
6567 ready: true,
6668 })
6769 if err != nil {
68- d.sendSamePeerSwitchFailure(token, request.RequestID, ports.SamePeerSwitchStaleTarget)
70+ d.sendSamePeerSwitchFailure(effect, request.RequestID, ports.SamePeerSwitchStaleTarget)
6971 return
7072 }
7173
72- if fresh, admitted := token.ac.beginAttachmentEffect(transition.published); admitted {
73- d.closePicker(token.ac)
74+ if fresh, admitted := effect.ac.beginAttachmentEffect(transition.published); admitted {
75+ d.closePicker(effect.ac)
7476 fresh.End()
7577 }
7678 d.touchMRU(target)
@@ -106,15 +108,24 @@ func (d *Daemon) samePeerTarget(request ports.SamePeerSwitchRequest) (*session,
106108 return target, tabIndex, true
107109 }
108110
109-func (d *Daemon) sendSamePeerSwitchFailure(token attachmentConnectionToken, requestID uint64, code ports.SamePeerSwitchFailureCode) {
110- if !token.attachmentCurrent() {
111+func (d *Daemon) sendSamePeerSwitchFailure(effect *attachmentEffect, requestID uint64, code ports.SamePeerSwitchFailureCode) {
112+ if effect == nil || effect.ac == nil {
111113 return
112114 }
115+ sender := effect
116+ if !sender.current() {
117+ var admitted bool
118+ sender, admitted = effect.ac.beginAttachmentEffect(effect.capability())
119+ if !admitted {
120+ return
121+ }
122+ defer sender.End()
123+ }
113124 payload, err := ports.MarshalSamePeerSwitchFailure(ports.SamePeerSwitchFailure{RequestID: requestID, Code: code})
114125 if err != nil {
115126 return
116127 }
117- if err := token.sendControl(ports.Frame{Type: ports.MsgSamePeerSwitchFailure, Payload: payload}); err != nil {
118- d.detachOnAttachmentSendError(token, token.transport.transport)
128+ if err := sender.sendControl(ports.Frame{Type: ports.MsgSamePeerSwitchFailure, Payload: payload}); err != nil {
129+ d.detachOnAttachmentSendError(sender.capability(), sender.transport.transport)
119130 }
120131 }
M internal/usecase/daemon/same_peer_switch_test.go +4 -6
@@ -70,15 +70,14 @@ func TestSamePeerSwitchTransitionsExactTargetAndPreferredTab(t *testing.T) {
7070 d.mu.Unlock()
7171 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
7272
73- token := source.attachmentToken(ac, ac.transport())
73+ token := source.captureAttachmentCapability(ac, ac.transport())
7474 effect, admitted := ac.beginAttachmentEffect(token)
7575 require.True(t, admitted)
76- token.effect = effect
7776 defer effect.End()
7877 requestTarget := ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"}
7978 ac.offerSamePeerTarget(requestTarget)
8079
81- d.switchSamePeerForAttachment(token, ports.SamePeerSwitchRequest{
80+ d.switchSamePeerForAttachment(effect, ports.SamePeerSwitchRequest{
8281 RequestID: 1,
8382 Target: requestTarget,
8483 PreferredTabID: domain.TabStableID(target.tabs[1].stableID),
@@ -109,13 +108,12 @@ func TestSamePeerSwitchRejectsStaleTargetWithoutMutation(t *testing.T) {
109108 defer releaseAll(releases)
110109 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
111110
112- token := source.attachmentToken(ac, ac.transport())
111+ token := source.captureAttachmentCapability(ac, ac.transport())
113112 effect, admitted := ac.beginAttachmentEffect(token)
114113 require.True(t, admitted)
115- token.effect = effect
116114 defer effect.End()
117115
118- d.switchSamePeerForAttachment(token, ports.SamePeerSwitchRequest{
116+ d.switchSamePeerForAttachment(effect, ports.SamePeerSwitchRequest{
119117 RequestID: 1,
120118 Target: ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{9}, SessionName: "missing"},
121119 })
M internal/usecase/daemon/session.go +45 -59
@@ -511,27 +511,28 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
511511
512512 // createSessionAndSwitchForAttachment creates no target until the initiating role
513513 // ticket has ended and the exact source capability is frozen and revalidated.
514-func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionToken, name string) error {
514+func (d *Daemon) createSessionAndSwitchForAttachment(effect *attachmentEffect, name string) error {
515515 if name == "" {
516516 return errSessionNameRequired
517517 }
518518 if err := domain.ValidateSessionName(name); err != nil {
519519 return err
520520 }
521- if token.sess == nil || token.ac == nil || token.effect == nil {
521+ if effect == nil || effect.sess == nil || effect.ac == nil {
522522 return errAttachmentTransition
523523 }
524- token.effect.bindActionEnd(d, "create-session")
525- token.effect.End()
524+ effect.bindActionEnd(d, "create-session")
525+ effect.End()
526+ capability := effect.capability()
526527
527528 var created *session
528529 transition, err := d.transitionAttachment(attachmentTransitionRequest{
529- source: token.sess, next: token.ac,
530+ source: capability.sess, next: capability.ac,
530531
531- expectedTransport: token.transport, sourceToken: &token, action: "create-session",
532+ expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-session",
532533 copySourceEnvironment: true, ready: true,
533534 createTargetLocked: func() (*session, error) {
534- source := token.sess
535+ source := capability.sess
535536 if source == nil {
536537 return nil, errAttachmentTransition
537538 }
@@ -545,7 +546,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
545546 cwd, env := source.cwd, copyEnvironment(source.env)
546547 source.mu.Unlock()
547548 var createErr error
548- created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.geometrySnapshot(), env)
549+ created, createErr = d.createSessionLockedWithMode(name, false, cwd, capability.ac.geometrySnapshot(), env)
549550 return created, createErr
550551 },
551552 })
@@ -557,7 +558,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
557558 }
558559
559560 d.touchMRU(created)
560- d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
561+ d.log.Info("client attached", "session", created.name, "resume", capability.ac.resumeCapable)
561562 d.deferAttachmentTransitionCleanups(transition)
562563 d.firstPaintForTransition(transition.published)
563564 return nil
@@ -599,29 +600,30 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie
599600 return nil
600601 }
601602
602-func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(token attachmentConnectionToken) error {
603- if token.sess == nil || token.ac == nil || token.effect == nil {
603+func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(effect *attachmentEffect) error {
604+ if effect == nil || effect.sess == nil || effect.ac == nil {
604605 return errAttachmentTransition
605606 }
606- token.effect.bindActionEnd(d, "create-ephemeral-session")
607- token.effect.End()
607+ effect.bindActionEnd(d, "create-ephemeral-session")
608+ effect.End()
609+ capability := effect.capability()
608610
609611 var created *session
610612 transition, err := d.transitionAttachment(attachmentTransitionRequest{
611- source: token.sess, next: token.ac,
612- expectedTransport: token.transport, sourceToken: &token, action: "create-ephemeral-session",
613+ source: capability.sess, next: capability.ac,
614+ expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-ephemeral-session",
613615 copySourceEnvironment: true, ready: true,
614616 createTargetLocked: func() (*session, error) {
615- if token.sess == nil || d.closing {
617+ if capability.sess == nil || d.closing {
616618 return nil, errAttachmentTransition
617619 }
618- source := token.sess
620+ source := capability.sess
619621 source.mu.Lock()
620622 cwd, env := source.cwd, copyEnvironment(source.env)
621623 source.mu.Unlock()
622624 name := d.allocEphemeralNameLocked()
623625 var createErr error
624- created, createErr = d.createSessionLockedWithMode(name, true, cwd, token.ac.geometrySnapshot(), env)
626+ created, createErr = d.createSessionLockedWithMode(name, true, cwd, capability.ac.geometrySnapshot(), env)
625627 return created, createErr
626628 },
627629 })
@@ -632,7 +634,7 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(token attachmentCo
632634 return err
633635 }
634636 d.touchMRU(created)
635- d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable)
637+ d.log.Info("client attached", "session", created.name, "resume", capability.ac.resumeCapable)
636638 d.deferAttachmentTransitionCleanups(transition)
637639 d.firstPaintForTransition(transition.published)
638640 return nil
@@ -855,17 +857,6 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
855857 go d.readPanePTY(p)
856858 }
857859
858-func (s *session) detachIfCurrent(ac *attachedClient) bool {
859- s.mu.Lock()
860- defer s.mu.Unlock()
861- if !attachmentRegisteredLocked(s, ac) || ac.currentAttachmentSession() != s {
862- return false
863- }
864- s.unregisterAttachmentLocked(ac)
865- ac.setSession(nil)
866- return true
867-}
868-
869860 // detachIfCurrent publishes terminal attachment invalidation through the attachment
870861 // gate. Freeze/drain happens before routing or session locks are acquired.
871862 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
@@ -892,7 +883,6 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
892883 }
893884 if current {
894885 sess.unregisterAttachmentLocked(ac)
895- ac.connectionGeneration.Add(1)
896886 ac.setSession(nil)
897887 ac.invalidateFrozenAttachmentCapability()
898888 }
@@ -904,11 +894,11 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
904894 // detachIfAttachmentCurrent invalidates only the exact attachment generation and transport
905895 // whose admitted send failed. A queued stale error cannot detach a later
906896 // attachment publication or a rebound transport on the same attachment object.
907-func (d *Daemon) detachIfAttachmentCurrent(token attachmentConnectionToken) bool {
897+func (d *Daemon) detachIfAttachmentCurrent(token attachmentCapability) bool {
908898 return d.detachIfAttachmentCurrentUntil(token, nil)
909899 }
910900
911-func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {
901+func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentCapability, done func() <-chan struct{}) bool {
912902 if token.sess == nil || token.ac == nil || token.transport.transport == nil {
913903 return false
914904 }
@@ -938,13 +928,9 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
938928 if coordinator != nil {
939929 coordinator.mu.Lock()
940930 }
941- req := attachmentTransitionRequest{
942- source: token.sess, next: token.ac, expectedTransport: token.transport,
943- }
944- current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
931+ current := coordinator != nil && token.currentInSessionAndLeaseLocked(token.sess, token.ac, coordinator)
945932 if current {
946933 unregisterAttachmentSessionLocked(token.sess, token.ac)
947- token.ac.connectionGeneration.Add(1)
948934 token.ac.setSession(nil)
949935 token.ac.invalidateFrozenAttachmentCapability()
950936 }
@@ -1059,7 +1045,7 @@ func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) {
10591045 if ac.deferRouteIdentityUntilSnapshot() {
10601046 continue
10611047 }
1062- token := sess.attachmentToken(ac, ac.transport())
1048+ token := sess.captureAttachmentCapability(ac, ac.transport())
10631049 if token.ac == nil {
10641050 continue
10651051 }
@@ -1067,11 +1053,11 @@ func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) {
10671053 if !admitted {
10681054 continue
10691055 }
1070- token = effect.connectionToken()
1071- err := d.sendCommittedRouteIdentityForAttachment(token)
1056+ err := d.sendCommittedRouteIdentityForAttachment(effect)
1057+ capability := effect.capability()
10721058 effect.End()
10731059 if err != nil {
1074- d.detachOnAttachmentSendError(token, token.transport.transport)
1060+ d.detachOnAttachmentSendError(capability, capability.transport.transport)
10751061 }
10761062 }
10771063 }
@@ -1149,7 +1135,7 @@ func (d *Daemon) closeTabLocked(sess *session, tb *tab, repaint bool) error {
11491135 return d.closeTabLockedWithEffect(sess, tb, repaint, nil)
11501136 }
11511137
1152-func (d *Daemon) closeTabLockedWithEffect(sess *session, tb *tab, repaint bool, effect *attachmentEffectTicket) error {
1138+func (d *Daemon) closeTabLockedWithEffect(sess *session, tb *tab, repaint bool, effect *attachmentEffect) error {
11531139 if sess == nil || tb == nil {
11541140 return layout.ErrNotFound
11551141 }
@@ -1178,7 +1164,7 @@ func (d *Daemon) closeTabLockedWithEffect(sess *session, tb *tab, repaint bool,
11781164 var err error
11791165 if effect != nil {
11801166 effect.bindActionEnd(d, "close-tab")
1181- err = d.killSessionForAttachment(sess, ports.ReasonSessionKilled, false, effect.connectionToken(), "close-tab")
1167+ err = d.killSessionForAttachment(sess, ports.ReasonSessionKilled, false, effect, "close-tab")
11821168 } else {
11831169 err = d.killSession(sess, ports.ReasonSessionKilled, false)
11841170 }
@@ -1305,8 +1291,9 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
13051291 }
13061292
13071293 type sessionKillAdmission struct {
1308- token attachmentConnectionToken
1309- action string
1294+ capability attachmentCapability
1295+ effect *attachmentEffect
1296+ action string
13101297 }
13111298
13121299 type sessionKillParticipants struct {
@@ -1315,12 +1302,15 @@ type sessionKillParticipants struct {
13151302 effectGates []*attachedClient
13161303 interrupts []attachmentTransportInterrupt
13171304 source *session
1318- sourceToken *attachmentConnectionToken
1305+ sourceToken *attachmentCapability
13191306 target *session
13201307 }
13211308
1322-func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge bool, token attachmentConnectionToken, action string) error {
1323- return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action})
1309+func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge bool, effect *attachmentEffect, action string) error {
1310+ if effect == nil {
1311+ return errAttachmentTransition
1312+ }
1313+ return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{capability: effect.capability(), effect: effect, action: action})
13241314 }
13251315
13261316 // snapshotSessionKillParticipants captures attachment membership under the canonical
@@ -1330,7 +1320,7 @@ func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge boo
13301320 func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission, requireEmpty bool) (sessionKillParticipants, bool) {
13311321 snapshot := sessionKillParticipants{target: target}
13321322 if admission != nil {
1333- token := admission.token
1323+ token := admission.capability
13341324 if token.sess == nil || token.ac == nil || token.transport.transport == nil {
13351325 return sessionKillParticipants{}, false
13361326 }
@@ -1338,7 +1328,7 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
13381328 if snapshot.source == nil {
13391329 return sessionKillParticipants{}, false
13401330 }
1341- snapshot.sourceToken = &admission.token
1331+ snapshot.sourceToken = &admission.capability
13421332 }
13431333
13441334 d.mu.Lock()
@@ -1381,7 +1371,7 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
13811371 snapshot.effectGates = append(snapshot.effectGates, ac)
13821372 }
13831373 if admission != nil {
1384- add(admission.token.ac)
1374+ add(admission.capability.ac)
13851375 }
13861376 for ac := range snapshot.attachments {
13871377 add(ac)
@@ -1440,10 +1430,7 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
14401430 return true
14411431 }
14421432 token := *snapshot.sourceToken
1443- req := attachmentTransitionRequest{
1444- source: token.sess, next: token.ac, expectedTransport: token.transport,
1445- }
1446- return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
1433+ return token.currentInSessionAndLeaseLocked(token.sess, token.ac, sourceCoordinator)
14471434 }
14481435
14491436 // stopInMemoryLifecycle cancels every session producer and closes every PTY
@@ -1570,7 +1557,7 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
15701557 return nil
15711558 }
15721559 if admission != nil {
1573- d.endActionAttachmentEffect(admission.token.effect, admission.action)
1560+ d.endActionAttachmentEffect(admission.effect, admission.action)
15741561 }
15751562 action := ""
15761563 if admission != nil {
@@ -1740,7 +1727,6 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
17401727 return
17411728 }
17421729 attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})
1743- attached.connectionGeneration.Add(1)
17441730 attached.setSession(nil)
17451731 attached.invalidateFrozenAttachmentCapability()
17461732 }
@@ -1769,7 +1755,7 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
17691755 d.mu.Unlock()
17701756 d.finishParkedAttachmentRetirements(parkedRetirements)
17711757 frozen.unfreeze()
1772- frozen = frozenAttachmentEffectGates{}
1758+ frozen = attachmentTransitionGuard{}
17731759 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
17741760
17751761 var purgeErr error
M internal/usecase/daemon/session_back.go +6 -7
@@ -8,11 +8,11 @@ import (
88 // backSessionForAttachment delegates previous-route ownership to the client
99 // once it has published a complete route snapshot. There is no daemon-local
1010 // previous-session fallback after the global-history cutover.
11-func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {
12- if d == nil || token.sess == nil || token.ac == nil {
11+func (d *Daemon) backSessionForAttachment(effect *attachmentEffect) error {
12+ if d == nil || !effect.current() || effect.sess == nil || effect.ac == nil {
1313 return nil
1414 }
15- snapshot := token.ac.routeSnapshotCopy()
15+ snapshot := effect.ac.routeSnapshotCopy()
1616 if snapshot.Generation == 0 {
1717 if d.log != nil {
1818 d.log.Debug("back-session skipped: attachment has no published route snapshot")
@@ -22,7 +22,7 @@ func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error
2222 if snapshot.Previous.Key == 0 || snapshot.Previous.Generation == 0 {
2323 return nil
2424 }
25- return d.sendRecentRouteNavigationActionForAttachment(token, ports.RouteNavigationAction{
25+ return d.sendRecentRouteNavigationActionForAttachment(effect, ports.RouteNavigationAction{
2626 SnapshotGeneration: snapshot.Generation,
2727 Key: snapshot.Previous.Key,
2828 Generation: snapshot.Previous.Generation,
@@ -33,15 +33,14 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
3333 if d == nil || current == nil || ac == nil {
3434 return
3535 }
36- token := attachmentToken(current, ac, ac.transport())
36+ token := captureAttachmentCapability(current, ac, ac.transport())
3737 effect, admitted := ac.beginAttachmentEffect(token)
3838 if !admitted {
3939 d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition))
4040 return
4141 }
42- token.effect = effect
4342 defer effect.End()
44- if err := d.backSessionForAttachment(token); err != nil {
43+ if err := d.backSessionForAttachment(effect); err != nil {
4544 d.reportError(current, err)
4645 }
4746 }
M internal/usecase/daemon/session_back_route_test.go +3 -4
@@ -12,13 +12,12 @@ func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T
1212 transport := &closeTrackingTransport{}
1313 ac.replaceTransport(transport)
1414 rc := d.attachCoordinator(source, nil, ac, true)
15- token := source.attachmentToken(ac, transport)
15+ token := source.captureAttachmentCapability(ac, transport)
1616 token.lease = rc.attachmentLease(ac)
17- ac.publishAttachmentCapability(token)
17+ ac.installTestAttachmentCapability(token)
1818 effect, admitted := ac.beginAttachmentEffect(token)
1919 require.True(t, admitted)
2020 defer effect.End()
21- token.effect = effect
2221 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
2322 Generation: 5,
2423 Active: ports.RouteRef{Key: 9, Generation: 5},
@@ -26,7 +25,7 @@ func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T
2625 Entries: []ports.RecentRouteEntry{testRouteEntry(7, 3, "previous", 7, ports.RouteKindLocal)},
2726 })
2827
29- require.NoError(t, d.backSessionForAttachment(token))
28+ require.NoError(t, d.backSessionForAttachment(effect))
3029 frames := transport.Sends()
3130 require.NotEmpty(t, frames)
3231 var action ports.RouteNavigationAction
M internal/usecase/daemon/session_rename_purge_test.go +4 -4
@@ -17,8 +17,8 @@ func TestRenamePublishesCommittedRouteIdentityToAttachedClients(t *testing.T) {
1717 sess.ephemeral = true
1818 sess.incarnation = domain.IncarnationID{1}
1919 sess.mu.Unlock()
20- token := sess.attachmentToken(ac, ac.transport())
21- ac.publishAttachmentCapability(token)
20+ token := sess.captureAttachmentCapability(ac, ac.transport())
21+ ac.installTestAttachmentCapability(token)
2222 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
2323
2424 require.NoError(t, d.renameSession(sess, "vps-infra"))
@@ -40,8 +40,8 @@ func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T)
4040 sess.ephemeral = true
4141 sess.incarnation = domain.IncarnationID{1}
4242 sess.mu.Unlock()
43- token := sess.attachmentToken(ac, ac.transport())
44- ac.publishAttachmentCapability(token)
43+ token := sess.captureAttachmentCapability(ac, ac.transport())
44+ ac.installTestAttachmentCapability(token)
4545
4646 require.NoError(t, d.renameSession(sess, "vps-infra"))
4747 require.Empty(t, sends, "the route ledger must be published before its identity can be updated")
M internal/usecase/daemon/transactional_resize.go +6 -5
@@ -703,12 +703,13 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
703703 return false
704704 }
705705 }
706- connectionGeneration := ac.connectionGeneration.Load()
707- connection := ac.transportSnapshot()
706+ effect, admitted := beginAttachmentLeaseEffect(sess, ac, lease)
707+ if !admitted {
708+ return false
709+ }
710+ defer effect.End()
708711 current := func() bool {
709- return ac.connectionGeneration.Load() == connectionGeneration &&
710- ac.transportSnapshotCurrent(connection) &&
711- sess.geometryClaimTokenCurrent(ac, geometryClaim) &&
712+ return effect.current() && sess.geometryClaimTokenCurrent(ac, geometryClaim) &&
712713 rc.resizeCurrentForLease(epoch, ac, lease, false)
713714 }
714715 if !current() {

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