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
2 2
3 import "github.com/bnema/vev/internal/ports"3 import "github.com/bnema/vev/internal/ports"
4 4
5-// attachmentConnectionToken is the exact capability for one registered5+// attachmentCapability is the exact capability for one registered
6-// attachment connection incarnation. It is invalid as soon as any of the6+// Attachment Connection incarnation. It is invalid as soon as its Session
7-// session, attachment membership, connection generation, transport link, or7+// membership, generation, Transport incarnation, or render lease changes.
8-// coordinator lease changes.8+type attachmentCapability struct {
9-type attachmentConnectionToken struct {
10 sess *session9 sess *session
11 ac *attachedClient10 ac *attachedClient
12 generation uint6411 generation uint64
13 transport transportSnapshot12 transport transportSnapshot
14 lease *attachmentLease13 lease *attachmentLease
15- effect *attachmentEffectTicket
16 // rebase marks a cross-session move whose first paint starts a dependency-14 // rebase marks a cross-session move whose first paint starts a dependency-
17 // free output chain after publication releases architecture locks.15 // free output chain after publication releases architecture locks.
18 rebase bool16 rebase bool
19 }17 }
20 18
21-func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) attachmentConnectionToken {19+func captureAttachmentCapability(entry *session, ac *attachedClient, tr ports.Transport) attachmentCapability {
22 if entry == nil || entry.core() == nil || ac == nil || tr == nil {20 if entry == nil || entry.core() == nil || ac == nil || tr == nil {
23- return attachmentConnectionToken{}21+ return attachmentCapability{}
24 }22 }
25 // Capture one concrete link incarnation before taking the session lock. The23 // Capture one concrete link incarnation before taking the session lock. The
26 // same snapshot is revalidated below; do not take a second snapshot and24 // same snapshot is revalidated below; do not take a second snapshot and
27 // accidentally bind a replacement link.25 // accidentally bind a replacement link.
28 transport := ac.transportSnapshot()26 transport := ac.transportSnapshot()
29 if transport.transport != tr {27 if transport.transport != tr {
30- return attachmentConnectionToken{}28+ return attachmentCapability{}
31 }29 }
32- if ac.beforeAttachmentTokenValidation != nil {30+ if ac.beforeAttachmentCapabilityValidation != nil {
33- ac.beforeAttachmentTokenValidation()31+ ac.beforeAttachmentCapabilityValidation()
34 }32 }
35 core := entry.core()33 core := entry.core()
36 core.mu.Lock()34 core.mu.Lock()
37 if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(transport) {35 if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(transport) {
38 core.mu.Unlock()36 core.mu.Unlock()
39- return attachmentConnectionToken{}37+ return attachmentCapability{}
40 }38 }
41- generation := ac.connectionGeneration.Load()39+ generation := ac.lifecycle.generation.Load()
42- token := attachmentConnectionToken{40+ token := attachmentCapability{
43 sess: entry,41 sess: entry,
44 ac: ac,42 ac: ac,
45 generation: generation,43 generation: generation,
@@ -50,101 +48,93 @@ func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) att
50 }48 }
51 core.mu.Unlock()49 core.mu.Unlock()
52 if !token.current() {50 if !token.current() {
53- return attachmentConnectionToken{}51+ return attachmentCapability{}
54 }52 }
55- ac.bootstrapAttachmentCapability(token)53+ ac.lifecycle.installInitialCapability(token)
56 return token54 return token
57 }55 }
58 56
59-func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentConnectionToken {57+func (s *session) captureAttachmentCapability(ac *attachedClient, tr ports.Transport) attachmentCapability {
60- return attachmentToken(s, ac, tr)58+ return captureAttachmentCapability(s, ac, tr)
61 }59 }
62 60
63-// current validates every immutable identity captured by the token. Membership61+func (t attachmentCapability) sameIdentity(other attachmentCapability) bool {
64-// is the sole authority; there is no owner, replacement, or compatibility role.62+ return t.sess != nil && t.ac != nil && t.sess == other.sess && t.ac == other.ac &&
65-func (t attachmentConnectionToken) current() bool {63+ t.generation == other.generation && t.transport.transport == other.transport.transport &&
66- return t.sess != nil && t.ac != nil &&64+ t.transport.incarnation == other.transport.incarnation && t.lease == other.lease
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)
71 }65 }
72 66
73-// attachmentCurrent additionally validates the optional coordinator lease used67+func (t attachmentCapability) matchesConnectionSnapshot(sess *session, ac *attachedClient, transport transportSnapshot) bool {
74-// by render and resize effects. Attachments without that optional lease still68+ return t.sess == sess && t.ac == ac && ac != nil && t.transport == transport &&
75-// own their independent connection lifecycle.69+ ac.lifecycle.identityCurrent(t)
76-func (t attachmentConnectionToken) attachmentCurrent() bool {70+}
77- if !t.current() || t.ac.currentAttachmentSession() != t.sess {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) {
78 return false84 return false
79 }85 }
80- if t.lease == nil {86+ if capability.lease == nil {
81 return true87 return true
82 }88 }
83- rc := t.sess.core().coordinator.Load()89+ rc := capability.sess.core().coordinator.Load()
84- return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)90+ return rc != nil && capability.lease.attachment == capability.ac && rc.leaseCurrent(capability.lease, false)
85 }91 }
86 92
87-func (t attachmentConnectionToken) attachmentEffectCurrent() bool {93+func (t attachmentCapability) current() bool {
88- if t.effect != nil && !t.effect.ended.Load() {94+ return t.ac != nil && t.ac.lifecycle.current(t)
89- return true
90- }
91- return t.attachmentCurrent()
92 }95 }
93 96
94-func beginAttachmentLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*attachmentEffectTicket, bool) {97+func beginAttachmentLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*attachmentEffect, bool) {
95 if sess == nil || ac == nil || lease == nil {98 if sess == nil || ac == nil || lease == nil {
96 return nil, false99 return nil, false
97 }100 }
98- token := attachmentToken(sess, ac, ac.transport())101+ token := captureAttachmentCapability(sess, ac, ac.transport())
99 token.lease = lease102 token.lease = lease
100 return ac.beginAttachmentEffect(token)103 return ac.beginAttachmentEffect(token)
101 }104 }
102 105
103-func (t *attachmentConnectionToken) endAttachmentEffect() {106+func (t attachmentCapability) currentInSessionLocked(sess *session) bool {
104- if t == nil || t.effect == nil {107+ return t.sess == sess && t.ac != nil && t.ac.lifecycle.currentSessionLocked(t)
105- return
106- }
107- t.effect.End()
108- t.effect = nil
109 }108 }
110 109
111-// attachmentEffectCurrentSessionLocked validates authority at a session-owned110+func (t attachmentCapability) currentInSessionAndLeaseLocked(sess *session, ac *attachedClient, coordinator *renderCoordinator) bool {
112-// mutation boundary where sess.core().mu is already held. A live effect ticket111+ return t.sess == sess && t.ac == ac && ac != nil &&
113-// preserves the exact capability it captured for the effect because attachment112+ ac.lifecycle.currentSessionAndLeaseLocked(t, coordinator)
114-// transitions drain the gate before changing identity. This differs from113+}
115-// attachmentCurrent, which revalidates the attachment's currently published identity.114+
116-func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool {115+// currentSessionLocked validates capability authority where sess.core().mu is
117- if t.effect != nil && !t.effect.ended.Load() {116+// already held. An admitted effect does not use this comparison: a transition
118- return true117+// cannot change identity until that effect ends.
119- }118+func (l *attachmentLifecycle) currentSessionLocked(capability attachmentCapability) bool {
120- if t.sess == nil || t.ac == nil ||119+ if !l.identityCurrent(capability) || !attachmentRegisteredLocked(capability.sess, capability.ac) {
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) {
125 return false120 return false
126 }121 }
127- if t.lease == nil {122+ if capability.lease == nil {
128 return true123 return true
129 }124 }
130- rc := t.sess.core().coordinator.Load()125+ rc := capability.sess.core().coordinator.Load()
131- return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true)126+ return rc != nil && capability.lease.attachment == capability.ac && rc.leaseCurrent(capability.lease, false)
132 }127 }
133 128
134-func (t attachmentConnectionToken) sendControl(frame ports.Frame) error {129+// currentSessionAndLeaseLocked validates the same capability tuple when the
135- if t.ac == nil || t.transport.transport == nil {130+// caller already holds both the Session and coordinator locks.
136- return errAttachmentTransition131+func (l *attachmentLifecycle) currentSessionAndLeaseLocked(capability attachmentCapability, coordinator *renderCoordinator) bool {
137- }132+ if !l.identityCurrent(capability) || !attachmentRegisteredLocked(capability.sess, capability.ac) {
138- t.ac.sendMu.Lock()133+ return false
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
143 }134 }
144- err := t.transport.transport.Send(frame)135+ if capability.lease == nil {
145- if err != nil {136+ return true
146- t.effect.reportTransportFailure(t.transport)
147 }137 }
148- t.effect.endTransportSend()138+ return coordinator != nil && capability.lease.attachment == capability.ac &&
149- return err139+ coordinator.leaseCurrentLocked(capability.lease, false)
150 }140 }
M internal/usecase/daemon/attachment_effect_freeze.go +17 -17
@@ -60,7 +60,7 @@ func (d *attachmentEffectDrainDeadline) Done() <-chan struct{} {
60 return d.done60 return d.done
61 }61 }
62 62
63-type frozenAttachmentEffectGates struct {63+type attachmentTransitionGuard struct {
64 clients []*attachedClient64 clients []*attachedClient
65 interruptions map[*attachedClient]transportSnapshot65 interruptions map[*attachedClient]transportSnapshot
66 acquired bool66 acquired bool
@@ -84,7 +84,7 @@ type attachmentEffectFreezeOptions struct {
84 84
85 // freezeAttachmentEffectGates establishes transition priority in immutable attachment85 // freezeAttachmentEffectGates establishes transition priority in immutable attachment
86 // order, then drains earlier admissions.86 // order, then drains earlier admissions.
87-func freezeAttachmentEffectGates(clients ...*attachedClient) frozenAttachmentEffectGates {87+func freezeAttachmentEffectGates(clients ...*attachedClient) attachmentTransitionGuard {
88 return freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{}, clients...)88 return freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{}, clients...)
89 }89 }
90 90
@@ -93,11 +93,11 @@ func freezeAttachmentEffectGates(clients ...*attachedClient) frozenAttachmentEff
93 // behind another owner, only gates acquired by this call are rolled back, in93 // behind another owner, only gates acquired by this call are rolled back, in
94 // reverse order. nonblocking is used by move reservations to avoid inverting94 // reverse order. nonblocking is used by move reservations to avoid inverting
95 // the move/teardown protocols.95 // the move/teardown protocols.
96-func freezeAttachmentEffectGatesWith(options attachmentEffectFreezeOptions, clients ...*attachedClient) frozenAttachmentEffectGates {96+func freezeAttachmentEffectGatesWith(options attachmentEffectFreezeOptions, clients ...*attachedClient) attachmentTransitionGuard {
97 ordered := orderedAttachmentEffectClients(clients)97 ordered := orderedAttachmentEffectClients(clients)
98 frozen, acquired := acquireAttachmentEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)98 frozen, acquired := acquireAttachmentEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking)
99 if !acquired {99 if !acquired {
100- return frozenAttachmentEffectGates{}100+ return attachmentTransitionGuard{}
101 }101 }
102 102
103 interruptByClient := attachmentTransportInterruptsByClient(options.interrupts)103 interruptByClient := attachmentTransportInterruptsByClient(options.interrupts)
@@ -121,18 +121,18 @@ func orderedAttachmentEffectClients(clients []*attachedClient) []*attachedClient
121 continue121 continue
122 }122 }
123 unique[ac] = struct{}{}123 unique[ac] = struct{}{}
124- ac.attachmentEffects.immutableOrder()124+ ac.lifecycle.immutableOrder()
125 ordered = append(ordered, ac)125 ordered = append(ordered, ac)
126 }126 }
127 sort.Slice(ordered, func(i, j int) bool {127 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()
129 })129 })
130 return ordered130 return ordered
131 }131 }
132 132
133 // waitForAttachmentEffectChangeLocked requires g.mu and returns with g.mu held. A133 // waitForAttachmentEffectChangeLocked requires g.mu and returns with g.mu held. A
134 // true result means the overall deadline won and the caller must stop waiting.134 // 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 {
136 if done == nil {136 if done == nil {
137 g.cond.Wait()137 g.cond.Wait()
138 return false138 return false
@@ -156,22 +156,22 @@ func waitForAttachmentEffectChangeLocked(g *attachmentEffectGate, done func() <-
156 // acquireAttachmentEffectGates establishes exclusive transition ownership in order.156 // acquireAttachmentEffectGates establishes exclusive transition ownership in order.
157 // It must run without architecture locks. Deadline rollback releases only this157 // It must run without architecture locks. Deadline rollback releases only this
158 // call's partial acquisition and does so in reverse order.158 // 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) {159+func acquireAttachmentEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (attachmentTransitionGuard, bool) {
160- frozen := frozenAttachmentEffectGates{clients: make([]*attachedClient, 0, len(ordered))}160+ frozen := attachmentTransitionGuard{clients: make([]*attachedClient, 0, len(ordered))}
161 for _, ac := range ordered {161 for _, ac := range ordered {
162- g := &ac.attachmentEffects162+ g := &ac.lifecycle
163 g.mu.Lock()163 g.mu.Lock()
164 g.initLocked()164 g.initLocked()
165 for g.phase == attachmentEffectsFrozen {165 for g.phase == attachmentEffectsFrozen {
166 if nonblocking {166 if nonblocking {
167 g.mu.Unlock()167 g.mu.Unlock()
168 frozen.unfreeze()168 frozen.unfreeze()
169- return frozenAttachmentEffectGates{}, false169+ return attachmentTransitionGuard{}, false
170 }170 }
171 if waitForAttachmentEffectChangeLocked(g, done) {171 if waitForAttachmentEffectChangeLocked(g, done) {
172 g.mu.Unlock()172 g.mu.Unlock()
173 frozen.unfreeze()173 frozen.unfreeze()
174- return frozenAttachmentEffectGates{}, false174+ return attachmentTransitionGuard{}, false
175 }175 }
176 }176 }
177 g.phase = attachmentEffectsFrozen177 g.phase = attachmentEffectsFrozen
@@ -204,7 +204,7 @@ func interruptFrozenAttachmentTransports(ordered []*attachedClient, interruptByC
204 if !mayInterrupt {204 if !mayInterrupt {
205 continue205 continue
206 }206 }
207- g := &ac.attachmentEffects207+ g := &ac.lifecycle
208 g.mu.Lock()208 g.mu.Lock()
209 if gateHasTransportEffectLocked(g, expected) {209 if gateHasTransportEffectLocked(g, expected) {
210 toClose = append(toClose, attachmentTransportInterrupt{ac: ac, transport: expected})210 toClose = append(toClose, attachmentTransportInterrupt{ac: ac, transport: expected})
@@ -221,7 +221,7 @@ func interruptFrozenAttachmentTransports(ordered []*attachedClient, interruptByC
221 // late tickets can then retire without pinning the owner indefinitely.221 // late tickets can then retire without pinning the owner indefinitely.
222 func drainFrozenAttachmentEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool {222 func drainFrozenAttachmentEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool {
223 for _, ac := range ordered {223 for _, ac := range ordered {
224- g := &ac.attachmentEffects224+ g := &ac.lifecycle
225 g.mu.Lock()225 g.mu.Lock()
226 for g.inFlight != 0 {226 for g.inFlight != 0 {
227 if waitForAttachmentEffectChangeLocked(g, done) {227 if waitForAttachmentEffectChangeLocked(g, done) {
@@ -238,7 +238,7 @@ func drainFrozenAttachmentEffects(ordered []*attachedClient, interruptByClient,
238 return true238 return true
239 }239 }
240 240
241-func gateHasTransportEffectLocked(g *attachmentEffectGate, expected transportSnapshot) bool {241+func gateHasTransportEffectLocked(g *attachmentLifecycle, expected transportSnapshot) bool {
242 for _, active := range g.transportEffects {242 for _, active := range g.transportEffects {
243 if active.transport == expected.transport && active.incarnation == expected.incarnation {243 if active.transport == expected.transport && active.incarnation == expected.incarnation {
244 return true244 return true
@@ -247,9 +247,9 @@ func gateHasTransportEffectLocked(g *attachmentEffectGate, expected transportSna
247 return false247 return false
248 }248 }
249 249
250-func (f frozenAttachmentEffectGates) unfreeze() {250+func (f attachmentTransitionGuard) unfreeze() {
251 for i := len(f.clients) - 1; i >= 0; i-- {251 for i := len(f.clients) - 1; i >= 0; i-- {
252- g := &f.clients[i].attachmentEffects252+ g := &f.clients[i].lifecycle
253 g.mu.Lock()253 g.mu.Lock()
254 g.initLocked()254 g.initLocked()
255 g.phase = attachmentEffectsStable255 g.phase = attachmentEffectsStable
M internal/usecase/daemon/attachment_effect_gate.go +108 -101
@@ -15,46 +15,33 @@ const (
15 attachmentEffectsFrozen15 attachmentEffectsFrozen
16 )16 )
17 17
18-// attachmentCapability is the single attachment-local publication consumed by18+// attachmentLifecycle owns the immutable committed capability and the
19-// effect admission. It deliberately contains no independently mutable state:19+// linearization gate for every attachment-bound observable effect. Transitions
20-// a ticket is admitted only when every field matches the frame's token.20+// freeze and drain it before changing membership, generation, Transport, or
21-type attachmentCapability struct {21+// render lease identity.
22- sess *session22+type attachmentLifecycle struct {
23- generation uint6423+ generation atomic.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 {
44 mu sync.Mutex24 mu sync.Mutex
45 cond *sync.Cond25 cond *sync.Cond
46 changed chan struct{}26 changed chan struct{}
47 phase attachmentEffectPhase27 phase attachmentEffectPhase
48 capability attachmentCapability28 capability attachmentCapability
49 inFlight uint6429 inFlight uint64
50- transportEffects map[*attachmentEffectTicket]transportSnapshot30+ transportEffects map[*attachmentEffect]transportSnapshot
51 failedTransport transportSnapshot31 failedTransport transportSnapshot
52 order atomic.Uint6432 order atomic.Uint64
53 }33 }
54 34
55 var nextAttachmentEffectGateOrder atomic.Uint6435 var nextAttachmentEffectGateOrder atomic.Uint64
56 36
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() {
58 if g.cond == nil {45 if g.cond == nil {
59 g.cond = sync.NewCond(&g.mu)46 g.cond = sync.NewCond(&g.mu)
60 }47 }
@@ -63,14 +50,14 @@ func (g *attachmentEffectGate) initLocked() {
63 }50 }
64 }51 }
65 52
66-func (g *attachmentEffectGate) signalLocked() {53+func (g *attachmentLifecycle) signalLocked() {
67 g.initLocked()54 g.initLocked()
68 close(g.changed)55 close(g.changed)
69 g.changed = make(chan struct{})56 g.changed = make(chan struct{})
70 g.cond.Broadcast()57 g.cond.Broadcast()
71 }58 }
72 59
73-func (g *attachmentEffectGate) immutableOrder() uint64 {60+func (g *attachmentLifecycle) immutableOrder() uint64 {
74 if order := g.order.Load(); order != 0 {61 if order := g.order.Load(); order != 0 {
75 return order62 return order
76 }63 }
@@ -81,7 +68,7 @@ func (g *attachmentEffectGate) immutableOrder() uint64 {
81 return g.order.Load()68 return g.order.Load()
82 }69 }
83 70
84-func (g *attachmentEffectGate) inFlightCount() uint64 {71+func (g *attachmentLifecycle) inFlightCount() uint64 {
85 if g == nil {72 if g == nil {
86 return 073 return 0
87 }74 }
@@ -90,17 +77,18 @@ func (g *attachmentEffectGate) inFlightCount() uint64 {
90 return g.inFlight77 return g.inFlight
91 }78 }
92 79
93-// attachmentEffectTicket reserves the exact published capability until End. End is80+// attachmentEffect is an explicit admission of one immutable capability. It
94-// idempotent so error paths can safely defer it before doing any observable I/O.81+// remains valid until End because transitions freeze admission and drain all
95-type attachmentEffectTicket struct {82+// existing effects before changing lifecycle identity. End is idempotent.
96- gate *attachmentEffectGate83+type attachmentEffect struct {
97- token attachmentConnectionToken84+ attachmentCapability
85+ lifecycle *attachmentLifecycle
98 actionDaemon *Daemon86 actionDaemon *Daemon
99 action string87 action string
100 ended atomic.Bool88 ended atomic.Bool
101 }89 }
102 90
103-func (t *attachmentEffectTicket) bindActionEnd(d *Daemon, action string) {91+func (t *attachmentEffect) bindActionEnd(d *Daemon, action string) {
104 if t == nil || t.ended.Load() {92 if t == nil || t.ended.Load() {
105 return93 return
106 }94 }
@@ -108,25 +96,31 @@ func (t *attachmentEffectTicket) bindActionEnd(d *Daemon, action string) {
108 t.action = action96 t.action = action
109 }97 }
110 98
111-func (t *attachmentEffectTicket) connectionToken() attachmentConnectionToken {99+func (t *attachmentEffect) capability() attachmentCapability {
112 if t == nil {100 if t == nil {
113- return attachmentConnectionToken{}101+ return attachmentCapability{}
114 }102 }
115- token := t.token103+ return t.attachmentCapability
116- token.effect = t104+}
117- return token105+
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()
118 }112 }
119 113
120-func (t *attachmentEffectTicket) End() {114+func (t *attachmentEffect) End() {
121- if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) {115+ if t == nil || t.lifecycle == nil || !t.ended.CompareAndSwap(false, true) {
122 return116 return
123 }117 }
124- g := t.gate118+ g := t.lifecycle
125 g.mu.Lock()119 g.mu.Lock()
126 g.initLocked()120 g.initLocked()
127 if g.inFlight == 0 {121 if g.inFlight == 0 {
128 g.mu.Unlock()122 g.mu.Unlock()
129- panic("daemon: attachment effect ticket ended without admission")123+ panic("daemon: attachment effect ended without admission")
130 }124 }
131 delete(g.transportEffects, t)125 delete(g.transportEffects, t)
132 g.inFlight--126 g.inFlight--
@@ -137,7 +131,7 @@ func (t *attachmentEffectTicket) End() {
137 }131 }
138 }132 }
139 133
140-func (d *Daemon) endActionAttachmentEffect(effect *attachmentEffectTicket, _ string) {134+func (d *Daemon) endActionAttachmentEffect(effect *attachmentEffect, _ string) {
141 if effect != nil {135 if effect != nil {
142 effect.End()136 effect.End()
143 }137 }
@@ -146,11 +140,11 @@ func (d *Daemon) endActionAttachmentEffect(effect *attachmentEffectTicket, _ str
146 // beginTransportSend marks only the interval spent in an external transport140 // beginTransportSend marks only the interval spent in an external transport
147 // send as interruptible. A replacement may close this exact published link to141 // send as interruptible. A replacement may close this exact published link to
148 // release the send without retiring an idle healthy attachment.142 // release the send without retiring an idle healthy attachment.
149-func (t *attachmentEffectTicket) beginTransportSend(expected transportSnapshot) bool {143+func (t *attachmentEffect) beginTransportSend(expected transportSnapshot) bool {
150- if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil {144+ if t == nil || t.lifecycle == nil || t.ended.Load() || expected.transport == nil {
151 return false145 return false
152 }146 }
153- g := t.gate147+ g := t.lifecycle
154 g.mu.Lock()148 g.mu.Lock()
155 g.initLocked()149 g.initLocked()
156 if t.ended.Load() || g.capability.transport.transport != expected.transport ||150 if t.ended.Load() || g.capability.transport.transport != expected.transport ||
@@ -159,7 +153,7 @@ func (t *attachmentEffectTicket) beginTransportSend(expected transportSnapshot)
159 return false153 return false
160 }154 }
161 if g.transportEffects == nil {155 if g.transportEffects == nil {
162- g.transportEffects = make(map[*attachmentEffectTicket]transportSnapshot)156+ g.transportEffects = make(map[*attachmentEffect]transportSnapshot)
163 }157 }
164 g.transportEffects[t] = expected158 g.transportEffects[t] = expected
165 g.signalLocked()159 g.signalLocked()
@@ -167,11 +161,11 @@ func (t *attachmentEffectTicket) beginTransportSend(expected transportSnapshot)
167 return true161 return true
168 }162 }
169 163
170-func (t *attachmentEffectTicket) reportTransportFailure(expected transportSnapshot) {164+func (t *attachmentEffect) reportTransportFailure(expected transportSnapshot) {
171- if t == nil || t.gate == nil || expected.transport == nil {165+ if t == nil || t.lifecycle == nil || expected.transport == nil {
172 return166 return
173 }167 }
174- g := t.gate168+ g := t.lifecycle
175 g.mu.Lock()169 g.mu.Lock()
176 if !t.ended.Load() && g.capability.transport.transport == expected.transport &&170 if !t.ended.Load() && g.capability.transport.transport == expected.transport &&
177 g.capability.transport.incarnation == expected.incarnation {171 g.capability.transport.incarnation == expected.incarnation {
@@ -181,11 +175,11 @@ func (t *attachmentEffectTicket) reportTransportFailure(expected transportSnapsh
181 g.mu.Unlock()175 g.mu.Unlock()
182 }176 }
183 177
184-func (t *attachmentEffectTicket) endTransportSend() {178+func (t *attachmentEffect) endTransportSend() {
185- if t == nil || t.gate == nil {179+ if t == nil || t.lifecycle == nil {
186 return180 return
187 }181 }
188- g := t.gate182+ g := t.lifecycle
189 g.mu.Lock()183 g.mu.Lock()
190 if _, exists := g.transportEffects[t]; exists {184 if _, exists := g.transportEffects[t]; exists {
191 delete(g.transportEffects, t)185 delete(g.transportEffects, t)
@@ -194,45 +188,57 @@ func (t *attachmentEffectTicket) endTransportSend() {
194 g.mu.Unlock()188 g.mu.Unlock()
195 }189 }
196 190
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+
197 // beginAttachmentEffect is the sole attachment-bound effect admission point. The gate is208 // beginAttachmentEffect is the sole attachment-bound effect admission point. The gate is
198 // held only long enough to reserve the capability; the effect itself must run209 // held only long enough to reserve the capability; the effect itself must run
199 // without the gate mutex.210 // without the gate mutex.
200-func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken) (*attachmentEffectTicket, bool) {211+func (ac *attachedClient) beginAttachmentEffect(token attachmentCapability) (*attachmentEffect, bool) {
201- if ac == nil || token.ac != ac {212+ if ac == nil || token.ac != ac || !token.current() {
202 return nil, false213 return nil, false
203 }214 }
204- g := &ac.attachmentEffects215+ g := &ac.lifecycle
205 g.mu.Lock()216 g.mu.Lock()
206 g.initLocked()217 g.initLocked()
207- if g.phase != attachmentEffectsStable || !g.capability.matches(token) {218+ if g.phase != attachmentEffectsStable || !g.capability.sameIdentity(token) {
208 g.mu.Unlock()219 g.mu.Unlock()
209 return nil, false220 return nil, false
210 }221 }
211 g.inFlight++222 g.inFlight++
212 g.mu.Unlock()223 g.mu.Unlock()
213- captured := token224+ return &attachmentEffect{attachmentCapability: token, lifecycle: g}, true
214- captured.effect = nil
215- return &attachmentEffectTicket{gate: g, token: captured}, true
216 }225 }
217 226
218 // beginCurrentAttachmentEffect waits out a transition that already froze ac, then227 // beginCurrentAttachmentEffect waits out a transition that already froze ac, then
219 // admits the capability derived from the session registry. Handshakes use it228 // admits the capability derived from the session registry. Handshakes use it
220 // after Welcome has completed so a replacement blocked behind that send can229 // after Welcome has completed so a replacement blocked behind that send can
221 // publish before readiness or first paint.230 // 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) {
223 //nolint:contextcheck // This compatibility wrapper deliberately delegates with a fresh context; cancellable callers use the context variant below.232 //nolint:contextcheck // This compatibility wrapper deliberately delegates with a fresh context; cancellable callers use the context variant below.
224 return ac.beginCurrentAttachmentEffectContext(context.Background(), sess, tr)233 return ac.beginCurrentAttachmentEffectContext(context.Background(), sess, tr)
225 }234 }
226 235
227-func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Context, sess *session, tr ports.Transport) (attachmentConnectionToken, *attachmentEffectTicket, bool) {236+func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Context, sess *session, tr ports.Transport) (attachmentCapability, *attachmentEffect, bool) {
228- if ctx == nil {237+ if ctx == nil || ac == nil || sess == nil || tr == nil {
229- ctx = context.Background()238+ return attachmentCapability{}, nil, false
230- }
231- if ac == nil || sess == nil || tr == nil {
232- return attachmentConnectionToken{}, nil, false
233 }239 }
234 for {240 for {
235- token := sess.attachmentToken(ac, tr)241+ token := sess.captureAttachmentCapability(ac, tr)
236 if token.ac == nil {242 if token.ac == nil {
237 return token, nil, false243 return token, nil, false
238 }244 }
@@ -240,7 +246,7 @@ func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Contex
240 return token, ticket, true246 return token, ticket, true
241 }247 }
242 248
243- g := &ac.attachmentEffects249+ g := &ac.lifecycle
244 g.mu.Lock()250 g.mu.Lock()
245 g.initLocked()251 g.initLocked()
246 if g.phase == attachmentEffectsFrozen {252 if g.phase == attachmentEffectsFrozen {
@@ -254,8 +260,8 @@ func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Contex
254 }260 }
255 }261 }
256 g.mu.Unlock()262 g.mu.Unlock()
257- // A stable mismatch is only recoverable when a publication raced token263+ // A stable mismatch is only recoverable when a publication raced capability
258- // capture; an already-stale token proves that happened. A current token264+ // capture; an already-stale capability proves that happened. A current capability
259 // with a stable mismatch violates the publication invariant, so fail265 // with a stable mismatch violates the publication invariant, so fail
260 // closed instead of spinning forever.266 // closed instead of spinning forever.
261 if token.current() {267 if token.current() {
@@ -266,58 +272,59 @@ func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Contex
266 }272 }
267 }273 }
268 274
269-// publishAttachmentCapability is used by direct/headless setup and by transition275+// installInitialCapability publishes only an empty lifecycle owner. It supports
270-// publication while the gate is frozen. Production attachment changes publish only276+// direct/headless construction without creating a second mutable publication.
271-// through publishFrozenAttachmentCapability.277+func (g *attachmentLifecycle) installInitialCapability(capability attachmentCapability) {
272-func (ac *attachedClient) publishAttachmentCapability(token attachmentConnectionToken) {
273- if ac == nil {
274- return
275- }
276- g := &ac.attachmentEffects
277 g.mu.Lock()278 g.mu.Lock()
278 g.initLocked()279 g.initLocked()
279- g.capability = capabilityFromToken(token)280+ if g.phase == attachmentEffectsStable && g.capability.sess == nil {
280- g.failedTransport = transportSnapshot{}281+ g.capability = capability
282+ g.failedTransport = transportSnapshot{}
283+ }
281 g.mu.Unlock()284 g.mu.Unlock()
282 }285 }
283 286
284-// bootstrapAttachmentCapability supports direct/headless session construction. It287+func (ac *attachedClient) publishFrozenAttachmentCapability(capability attachmentCapability) attachmentCapability {
285-// never changes an existing or frozen production publication.288+ g := &ac.lifecycle
286-func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) {
287- if ac == nil || token.sess == nil || token.ac != ac {
288- return
289- }
290- g := &ac.attachmentEffects
291 g.mu.Lock()289 g.mu.Lock()
292 g.initLocked()290 g.initLocked()
293- if g.phase == attachmentEffectsStable && g.capability.sess == nil {291+ if g.phase != attachmentEffectsFrozen {
294- g.capability = capabilityFromToken(token)292+ g.mu.Unlock()
295- g.failedTransport = transportSnapshot{}293+ panic("daemon: publishing attachment capability without transition freeze")
296 }294 }
295+ capability.generation = g.generation.Add(1)
296+ g.capability = capability
297+ g.failedTransport = transportSnapshot{}
297 g.mu.Unlock()298 g.mu.Unlock()
299+ return capability
298 }300 }
299 301
300-func (ac *attachedClient) publishFrozenAttachmentCapability(token attachmentConnectionToken) {302+// invalidateRetiredAttachmentCapability retires parked identity either while
301- g := &ac.attachmentEffects303+// 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
302 g.mu.Lock()307 g.mu.Lock()
303 g.initLocked()308 g.initLocked()
304- if g.phase != attachmentEffectsFrozen {309+ if g.phase != attachmentEffectsFrozen && (g.phase != attachmentEffectsStable || g.inFlight != 0) {
305 g.mu.Unlock()310 g.mu.Unlock()
306- panic("daemon: publishing attachment capability without transition freeze")311+ panic("daemon: invalidating an active attachment capability")
307 }312 }
308- g.capability = capabilityFromToken(token)313+ g.generation.Add(1)
314+ g.capability = attachmentCapability{}
309 g.failedTransport = transportSnapshot{}315 g.failedTransport = transportSnapshot{}
310 g.mu.Unlock()316 g.mu.Unlock()
311 }317 }
312 318
313 func (ac *attachedClient) invalidateFrozenAttachmentCapability() {319 func (ac *attachedClient) invalidateFrozenAttachmentCapability() {
314- g := &ac.attachmentEffects320+ g := &ac.lifecycle
315 g.mu.Lock()321 g.mu.Lock()
316 g.initLocked()322 g.initLocked()
317 if g.phase != attachmentEffectsFrozen {323 if g.phase != attachmentEffectsFrozen {
318 g.mu.Unlock()324 g.mu.Unlock()
319 panic("daemon: invalidating attachment capability without transition freeze")325 panic("daemon: invalidating attachment capability without transition freeze")
320 }326 }
327+ g.generation.Add(1)
321 g.capability = attachmentCapability{}328 g.capability = attachmentCapability{}
322 g.failedTransport = transportSnapshot{}329 g.failedTransport = transportSnapshot{}
323 g.mu.Unlock()330 g.mu.Unlock()
M internal/usecase/daemon/attachment_effect_gate_test.go +73 -61
@@ -118,7 +118,7 @@ func (t *teardownBlockingTransport) Closed() bool {
118 }118 }
119 }119 }
120 120
121-func startBlockedAttachmentSend(t *testing.T, token attachmentConnectionToken) <-chan struct{} {121+func startBlockedAttachmentSend(t *testing.T, token attachmentCapability) <-chan struct{} {
122 t.Helper()122 t.Helper()
123 ticket, admitted := token.ac.beginAttachmentEffect(token)123 ticket, admitted := token.ac.beginAttachmentEffect(token)
124 require.True(t, admitted)124 require.True(t, admitted)
@@ -133,19 +133,19 @@ func startBlockedAttachmentSend(t *testing.T, token attachmentConnectionToken) <
133 133
134 func requireAttachmentEffectGateRetired(t *testing.T, ac *attachedClient) {134 func requireAttachmentEffectGateRetired(t *testing.T, ac *attachedClient) {
135 t.Helper()135 t.Helper()
136- ac.attachmentEffects.mu.Lock()136+ ac.lifecycle.mu.Lock()
137- defer ac.attachmentEffects.mu.Unlock()137+ defer ac.lifecycle.mu.Unlock()
138- require.Equal(t, attachmentEffectsStable, ac.attachmentEffects.phase)138+ require.Equal(t, attachmentEffectsStable, ac.lifecycle.phase)
139- require.Zero(t, ac.attachmentEffects.inFlight)139+ require.Zero(t, ac.lifecycle.inFlight)
140- require.Empty(t, ac.attachmentEffects.transportEffects)140+ require.Empty(t, ac.lifecycle.transportEffects)
141 }141 }
142 142
143 func TestBeginCurrentAttachmentEffectFailsClosedOnStableCapabilityMismatch(t *testing.T) {143 func TestBeginCurrentAttachmentEffectFailsClosedOnStableCapabilityMismatch(t *testing.T) {
144 _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())144 _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
145- current := sess.attachmentToken(ac, ac.transport())145+ current := sess.captureAttachmentCapability(ac, ac.transport())
146 stale := current146 stale := current
147 stale.generation++147 stale.generation++
148- ac.publishAttachmentCapability(stale)148+ ac.installTestAttachmentCapability(stale)
149 149
150 result := make(chan bool, 1)150 result := make(chan bool, 1)
151 go func() {151 go func() {
@@ -163,6 +163,16 @@ func TestBeginCurrentAttachmentEffectFailsClosedOnStableCapabilityMismatch(t *te
163 }163 }
164 }164 }
165 165
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+
166 func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) {176 func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) {
167 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())177 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
168 rc := d.attachCoordinator(sess, nil, ac, true)178 rc := d.attachCoordinator(sess, nil, ac, true)
@@ -173,9 +183,9 @@ func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) {
173 }183 }
174 184
175 func attachmentEffectGatePublicationSnapshot(ac *attachedClient) (attachmentEffectPhase, attachmentCapability) {185 func attachmentEffectGatePublicationSnapshot(ac *attachedClient) (attachmentEffectPhase, attachmentCapability) {
176- ac.attachmentEffects.mu.Lock()186+ ac.lifecycle.mu.Lock()
177- defer ac.attachmentEffects.mu.Unlock()187+ defer ac.lifecycle.mu.Unlock()
178- return ac.attachmentEffects.phase, ac.attachmentEffects.capability188+ return ac.lifecycle.phase, ac.lifecycle.capability
179 }189 }
180 190
181 func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) {191 func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) {
@@ -197,21 +207,21 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
197 sess.mu.Unlock()207 sess.mu.Unlock()
198 208
199 rc := d.attachCoordinator(sess, nil, first, true)209 rc := d.attachCoordinator(sess, nil, first, true)
200- firstToken := sess.attachmentToken(first, firstTransport)210+ firstToken := sess.captureAttachmentCapability(first, firstTransport)
201 firstToken.lease = rc.attachmentLease(first)211 firstToken.lease = rc.attachmentLease(first)
202- first.publishAttachmentCapability(firstToken)212+ first.installTestAttachmentCapability(firstToken)
203- blockedToken := sess.attachmentToken(blocked, blockedTransport)213+ blockedToken := sess.captureAttachmentCapability(blocked, blockedTransport)
204- blocked.publishAttachmentCapability(blockedToken)214+ blocked.installTestAttachmentCapability(blockedToken)
205- laterToken := sess.attachmentToken(later, laterTransport)215+ laterToken := sess.captureAttachmentCapability(later, laterTransport)
206- later.publishAttachmentCapability(laterToken)216+ later.installTestAttachmentCapability(laterToken)
207 217
208 // Fix the canonical order so teardown acquires first, waits behind the gate218 // Fix the canonical order so teardown acquires first, waits behind the gate
209 // owned by this test, and never reaches the still-stable later participant.219 // owned by this test, and never reaches the still-stable later participant.
210- first.attachmentEffects.immutableOrder()220+ first.lifecycle.immutableOrder()
211- blocked.attachmentEffects.immutableOrder()221+ blocked.lifecycle.immutableOrder()
212- later.attachmentEffects.immutableOrder()222+ later.lifecycle.immutableOrder()
213- require.Less(t, first.attachmentEffects.order.Load(), blocked.attachmentEffects.order.Load())223+ require.Less(t, first.lifecycle.order.Load(), blocked.lifecycle.order.Load())
214- require.Less(t, blocked.attachmentEffects.order.Load(), later.attachmentEffects.order.Load())224+ require.Less(t, blocked.lifecycle.order.Load(), later.lifecycle.order.Load())
215 225
216 blockedOwner := freezeAttachmentEffectGates(blocked)226 blockedOwner := freezeAttachmentEffectGates(blocked)
217 blockedOwnerHeld := true227 blockedOwnerHeld := true
@@ -227,9 +237,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
227 require.Equal(t, attachmentEffectsStable, firstPhase)237 require.Equal(t, attachmentEffectsStable, firstPhase)
228 require.Equal(t, attachmentEffectsFrozen, blockedPhase)238 require.Equal(t, attachmentEffectsFrozen, blockedPhase)
229 require.Equal(t, attachmentEffectsStable, laterPhase)239 require.Equal(t, attachmentEffectsStable, laterPhase)
230- firstGeneration := first.connectionGeneration.Load()240+ firstGeneration := first.lifecycle.generationValue()
231- blockedGeneration := blocked.connectionGeneration.Load()241+ blockedGeneration := blocked.lifecycle.generationValue()
232- laterGeneration := later.connectionGeneration.Load()242+ laterGeneration := later.lifecycle.generationValue()
233 firstSnapshot := first.transportSnapshot()243 firstSnapshot := first.transportSnapshot()
234 blockedSnapshot := blocked.transportSnapshot()244 blockedSnapshot := blocked.transportSnapshot()
235 laterSnapshot := later.transportSnapshot()245 laterSnapshot := later.transportSnapshot()
@@ -264,9 +274,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
264 for _, ac := range []*attachedClient{first, blocked, later} {274 for _, ac := range []*attachedClient{first, blocked, later} {
265 require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership")275 require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership")
266 }276 }
267- require.Equal(t, firstGeneration, first.connectionGeneration.Load())277+ require.Equal(t, firstGeneration, first.lifecycle.generationValue())
268- require.Equal(t, blockedGeneration, blocked.connectionGeneration.Load())278+ require.Equal(t, blockedGeneration, blocked.lifecycle.generationValue())
269- require.Equal(t, laterGeneration, later.connectionGeneration.Load())279+ require.Equal(t, laterGeneration, later.lifecycle.generationValue())
270 require.True(t, first.transportSnapshotCurrent(firstSnapshot))280 require.True(t, first.transportSnapshotCurrent(firstSnapshot))
271 require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot))281 require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot))
272 require.True(t, later.transportSnapshotCurrent(laterSnapshot))282 require.True(t, later.transportSnapshotCurrent(laterSnapshot))
@@ -290,7 +300,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi
290 require.True(t, complete.acquired, "rolled-back gate set could not be acquired again")300 require.True(t, complete.acquired, "rolled-back gate set could not be acquired again")
291 require.True(t, complete.drained)301 require.True(t, complete.drained)
292 complete.unfreeze()302 complete.unfreeze()
293- for _, token := range []attachmentConnectionToken{firstToken, blockedToken, laterToken} {303+ for _, token := range []attachmentCapability{firstToken, blockedToken, laterToken} {
294 ticket, admitted := token.ac.beginAttachmentEffect(token)304 ticket, admitted := token.ac.beginAttachmentEffect(token)
295 require.True(t, admitted, "pre-publication abort changed an attachment capability")305 require.True(t, admitted, "pre-publication abort changed an attachment capability")
296 ticket.End()306 ticket.End()
@@ -303,7 +313,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
303 snatchedTransport := newTeardownBlockingTransport()313 snatchedTransport := newTeardownBlockingTransport()
304 snatched.replaceTransport(snatchedTransport)314 snatched.replaceTransport(snatchedTransport)
305 d.attachCoordinator(sess, nil, snatched, true)315 d.attachCoordinator(sess, nil, snatched, true)
306- snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport))316+ snatched.installTestAttachmentCapability(sess.captureAttachmentCapability(snatched, snatchedTransport))
307 317
308 activeTransport := newTeardownBlockingTransport()318 activeTransport := newTeardownBlockingTransport()
309 active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size}319 active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size}
@@ -324,8 +334,8 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) {
324 d.mu.Unlock()334 d.mu.Unlock()
325 d.attachCoordinator(unrelated, nil, unrelatedClient, true)335 d.attachCoordinator(unrelated, nil, unrelatedClient, true)
326 336
327- snatchedToken := sess.attachmentToken(snatched, snatchedTransport)337+ snatchedToken := sess.captureAttachmentCapability(snatched, snatchedTransport)
328- activeToken := sess.attachmentToken(active, activeTransport)338+ activeToken := sess.captureAttachmentCapability(active, activeTransport)
329 snatchedDone := startBlockedAttachmentSend(t, snatchedToken)339 snatchedDone := startBlockedAttachmentSend(t, snatchedToken)
330 activeDone := startBlockedAttachmentSend(t, activeToken)340 activeDone := startBlockedAttachmentSend(t, activeToken)
331 awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block")341 awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block")
@@ -359,9 +369,9 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test
359 stale := newTeardownBlockingTransport()369 stale := newTeardownBlockingTransport()
360 ac.replaceTransport(stale)370 ac.replaceTransport(stale)
361 d.attachCoordinator(sess, nil, ac, true)371 d.attachCoordinator(sess, nil, ac, true)
362- ac.publishAttachmentCapability(sess.attachmentToken(ac, stale))372+ ac.installTestAttachmentCapability(sess.captureAttachmentCapability(ac, stale))
363 373
364- token := sess.attachmentToken(ac, stale)374+ token := sess.captureAttachmentCapability(ac, stale)
365 sendDone := startBlockedAttachmentSend(t, token)375 sendDone := startBlockedAttachmentSend(t, token)
366 awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block")376 awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block")
367 fresh := &closeTrackingTransport{}377 fresh := &closeTrackingTransport{}
@@ -388,7 +398,7 @@ func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T
388 transport := &closeTrackingTransport{}398 transport := &closeTrackingTransport{}
389 active.replaceTransport(transport)399 active.replaceTransport(transport)
390 d.attachCoordinator(sess, nil, active, true)400 d.attachCoordinator(sess, nil, active, true)
391- active.publishAttachmentCapability(sess.attachmentToken(active, transport))401+ active.installTestAttachmentCapability(sess.captureAttachmentCapability(active, transport))
392 402
393 owner := freezeAttachmentEffectGates(active)403 owner := freezeAttachmentEffectGates(active)
394 require.True(t, owner.acquired)404 require.True(t, owner.acquired)
@@ -419,9 +429,9 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) {
419 blocked := newTeardownBlockingTransport()429 blocked := newTeardownBlockingTransport()
420 active.replaceTransport(blocked)430 active.replaceTransport(blocked)
421 d.attachCoordinator(sess, nil, active, true)431 d.attachCoordinator(sess, nil, active, true)
422- active.publishAttachmentCapability(sess.attachmentToken(active, blocked))432+ active.installTestAttachmentCapability(sess.captureAttachmentCapability(active, blocked))
423 433
424- token := sess.attachmentToken(active, blocked)434+ token := sess.captureAttachmentCapability(active, blocked)
425 sendDone := startBlockedAttachmentSend(t, token)435 sendDone := startBlockedAttachmentSend(t, token)
426 awaitTestCompletion(t, blocked.sendEntered, "active send did not block")436 awaitTestCompletion(t, blocked.sendEntered, "active send did not block")
427 437
@@ -445,9 +455,9 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileAttachmentEffectGateIsBus
445 transport := &closeTrackingTransport{}455 transport := &closeTrackingTransport{}
446 ac.replaceTransport(transport)456 ac.replaceTransport(transport)
447 rc := d.attachCoordinator(sess, nil, ac, true)457 rc := d.attachCoordinator(sess, nil, ac, true)
448- token := sess.attachmentToken(ac, transport)458+ token := sess.captureAttachmentCapability(ac, transport)
449 token.lease = rc.attachmentLease(ac)459 token.lease = rc.attachmentLease(ac)
450- ac.publishAttachmentCapability(token)460+ ac.installTestAttachmentCapability(token)
451 461
452 clock := &signalClock{timers: make(chan *signalTimer, 1)}462 clock := &signalClock{timers: make(chan *signalTimer, 1)}
453 d.clock = clock463 d.clock = clock
@@ -476,9 +486,9 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat
476 old.replaceTransport(oldTransport)486 old.replaceTransport(oldTransport)
477 rc := d.attachCoordinator(sess, nil, old, true)487 rc := d.attachCoordinator(sess, nil, old, true)
478 lease := rc.attachmentLease(old)488 lease := rc.attachmentLease(old)
479- token := sess.attachmentToken(old, oldTransport)489+ token := sess.captureAttachmentCapability(old, oldTransport)
480 token.lease = lease490 token.lease = lease
481- old.publishAttachmentCapability(token)491+ old.installTestAttachmentCapability(token)
482 492
483 paintDone := make(chan struct{})493 paintDone := make(chan struct{})
484 go func() {494 go func() {
@@ -530,9 +540,9 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition(
530 oldTransport := newDatagramTestTransport()540 oldTransport := newDatagramTestTransport()
531 old.replaceTransport(oldTransport)541 old.replaceTransport(oldTransport)
532 rc := d.attachCoordinator(sess, nil, old, true)542 rc := d.attachCoordinator(sess, nil, old, true)
533- token := sess.attachmentToken(old, oldTransport)543+ token := sess.captureAttachmentCapability(old, oldTransport)
534 token.lease = rc.attachmentLease(old)544 token.lease = rc.attachmentLease(old)
535- old.publishAttachmentCapability(token)545+ old.installTestAttachmentCapability(token)
536 546
537 ticket, ok := old.beginAttachmentEffect(token)547 ticket, ok := old.beginAttachmentEffect(token)
538 require.True(t, ok)548 require.True(t, ok)
@@ -631,15 +641,16 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
631 old.replaceTransport(oldTransport)641 old.replaceTransport(oldTransport)
632 old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)642 old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings)
633 rc := d.attachCoordinator(sess, nil, old, true)643 rc := d.attachCoordinator(sess, nil, old, true)
634- token := sess.attachmentToken(old, oldTransport)644+ token := sess.captureAttachmentCapability(old, oldTransport)
635 token.lease = rc.attachmentLease(old)645 token.lease = rc.attachmentLease(old)
646+ old.installTestAttachmentCapability(token)
636 if tt.setup != nil {647 if tt.setup != nil {
637 tt.setup(d, sess, old, pty)648 tt.setup(d, sess, old, pty)
638 }649 }
639 650
640 admitted := make(chan struct{})651 admitted := make(chan struct{})
641 release := make(chan struct{})652 release := make(chan struct{})
642- d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {653+ d.afterAttachmentEffectAdmitted = func(attachmentCapability) {
643 close(admitted)654 close(admitted)
644 <-release655 <-release
645 }656 }
@@ -672,9 +683,9 @@ func TestAttachedRouteSnapshotIsAcceptedAsAnAttachmentValue(t *testing.T) {
672 transport := &closeTrackingTransport{}683 transport := &closeTrackingTransport{}
673 ac.replaceTransport(transport)684 ac.replaceTransport(transport)
674 rc := d.attachCoordinator(source, nil, ac, true)685 rc := d.attachCoordinator(source, nil, ac, true)
675- token := source.attachmentToken(ac, transport)686+ token := source.captureAttachmentCapability(ac, transport)
676 token.lease = rc.attachmentLease(ac)687 token.lease = rc.attachmentLease(ac)
677- ac.publishAttachmentCapability(token)688+ ac.installTestAttachmentCapability(token)
678 689
679 activeRef := ports.RouteRef{Key: 8, Generation: 4}690 activeRef := ports.RouteRef{Key: 8, Generation: 4}
680 activeTarget := testRouteTarget(source.name, 8)691 activeTarget := testRouteTarget(source.name, 8)
@@ -711,9 +722,9 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T)
711 transport := &closeTrackingTransport{}722 transport := &closeTrackingTransport{}
712 ac.replaceTransport(transport)723 ac.replaceTransport(transport)
713 rc := d.attachCoordinator(source, nil, ac, true)724 rc := d.attachCoordinator(source, nil, ac, true)
714- token := source.attachmentToken(ac, transport)725+ token := source.captureAttachmentCapability(ac, transport)
715 token.lease = rc.attachmentLease(ac)726 token.lease = rc.attachmentLease(ac)
716- ac.publishAttachmentCapability(token)727+ ac.installTestAttachmentCapability(token)
717 payload, err := ports.MarshalCommandRequest(ports.CommandRequest{728 payload, err := ports.MarshalCommandRequest(ports.CommandRequest{
718 Version: ports.ProtocolVersion, RequestID: 1, Slug: "back-session", Attached: true,729 Version: ports.ProtocolVersion, RequestID: 1, Slug: "back-session", Attached: true,
719 })730 })
@@ -762,9 +773,9 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) {
762 d.sessions[target.id] = target773 d.sessions[target.id] = target
763 774
764 rc := d.attachCoordinator(source, nil, ac, true)775 rc := d.attachCoordinator(source, nil, ac, true)
765- token := source.attachmentToken(ac, ac.transport())776+ token := source.captureAttachmentCapability(ac, ac.transport())
766 token.lease = rc.attachmentLease(ac)777 token.lease = rc.attachmentLease(ac)
767- ac.publishAttachmentCapability(token)778+ ac.installTestAttachmentCapability(token)
768 779
769 d.handleAttachmentClientFrame(token, frameInput([]byte("\x1ba")))780 d.handleAttachmentClientFrame(token, frameInput([]byte("\x1ba")))
770 781
@@ -780,9 +791,9 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T)
780 791
781 oldTransport := old.transport()792 oldTransport := old.transport()
782 rc := d.attachCoordinator(sess, nil, old, true)793 rc := d.attachCoordinator(sess, nil, old, true)
783- token := sess.attachmentToken(old, oldTransport)794+ token := sess.captureAttachmentCapability(old, oldTransport)
784 token.lease = rc.attachmentLease(old)795 token.lease = rc.attachmentLease(old)
785- old.publishAttachmentCapability(token)796+ old.installTestAttachmentCapability(token)
786 797
787 admissionEnded := make(chan struct{})798 admissionEnded := make(chan struct{})
788 releaseAction := make(chan struct{})799 releaseAction := make(chan struct{})
@@ -824,9 +835,9 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) {
824 d, sess, ac, _ := newManualSessionWithPTYs(t, p)835 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
825 d.enterPicker(sess, ac)836 d.enterPicker(sess, ac)
826 rc := d.attachCoordinator(sess, nil, ac, true)837 rc := d.attachCoordinator(sess, nil, ac, true)
827- token := sess.attachmentToken(ac, ac.transport())838+ token := sess.captureAttachmentCapability(ac, ac.transport())
828 token.lease = rc.attachmentLease(ac)839 token.lease = rc.attachmentLease(ac)
829- ac.publishAttachmentCapability(token)840+ ac.installTestAttachmentCapability(token)
830 841
831 d.handleAttachmentClientFrame(token, frameInput([]byte("x")))842 d.handleAttachmentClientFrame(token, frameInput([]byte("x")))
832 843
@@ -841,12 +852,13 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test
841 oldTransport := newDatagramTestTransport()852 oldTransport := newDatagramTestTransport()
842 old.replaceTransport(oldTransport)853 old.replaceTransport(oldTransport)
843 rc := d.attachCoordinator(sess, nil, old, true)854 rc := d.attachCoordinator(sess, nil, old, true)
844- token := sess.attachmentToken(old, oldTransport)855+ token := sess.captureAttachmentCapability(old, oldTransport)
845 token.lease = rc.attachmentLease(old)856 token.lease = rc.attachmentLease(old)
857+ old.installTestAttachmentCapability(token)
846 858
847 admitted := make(chan struct{})859 admitted := make(chan struct{})
848 release := make(chan struct{})860 release := make(chan struct{})
849- d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {861+ d.afterAttachmentEffectAdmitted = func(attachmentCapability) {
850 close(admitted)862 close(admitted)
851 <-release863 <-release
852 }864 }
@@ -875,7 +887,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
875 aTransport := newDatagramTestTransport()887 aTransport := newDatagramTestTransport()
876 a.replaceTransport(aTransport)888 a.replaceTransport(aTransport)
877 d.attachCoordinator(first, nil, a, true)889 d.attachCoordinator(first, nil, a, true)
878- first.attachmentToken(a, aTransport)890+ first.captureAttachmentCapability(a, aTransport)
879 891
880 bTransport := newDatagramTestTransport()892 bTransport := newDatagramTestTransport()
881 b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}893 b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size}
@@ -886,7 +898,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi
886 d.sessions[second.id] = second898 d.sessions[second.id] = second
887 d.mu.Unlock()899 d.mu.Unlock()
888 d.attachCoordinator(second, nil, b, true)900 d.attachCoordinator(second, nil, b, true)
889- second.attachmentToken(b, bTransport)901+ second.captureAttachmentCapability(b, bTransport)
890 902
891 start := make(chan struct{})903 start := make(chan struct{})
892 results := make(chan error, 2)904 results := make(chan error, 2)
@@ -933,9 +945,9 @@ func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) {
933 transport := newDatagramTestTransport()945 transport := newDatagramTestTransport()
934 ac.replaceTransport(transport)946 ac.replaceTransport(transport)
935 rc := d.attachCoordinator(sess, nil, ac, true)947 rc := d.attachCoordinator(sess, nil, ac, true)
936- token := sess.attachmentToken(ac, transport)948+ token := sess.captureAttachmentCapability(ac, transport)
937 token.lease = rc.attachmentLease(ac)949 token.lease = rc.attachmentLease(ac)
938- ac.publishAttachmentCapability(token)950+ ac.installTestAttachmentCapability(token)
939 951
940 frozen := freezeAttachmentEffectGates(ac)952 frozen := freezeAttachmentEffectGates(ac)
941 require.True(t, frozen.acquired)953 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) {
357 }357 }
358 generation, revision := uint64(0), uint64(0)358 generation, revision := uint64(0), uint64(0)
359 if s.attachment != nil {359 if s.attachment != nil {
360- generation = s.attachment.connectionGeneration.Load()360+ generation = s.attachment.lifecycle.generationValue()
361 revision = s.attachment.view.revision361 revision = s.attachment.view.revision
362 }362 }
363 return generation, s.currentEpochLocked(), revision363 return generation, s.currentEpochLocked(), revision
@@ -375,7 +375,7 @@ func (s *attachmentOutput) preparedCurrentLocked(generation, epoch, viewRevision
375 375
376 func (p *preparedOutput) currentLocked() bool {376 func (p *preparedOutput) currentLocked() bool {
377 return p != nil && p.stream != nil && p.stream.preparedCurrentLocked(p.generation, p.epoch, p.viewRevision, p.base) &&377 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)
379 }379 }
380 380
381 func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision uint64, size domain.Size, full bool) (ports.Frame, error) {381 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) {
188 name string188 name string
189 change func(*attachedClient)189 change func(*attachedClient)
190 }{190 }{
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) }},
192 {name: "view revision", change: func(ac *attachedClient) {192 {name: "view revision", change: func(ac *attachedClient) {
193 ac.viewMu.Lock()193 ac.viewMu.Lock()
194 ac.view.revision++194 ac.view.revision++
M internal/usecase/daemon/attachment_parking_test.go +2 -2
@@ -103,7 +103,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
103 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)103 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport)
104 require.NoError(t, err)104 require.NoError(t, err)
105 oldToken := ac.resumeToken105 oldToken := ac.resumeToken
106- oldGeneration := ac.connectionGeneration.Load()106+ oldGeneration := ac.lifecycle.generationValue()
107 d.clientGone(sess, ac, oldTransport, false)107 d.clientGone(sess, ac, oldTransport, false)
108 108
109 newTransport := &closeTrackingTransport{}109 newTransport := &closeTrackingTransport{}
@@ -113,7 +113,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) {
113 require.Same(t, sess, resumed)113 require.Same(t, sess, resumed)
114 require.Same(t, ac, same)114 require.Same(t, ac, same)
115 require.NotEqual(t, oldToken, ac.resumeToken)115 require.NotEqual(t, oldToken, ac.resumeToken)
116- require.Greater(t, ac.connectionGeneration.Load(), oldGeneration)116+ require.Greater(t, ac.lifecycle.generationValue(), oldGeneration)
117 require.True(t, d.commitResumeClaim(ac))117 require.True(t, d.commitResumeClaim(ac))
118 d.mu.Lock()118 d.mu.Lock()
119 _, oldRetained := d.parked[oldToken]119 _, oldRetained := d.parked[oldToken]
M internal/usecase/daemon/attachment_render_test.go +7 -7
@@ -123,13 +123,13 @@ func TestAttachmentResizeKeepsPeerWindowAndExpandsSharedContent(t *testing.T) {
123 rc := sess.renderCoordinator()123 rc := sess.renderCoordinator()
124 lease := rc.attachmentLease(second)124 lease := rc.attachmentLease(second)
125 require.True(t, rc.markAttachmentReady(lease))125 require.True(t, rc.markAttachmentReady(lease))
126- token := sess.attachmentToken(second, secondTransport)126+ token := sess.captureAttachmentCapability(second, secondTransport)
127 token.lease = lease127 token.lease = lease
128- ticket, admitted := second.beginAttachmentEffect(token)128+ second.installTestAttachmentCapability(token)
129+ effect, admitted := second.beginAttachmentEffect(token)
129 require.True(t, admitted)130 require.True(t, admitted)
130- token.effect = ticket131+ require.True(t, d.resizeAttachmentForLease(effect, domain.Size{Cols: 120, Rows: 50}))
131- require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 120, Rows: 50}))132+ effect.End()
132- ticket.End()
133 133
134 tb.mu.Lock()134 tb.mu.Lock()
135 require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size)135 require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size)
@@ -166,7 +166,7 @@ func TestAttachmentFirstPaintDoesNotWaitForBlockedPeer(t *testing.T) {
166 awaitTestCompletion(t, oldTransport.entered, "slow attachment did not begin its blocked output")166 awaitTestCompletion(t, oldTransport.entered, "slow attachment did not begin its blocked output")
167 167
168 require.True(t, rc.markAttachmentReady(freshLease))168 require.True(t, rc.markAttachmentReady(freshLease))
169- token := sess.attachmentToken(fresh, newTransport)169+ token := sess.captureAttachmentCapability(fresh, newTransport)
170 token.lease = freshLease170 token.lease = freshLease
171 painted := make(chan bool, 1)171 painted := make(chan bool, 1)
172 go func() { painted <- d.firstPaintForTransition(token) }()172 go func() { painted <- d.firstPaintForTransition(token) }()
@@ -282,7 +282,7 @@ func TestAttachmentResizeUsesLatestClaimedSessionGeometry(t *testing.T) {
282 require.True(t, rc.markAttachmentReady(firstLease))282 require.True(t, rc.markAttachmentReady(firstLease))
283 require.True(t, rc.markAttachmentReady(secondLease))283 require.True(t, rc.markAttachmentReady(secondLease))
284 resize := func(ac *attachedClient, tr ports.Transport, size domain.Size) {284 resize := func(ac *attachedClient, tr ports.Transport, size domain.Size) {
285- token := sess.attachmentToken(ac, tr)285+ token := sess.captureAttachmentCapability(ac, tr)
286 d.handleAttachmentClientFrame(token, ports.Frame{286 d.handleAttachmentClientFrame(token, ports.Frame{
287 Type: ports.MsgResize,287 Type: ports.MsgResize,
288 Payload: mustMarshalResize(ports.Resize{Size: size}),288 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) {
25 transport := &closeTrackingTransport{}25 transport := &closeTrackingTransport{}
26 ac.replaceTransport(transport)26 ac.replaceTransport(transport)
27 rc := d.attachCoordinator(source, nil, ac, true)27 rc := d.attachCoordinator(source, nil, ac, true)
28- token := source.attachmentToken(ac, transport)28+ token := source.captureAttachmentCapability(ac, transport)
29 token.lease = rc.attachmentLease(ac)29 token.lease = rc.attachmentLease(ac)
30- ac.publishAttachmentCapability(token)30+ ac.installTestAttachmentCapability(token)
31 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})31 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
32 32
33 target := &session{33 target := &session{
@@ -75,9 +75,9 @@ func TestAttachmentTransitionTearsDownAfterCommittedIdentitySendFailure(t *testi
75 transport := &committedIdentityErrorTransport{}75 transport := &committedIdentityErrorTransport{}
76 ac.replaceTransport(transport)76 ac.replaceTransport(transport)
77 rc := d.attachCoordinator(source, nil, ac, true)77 rc := d.attachCoordinator(source, nil, ac, true)
78- token := source.attachmentToken(ac, transport)78+ token := source.captureAttachmentCapability(ac, transport)
79 token.lease = rc.attachmentLease(ac)79 token.lease = rc.attachmentLease(ac)
80- ac.publishAttachmentCapability(token)80+ ac.installTestAttachmentCapability(token)
81 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})81 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
82 82
83 target := &session{83 target := &session{
M internal/usecase/daemon/attachment_transition.go +12 -36
@@ -24,14 +24,15 @@ type attachmentTransitionRequest struct {
24 target *session24 target *session
25 next *attachedClient25 next *attachedClient
26 expectedTransport transportSnapshot26 expectedTransport transportSnapshot
27- sourceToken *attachmentConnectionToken27+ sourceCapability *attachmentCapability
28+ sourceEffect *attachmentEffect
28 action string29 action string
29 activateTargetTab bool30 activateTargetTab bool
30 targetTabIndex int31 targetTabIndex int
31 copySourceEnvironment bool32 copySourceEnvironment bool
32- // preserveAttachment commits attachment-local navigation state without33+ // preserveAttachment commits Attachment-local navigation state without
33- // changing session membership. The initiating connection token remains the34+ // changing Session membership. The initiating capability remains the exact
34- // exact authority for this mutation.35+ // authority for this mutation.
35 preserveAttachment bool36 preserveAttachment bool
36 expectedSourceTab *tab37 expectedSourceTab *tab
37 // transferExpectedSourceTab permits an installed visible floating pane only38 // transferExpectedSourceTab permits an installed visible floating pane only
@@ -44,33 +45,8 @@ type attachmentTransitionRequest struct {
44 attachmentEffectsFrozen bool45 attachmentEffectsFrozen bool
45 }46 }
46 47
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-
72 type attachmentTransitionResult struct {48 type attachmentTransitionResult struct {
73- published attachmentConnectionToken49+ published attachmentCapability
74 cleanups []renderLifecycleCleanup50 cleanups []renderLifecycleCleanup
75 sourceGeometrySession *session51 sourceGeometrySession *session
76 }52 }
@@ -103,7 +79,7 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
103 d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target {79 d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target {
104 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition80 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
105 }81 }
106- if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) {82+ if req.sourceCapability != nil && !req.sourceCapability.matchesConnectionSnapshot(source, req.next, req.expectedTransport) {
107 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition83 return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition
108 }84 }
109 req.preflighted = true85 req.preflighted = true
@@ -112,7 +88,7 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (
112 88
113 // freezeAttachmentTransition drains the snapshotted connection without any89 // freezeAttachmentTransition drains the snapshotted connection without any
114 // architecture lock held. The returned guard remains frozen through publication.90 // 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) {
116 if d.afterAttachmentEffectParticipantsSnapshotted != nil {92 if d.afterAttachmentEffectParticipantsSnapshotted != nil {
117 d.afterAttachmentEffectParticipantsSnapshotted(req.action, participants.clients)93 d.afterAttachmentEffectParticipantsSnapshotted(req.action, participants.clients)
118 }94 }
@@ -131,7 +107,7 @@ func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, par
131 }107 }
132 108
133 // publishAttachmentTransition revalidates the frozen source, creates an109 // 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.
135 func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) {111 func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
136 d.mu.Lock()112 d.mu.Lock()
137 defer d.mu.Unlock()113 defer d.mu.Unlock()
@@ -162,8 +138,8 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
162 if err != nil {138 if err != nil {
163 return attachmentTransitionResult{}, err139 return attachmentTransitionResult{}, err
164 }140 }
165- if req.sourceToken != nil {141+ if req.sourceEffect != nil {
166- d.endActionAttachmentEffect(req.sourceToken.effect, req.action)142+ d.endActionAttachmentEffect(req.sourceEffect, req.action)
167 }143 }
168 144
169 frozen, err := d.freezeAttachmentTransition(req, participants)145 frozen, err := d.freezeAttachmentTransition(req, participants)
@@ -183,7 +159,7 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
183 if req.ready && result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {159 if req.ready && result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 {
184 identityErr := errAttachmentTransition160 identityErr := errAttachmentTransition
185 if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted {161 if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted {
186- identityErr = d.sendCommittedRouteIdentityForAttachment(effect.connectionToken())162+ identityErr = d.sendCommittedRouteIdentityForAttachment(effect)
187 effect.End()163 effect.End()
188 }164 }
189 if identityErr != nil {165 if identityErr != nil {
M internal/usecase/daemon/attachment_transition_prelocked_test.go +2 -2
@@ -55,7 +55,7 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure
55 require.Equal(t, false, target.attachmentRegistered(next))55 require.Equal(t, false, target.attachmentRegistered(next))
56 require.Equal(t, true, target.attachmentRegistered(old))56 require.Equal(t, true, target.attachmentRegistered(old))
57 require.Same(t, source, next.currentSession())57 require.Same(t, source, next.currentSession())
58- require.Equal(t, uint64(0), next.connectionGeneration.Load())58+ require.Equal(t, uint64(0), next.lifecycle.generationValue())
59- require.Equal(t, uint64(0), old.connectionGeneration.Load())59+ require.Equal(t, uint64(0), old.lifecycle.generationValue())
60 require.Equal(t, 0, testAttachmentTabIndex(target))60 require.Equal(t, 0, testAttachmentTabIndex(target))
61 }61 }
M internal/usecase/daemon/attachment_transition_publish.go +12 -16
@@ -10,7 +10,7 @@ import (
10 // effect gate is frozen and d.mu is held. No target is created before this10 // effect gate is frozen and d.mu is held. No target is created before this
11 // succeeds.11 // succeeds.
12 func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool {12 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 {
14 return false14 return false
15 }15 }
16 d.notices.routingMu.Lock()16 d.notices.routingMu.Lock()
@@ -22,13 +22,13 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest
22 coordinator.mu.Lock()22 coordinator.mu.Lock()
23 defer coordinator.mu.Unlock()23 defer coordinator.mu.Unlock()
24 }24 }
25- if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) {25+ if !req.sourceCapability.currentInSessionAndLeaseLocked(req.source, req.next, coordinator) {
26 return false26 return false
27 }27 }
28 return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)28 return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab)
29 }29 }
30 30
31-func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionToken) bool {31+func (d *Daemon) sourceAttachmentCapabilityCurrentFrozen(token attachmentCapability) bool {
32 if token.sess == nil || token.ac == nil {32 if token.sess == nil || token.ac == nil {
33 return false33 return false
34 }34 }
@@ -46,8 +46,7 @@ func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionTo
46 coordinator.mu.Lock()46 coordinator.mu.Lock()
47 defer coordinator.mu.Unlock()47 defer coordinator.mu.Unlock()
48 }48 }
49- req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport}49+ return token.currentInSessionAndLeaseLocked(token.sess, token.ac, coordinator)
50- return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
51 }50 }
52 51
53 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {52 func transitionSourceTabCurrentLocked(source *session, expected *tab) bool {
@@ -84,7 +83,6 @@ type attachmentPublication struct {
84 targetCoordinator *renderCoordinator83 targetCoordinator *renderCoordinator
85 sourceCoordinator *renderCoordinator84 sourceCoordinator *renderCoordinator
86 releaseCoordinators func()85 releaseCoordinators func()
87- nextGeneration uint64
88 }86 }
89 87
90 func (p *attachmentPublication) unlockCoordinators() {88 func (p *attachmentPublication) unlockCoordinators() {
@@ -124,20 +122,20 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
124 if sourceCore == nil || targetCore == nil || !req.preflighted || !req.attachmentEffectsFrozen ||122 if sourceCore == nil || targetCore == nil || !req.preflighted || !req.attachmentEffectsFrozen ||
125 req.next == nil || d.closing || d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target ||123 req.next == nil || d.closing || d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target ||
126 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||124 req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) ||
127- req.preserveAttachment && req.sourceToken == nil {125+ req.preserveAttachment && req.sourceCapability == nil {
128 return nil, errAttachmentTransition126 return nil, errAttachmentTransition
129 }127 }
130 128
131 sourceCoordinator := sourceCore.coordinator.Load()129 sourceCoordinator := sourceCore.coordinator.Load()
132 targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(req.target)130 targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(req.target)
133- // transitionSourceTokenCurrentLocked also reads the source lease. Hold the131+ // Capability validation also reads the source lease. Hold the
134 // source coordinator lock before calling it, following session -> coordinator132 // source coordinator lock before calling it, following session -> coordinator
135 // ordering and preventing a lease rebind from racing validation.133 // ordering and preventing a lease rebind from racing validation.
136 releaseCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator)134 releaseCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator)
137 if d.afterAttachmentTransitionCoordinatorsLocked != nil {135 if d.afterAttachmentTransitionCoordinatorsLocked != nil {
138 d.afterAttachmentTransitionCoordinatorsLocked()136 d.afterAttachmentTransitionCoordinatorsLocked()
139 }137 }
140- if req.sourceToken != nil && !transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) {138+ if req.sourceCapability != nil && !req.sourceCapability.currentInSessionAndLeaseLocked(source, req.next, sourceCoordinator) {
141 releaseCoordinators()139 releaseCoordinators()
142 return nil, errAttachmentTransition140 return nil, errAttachmentTransition
143 }141 }
@@ -155,7 +153,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR
155 }153 }
156 if !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) ||154 if !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) ||
157 req.activateTargetTab && !req.target.validTargetTabLocked(req.targetTabIndex) ||155 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) {
159 releaseCoordinators()157 releaseCoordinators()
160 return nil, errAttachmentTransition158 return nil, errAttachmentTransition
161 }159 }
@@ -196,7 +194,6 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
196 // attachment claim with the replacement transport's current size.194 // attachment claim with the replacement transport's current size.
197 req.target.claimGeometryOwnerLocked(req.next, req.next.geometrySnapshot())195 req.target.claimGeometryOwnerLocked(req.next, req.next.geometrySnapshot())
198 }196 }
199- publication.nextGeneration = req.next.connectionGeneration.Add(1)
200 req.next.setSession(req.target)197 req.next.setSession(req.target)
201 }198 }
202 199
@@ -214,14 +211,13 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
214 lease = publication.targetCoordinator.attachWithReadinessLocked(req.next, req.ready)211 lease = publication.targetCoordinator.attachWithReadinessLocked(req.next, req.ready)
215 }212 }
216 }213 }
217- result.published = attachmentConnectionToken{214+ result.published = attachmentCapability{
218 sess: req.target, ac: req.next,215 sess: req.target, ac: req.next,
219- generation: publication.nextGeneration,216+ transport: req.expectedTransport, lease: lease,
220- transport: req.expectedTransport, lease: lease,
221 rebase: publication.source != req.target,217 rebase: publication.source != req.target,
222 }218 }
223 if req.attachmentEffectsFrozen {219 if req.attachmentEffectsFrozen {
224- req.next.publishFrozenAttachmentCapability(result.published)220+ result.published = req.next.publishFrozenAttachmentCapability(result.published)
225 }221 }
226 return result222 return result
227 }223 }
@@ -229,7 +225,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att
229 func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {225 func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult {
230 if publication.req.preserveAttachment {226 if publication.req.preserveAttachment {
231 d.applyTargetStateLocked(publication)227 d.applyTargetStateLocked(publication)
232- return attachmentTransitionResult{published: *publication.req.sourceToken}228+ return attachmentTransitionResult{published: *publication.req.sourceCapability}
233 }229 }
234 publishAttachmentOwnershipLocked(publication)230 publishAttachmentOwnershipLocked(publication)
235 d.applyTargetStateLocked(publication)231 d.applyTargetStateLocked(publication)
M internal/usecase/daemon/attention.go +9 -5
@@ -50,16 +50,20 @@ func (d *Daemon) noteAttention(sess *session, tb *tab) {
50 // happened — it also covers "no target exists", which is routine and not an50 // happened — it also covers "no target exists", which is routine and not an
51 // error. Only a failure to reach a target that does exist is a genuine error.51 // error. Only a failure to reach a target that does exist is a genuine error.
52 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {52 func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) error {
53- return d.jumpAttentionForAttachment(sess, ac, attachmentConnectionToken{})53+ return d.jumpAttentionForAttachment(sess, ac, nil)
54 }54 }
55 55
56-func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, token attachmentConnectionToken) error {56+func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, effect *attachmentEffect) error {
57 if sess == nil || ac == nil {57 if sess == nil || ac == nil {
58 return nil58 return nil
59 }59 }
60 if idx, ok := oldestAttentionTab(sess); ok {60 if idx, ok := oldestAttentionTab(sess); ok {
61 if sess.switchAttachmentTab(ac, idx) {61 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)
63 d.invalidateRender(sess, ac, true, "attention.go")67 d.invalidateRender(sess, ac, true, "attention.go")
64 }68 }
65 return nil69 return nil
@@ -70,10 +74,10 @@ func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, t
70 return nil74 return nil
71 }75 }
72 pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}76 pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex}
73- if token.ac == nil {77+ if effect == nil {
74 return d.switchToTarget(sess, ac, pickerTarget)78 return d.switchToTarget(sess, ac, pickerTarget)
75 }79 }
76- return d.switchActiveTargetForAttachment(token, pickerTarget)80+ return d.switchActiveTargetForAttachment(effect, pickerTarget)
77 }81 }
78 82
79 func oldestAttentionTab(sess *session) (int, bool) {83 func oldestAttentionTab(sess *session) (int, bool) {
M internal/usecase/daemon/client.go +58 -57
@@ -42,14 +42,11 @@ type attachedClient struct {
42 terminalCapabilities ports.TerminalCapabilities42 terminalCapabilities ports.TerminalCapabilities
43 navigationCapabilities ports.NavigationCapabilities43 navigationCapabilities ports.NavigationCapabilities
44 startupOverlay ports.StartupOverlay44 startupOverlay ports.StartupOverlay
45- // connectionGeneration is the wire-facing capability generation. attachmentEffects is45+ // lifecycle is the sole authority for attachment capability publication,
46- // the linearization gate for every attachment-bound observable operation. A role46+ // effect admission, transition freeze/drain, and connection generation.
47- // transition freezes and drains this gate before changing either generation47+ lifecycle attachmentLifecycle
48- // or the session/coordinator registries.48+ resumeCapable bool
49- connectionGeneration atomic.Uint6449+ resumeToken uint64
50- attachmentEffects attachmentEffectGate
51- resumeCapable bool
52- resumeToken uint64
53 // resumeClaimToken is non-zero only between a parked resume claim and its50 // resumeClaimToken is non-zero only between a parked resume claim and its
54 // successful Welcome. It lets a failed pre-claim handshake restore the old51 // successful Welcome. It lets a failed pre-claim handshake restore the old
55 // credential instead of consuming it.52 // credential instead of consuming it.
@@ -111,9 +108,9 @@ type attachedClient struct {
111 // routeSessionPurge distinguishes a brand-new session from a restored108 // routeSessionPurge distinguishes a brand-new session from a restored
112 // stopped session when failed-handshake cleanup removes the live instance.109 // stopped session when failed-handshake cleanup removes the live instance.
113 routeSessionPurge bool110 routeSessionPurge bool
114- // beforeAttachmentTokenValidation is a deterministic lifecycle-race seam111+ // beforeAttachmentCapabilityValidation is a deterministic lifecycle-race Seam
115 // used only by package tests.112 // used only by package tests.
116- beforeAttachmentTokenValidation func()113+ beforeAttachmentCapabilityValidation func()
117 }114 }
118 115
119 type cursorOut struct {116 type cursorOut struct {
@@ -474,7 +471,7 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po
474 // attachment's current transport incarnation and admits ticket's interruptible471 // attachment's current transport incarnation and admits ticket's interruptible
475 // transport send. The caller must already hold ac.sendMu and, on success, owns472 // transport send. The caller must already hold ac.sendMu and, on success, owns
476 // both the send and its matching ticket.endTransportSend.473 // 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 {
478 if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {475 if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {
479 return errTransportReplaced476 return errTransportReplaced
480 }477 }
@@ -484,7 +481,7 @@ func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSna
484 return nil481 return nil
485 }482 }
486 483
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 {
488 ac.sendMu.Lock()485 ac.sendMu.Lock()
489 defer ac.sendMu.Unlock()486 defer ac.sendMu.Unlock()
490 // A attachment-bound send requires a live ticket, and reports every rejection,487 // A attachment-bound send requires a live ticket, and reports every rejection,
@@ -862,37 +859,36 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) {
862 // firstPaintForTransition is the only active post-transition paint entry859 // firstPaintForTransition is the only active post-transition paint entry
863 // point. It rejects a stale attachment capability before rebasing output and carries860 // point. It rejects a stale attachment capability before rebasing output and carries
864 // the exact coordinator lease through every resize and render effect.861 // the exact coordinator lease through every resize and render effect.
865-func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {862+func (d *Daemon) firstPaintForTransition(capability attachmentCapability) bool {
866- if !token.attachmentCurrent() {863+ if !capability.current() {
867 return false864 return false
868 }865 }
869- ticket, admitted := token.ac.beginAttachmentEffect(token)866+ effect, admitted := capability.ac.beginAttachmentEffect(capability)
870 if !admitted {867 if !admitted {
871 return false868 return false
872 }869 }
873- token.effect = ticket870+ defer effect.End()
874- defer token.endAttachmentEffect()
875 if d.afterAttachmentEffectAdmitted != nil {871 if d.afterAttachmentEffectAdmitted != nil {
876- d.afterAttachmentEffectAdmitted(token)872+ d.afterAttachmentEffectAdmitted(effect.capability())
877 }873 }
878- if token.rebase {874+ if effect.rebase {
879 if d.beforeFirstPaintSendWait != nil {875 if d.beforeFirstPaintSendWait != nil {
880- d.beforeFirstPaintSendWait(token)876+ d.beforeFirstPaintSendWait(effect.capability())
881 }877 }
882- token.ac.sendMu.Lock()878+ effect.ac.sendMu.Lock()
883- if token.ac.renderStages.handoffRebase != nil {879+ if effect.ac.renderStages.handoffRebase != nil {
884- token.ac.renderStages.handoffRebase()880+ effect.ac.renderStages.handoffRebase()
885 }881 }
886- token.ac.rebaseOutput()882+ effect.ac.rebaseOutput()
887 // Captures belong to the old session even when pane-local IDs happen883 // Captures belong to the old session even when pane-local IDs happen
888 // to be reused by the destination.884 // to be reused by the destination.
889- token.ac.captureFrames = nil885+ effect.ac.captureFrames = nil
890- token.ac.sendMu.Unlock()886+ effect.ac.sendMu.Unlock()
891 }887 }
892- if token.sess == nil {888+ if effect.sess == nil {
893 return false889 return false
894 }890 }
895- d.firstPaintWithLease(token.sess, token.ac, token.lease, true)891+ d.firstPaintWithLease(effect.sess, effect.ac, effect.lease, true)
896 return true892 return true
897 }893 }
898 894
@@ -958,25 +954,25 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
958 d.invalidateRender(sess, ac, true, "client.go")954 d.invalidateRender(sess, ac, true, "client.go")
959 }955 }
960 956
961-func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) {957+func (d *Daemon) applyThemeForAttachment(effect *attachmentEffect, msg ports.Theme) {
962- if !token.attachmentEffectCurrent() {958+ if !effect.current() {
963 return959 return
964 }960 }
965 clientTheme := themeFromMessage(msg)961 clientTheme := themeFromMessage(msg)
966- token.ac.setClientTheme(clientTheme)962+ effect.ac.setClientTheme(clientTheme)
967- sess := token.sess963+ sess := effect.sess
968- if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {964+ if sess == nil || !effect.current() || !d.applyHostTheme(sess, effect.ac, clientTheme, false) {
969 return965 return
970 }966 }
971- if !token.attachmentEffectCurrent() {967+ if !effect.current() {
972 return968 return
973 }969 }
974 rc := sess.renderCoordinator()970 rc := sess.renderCoordinator()
975- if token.lease == nil || rc == nil {971+ if effect.lease == nil || rc == nil {
976- d.invalidateRender(sess, token.ac, true, "client.go")972+ d.invalidateRender(sess, effect.ac, true, "client.go")
977 return973 return
978 }974 }
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"})
980 }976 }
981 977
982 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {978 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) {
987 // attachment the latest shared tab/PTY geometry claimant. Callers hold the983 // attachment the latest shared tab/PTY geometry claimant. Callers hold the
988 // attachment effect admission; output state is rebased before any redraw so984 // attachment effect admission; output state is rebased before any redraw so
989 // every subsequent frame starts a new epoch.985 // 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 {
991 geometry := domain.Geometry{Size: size}987 geometry := domain.Geometry{Size: size}
992- if token.ac != nil {988+ if effect != nil && effect.ac != nil {
993 // Direct size-only callers do not have a fresh pixel pair. Preserve the989 // Direct size-only callers do not have a fresh pixel pair. Preserve the
994 // attachment's last complete pair instead of downgrading its claim to990 // attachment's last complete pair instead of downgrading its claim to
995 // cell-only geometry.991 // cell-only geometry.
996- geometry = token.ac.geometrySnapshot()992+ geometry = effect.ac.geometrySnapshot()
997 geometry.Size = size993 geometry.Size = size
998 }994 }
999- return d.resizeAttachmentGeometryForLease(token, geometry)995+ return d.resizeAttachmentGeometryForLease(effect, geometry)
1000 }996 }
1001 997
1002-func (d *Daemon) resizeAttachmentGeometryForLease(token attachmentConnectionToken, geometry domain.Geometry) bool {998+func (d *Daemon) resizeAttachmentGeometryForLease(effect *attachmentEffect, geometry domain.Geometry) bool {
1003 geometry = geometry.NormalizePixels()999 geometry = geometry.NormalizePixels()
1004- if token.ac == nil || !geometry.Valid() || !token.attachmentEffectCurrent() {1000+ if !effect.current() || effect.ac == nil || !geometry.Valid() {
1005 return false1001 return false
1006 }1002 }
1007- ac := token.ac1003+ ac := effect.ac
1008 ac.sendMu.Lock()1004 ac.sendMu.Lock()
1009- if !token.attachmentEffectCurrent() || !ac.transportSnapshotCurrent(token.transport) {1005+ if !effect.current() || !ac.transportSnapshotCurrent(effect.transport) {
1010 ac.sendMu.Unlock()1006 ac.sendMu.Unlock()
1011 return false1007 return false
1012 }1008 }
@@ -1036,7 +1032,7 @@ func (d *Daemon) resizeAttachmentGeometryForLease(token attachmentConnectionToke
1036 d.registerPreviewForSelection(ac)1032 d.registerPreviewForSelection(ac)
1037 }1033 }
1038 1034
1039- sess := token.sess1035+ sess := effect.sess
1040 if sess == nil {1036 if sess == nil {
1041 return false1037 return false
1042 }1038 }
@@ -1056,8 +1052,8 @@ func (d *Daemon) resizeAttachmentGeometryForLease(token attachmentConnectionToke
1056 }1052 }
1057 // Rendering is attachment-scoped. Schedule it through the coordinator so1053 // Rendering is attachment-scoped. Schedule it through the coordinator so
1058 // rapid resize frames coalesce under the attachment lease and deadline.1054 // rapid resize frames coalesce under the attachment lease and deadline.
1059- return rc.scheduleResizeForLease(geometry.Size, ac, token.lease, func(uint64) {1055+ return rc.scheduleResizeForLease(geometry.Size, ac, effect.lease, func(uint64) {
1060- go d.paint(sess, ac, true, token.lease)1056+ go d.paint(sess, ac, true, effect.lease)
1061 }) != 01057 }) != 0
1062 }1058 }
1063 1059
@@ -1071,37 +1067,42 @@ func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice po
1071 return1067 return
1072 }1068 }
1073 tr := ac.transport()1069 tr := ac.transport()
1074- token := sess.attachmentToken(ac, tr)1070+ token := sess.captureAttachmentCapability(ac, tr)
1075 if token.lease == nil {1071 if token.lease == nil {
1076 // Direct test/headless callers retain pointer-based routing. Production1072 // Direct test/headless callers retain pointer-based routing. Production
1077 // client frames always carry the exact lease through the attachment path below.1073 // client frames always carry the exact lease through the attachment path below.
1078 d.handleClientNoticeDirect(sess, ac, notice)1074 d.handleClientNoticeDirect(sess, ac, notice)
1079 return1075 return
1080 }1076 }
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)
1082 }1083 }
1083 1084
1084-func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken, notice ports.ClientNotice) {1085+func (d *Daemon) handleClientNoticeForAttachment(effect *attachmentEffect, notice ports.ClientNotice) {
1085 d.notices.routingMu.Lock()1086 d.notices.routingMu.Lock()
1086- if !token.attachmentEffectCurrent() {1087+ if !effect.current() {
1087 d.notices.routingMu.Unlock()1088 d.notices.routingMu.Unlock()
1088 return1089 return
1089 }1090 }
1090 if d.notices.beforeClientNoticeMutation != nil {1091 if d.notices.beforeClientNoticeMutation != nil {
1091 d.notices.beforeClientNoticeMutation()1092 d.notices.beforeClientNoticeMutation()
1092 }1093 }
1093- if !token.attachmentEffectCurrent() {1094+ if !effect.current() {
1094 d.notices.routingMu.Unlock()1095 d.notices.routingMu.Unlock()
1095 return1096 return
1096 }1097 }
1097- if token.sess == nil {1098+ if effect.sess == nil {
1098 d.notices.routingMu.Unlock()1099 d.notices.routingMu.Unlock()
1099 return1100 return
1100 }1101 }
1101- repaint := d.mutateClientNotice(token.sess, token.ac, notice)1102+ repaint := d.mutateClientNotice(effect.sess, effect.ac, notice)
1102 d.notices.routingMu.Unlock()1103 d.notices.routingMu.Unlock()
1103- if repaint && token.attachmentEffectCurrent() {1104+ if repaint && effect.current() {
1104- d.repaintForNotice(token.ac)1105+ d.repaintForNotice(effect.ac)
1105 }1106 }
1106 }1107 }
1107 1108
M internal/usecase/daemon/client_frame_routing.go +67 -74
@@ -14,26 +14,26 @@ const connectionSnapshotAttempts = 8
14 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a14 // currentAttachmentConnection retries the lock-free session-to-role snapshot when a
15 // handoff lands between those reads. The attachment gate still performs the final15 // handoff lands between those reads. The attachment gate still performs the final
16 // mutation admission after this function returns.16 // 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) {
18 for range connectionSnapshotAttempts {18 for range connectionSnapshotAttempts {
19 if !ac.currentTransportIs(tr) {19 if !ac.currentTransportIs(tr) {
20 break20 break
21 }21 }
22 sess := ac.currentAttachmentSession()22 sess := ac.currentAttachmentSession()
23 if sess == nil {23 if sess == nil {
24- return nil, attachmentConnectionToken{}, false24+ return nil, attachmentCapability{}, false
25 }25 }
26 if d.afterConnectionSessionSnapshot != nil {26 if d.afterConnectionSessionSnapshot != nil {
27 d.afterConnectionSessionSnapshot(sess)27 d.afterConnectionSessionSnapshot(sess)
28 }28 }
29- token := attachmentToken(sess, ac, tr)29+ token := captureAttachmentCapability(sess, ac, tr)
30 if token.ac == nil || ac.currentAttachmentSession() != sess {30 if token.ac == nil || ac.currentAttachmentSession() != sess {
31 runtime.Gosched()31 runtime.Gosched()
32 continue32 continue
33 }33 }
34 return sess, token, true34 return sess, token, true
35 }35 }
36- return nil, attachmentConnectionToken{}, false36+ return nil, attachmentCapability{}, false
37 }37 }
38 38
39 // runConnLoop is the per-connection input router: it pumps client messages39 // runConnLoop is the per-connection input router: it pumps client messages
@@ -74,57 +74,56 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
74 }74 }
75 75
76 // handleAttachmentClientFrame owns the attached-client protocol.76 // handleAttachmentClientFrame owns the attached-client protocol.
77-func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f ports.Frame) bool {77+func (d *Daemon) handleAttachmentClientFrame(capability attachmentCapability, f ports.Frame) bool {
78- if !token.attachmentEffectCurrent() {78+ if !capability.current() {
79 return false79 return false
80 }80 }
81 if d.afterAttachmentFrameDispatch != nil {81 if d.afterAttachmentFrameDispatch != nil {
82- d.afterAttachmentFrameDispatch(token)82+ d.afterAttachmentFrameDispatch(capability)
83 }83 }
84- ticket, admitted := token.ac.beginAttachmentEffect(token)84+ effect, admitted := capability.ac.beginAttachmentEffect(capability)
85 if !admitted {85 if !admitted {
86 return false86 return false
87 }87 }
88- token.effect = ticket88+ defer effect.End()
89- defer token.endAttachmentEffect()
90 if d.afterAttachmentEffectAdmitted != nil {89 if d.afterAttachmentEffectAdmitted != nil {
91- d.afterAttachmentEffectAdmitted(token)90+ d.afterAttachmentEffectAdmitted(effect.capability())
92 }91 }
93 switch f.Type {92 switch f.Type {
94 case ports.MsgInput:93 case ports.MsgInput:
95 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {94 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)
97 }96 }
98 case ports.MsgResize:97 case ports.MsgResize:
99- if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() {98+ if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && effect.current() {
100- d.resizeAttachmentGeometryForLease(token, rz.Geometry())99+ d.resizeAttachmentGeometryForLease(effect, rz.Geometry())
101 }100 }
102 case ports.MsgTheme:101 case ports.MsgTheme:
103 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {102 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
104- d.applyThemeForAttachment(token, th)103+ d.applyThemeForAttachment(effect, th)
105 }104 }
106 case ports.MsgImagePush:105 case ports.MsgImagePush:
107 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {106 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
108- d.handleSequencedImagePushForAttachment(token, ip.InputSeq, ip)107+ d.handleSequencedImagePushForAttachment(effect, ip.InputSeq, ip)
109 }108 }
110 case ports.MsgClientNotice:109 case ports.MsgClientNotice:
111 if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {110 if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil {
112- d.handleClientNoticeForAttachment(token, notice)111+ d.handleClientNoticeForAttachment(effect, notice)
113 } else {112 } else {
114 d.log.Warn("malformed client notice", "err", derr)113 d.log.Warn("malformed client notice", "err", derr)
115 }114 }
116 case ports.MsgDetach:115 case ports.MsgDetach:
117- if token.attachmentEffectCurrent() {116+ if effect.current() {
118- d.clientGoneForAttachment(token, true)117+ d.clientGoneForAttachment(effect, true)
119 return true118 return true
120 }119 }
121 case ports.MsgAck:120 case ports.MsgAck:
122 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {121 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)
124 }123 }
125 case ports.MsgOutputResetRequest:124 case ports.MsgOutputResetRequest:
126 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {125 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {
127- d.resetOutput(token)126+ d.resetOutput(effect)
128 }127 }
129 case ports.MsgSamePeerSwitchRequest:128 case ports.MsgSamePeerSwitchRequest:
130 request, derr := ports.UnmarshalSamePeerSwitchRequest(f.Payload)129 request, derr := ports.UnmarshalSamePeerSwitchRequest(f.Payload)
@@ -132,27 +131,27 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
132 d.log.Warn("malformed same-peer switch request", "err", derr)131 d.log.Warn("malformed same-peer switch request", "err", derr)
133 break132 break
134 }133 }
135- d.switchSamePeerForAttachment(token, request)134+ d.switchSamePeerForAttachment(effect, request)
136 case ports.MsgParkedRouteRequest:135 case ports.MsgParkedRouteRequest:
137 request, derr := ports.UnmarshalParkedRouteRequest(f.Payload)136 request, derr := ports.UnmarshalParkedRouteRequest(f.Payload)
138 if derr != nil {137 if derr != nil {
139 d.log.Warn("malformed parked-route request", "err", derr)138 d.log.Warn("malformed parked-route request", "err", derr)
140 break139 break
141 }140 }
142- d.handleParkedRouteRequest(token, request)141+ d.handleParkedRouteRequest(effect, request)
143 case ports.MsgRecentRouteSnapshot:142 case ports.MsgRecentRouteSnapshot:
144 snapshot, derr := ports.UnmarshalRecentRouteSnapshot(f.Payload)143 snapshot, derr := ports.UnmarshalRecentRouteSnapshot(f.Payload)
145 if derr != nil {144 if derr != nil {
146 d.log.Warn("malformed recent route snapshot", "err", derr)145 d.log.Warn("malformed recent route snapshot", "err", derr)
147 break146 break
148 }147 }
149- replayIdentity := token.ac.setRouteSnapshot(snapshot)148+ replayIdentity := effect.ac.setRouteSnapshot(snapshot)
150- d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-snapshot")149+ d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-snapshot")
151 if !replayIdentity {150 if !replayIdentity {
152 break151 break
153 }152 }
154- if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil {153+ if err := d.sendCommittedRouteIdentityForAttachment(effect); err != nil {
155- d.detachOnAttachmentSendError(token, token.transport.transport)154+ d.detachOnAttachmentSendError(effect.capability(), effect.transport.transport)
156 }155 }
157 case ports.MsgRouteAttentionSubscription:156 case ports.MsgRouteAttentionSubscription:
158 subscription, derr := ports.UnmarshalRouteAttentionSubscription(f.Payload)157 subscription, derr := ports.UnmarshalRouteAttentionSubscription(f.Payload)
@@ -160,8 +159,8 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
160 d.log.Warn("malformed route attention subscription", "err", derr)159 d.log.Warn("malformed route attention subscription", "err", derr)
161 break160 break
162 }161 }
163- token.ac.setRouteAttentionSubscription(subscription)162+ effect.ac.setRouteAttentionSubscription(subscription)
164- d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-attention")163+ d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-attention")
165 case ports.MsgRouteNavigationFailure:164 case ports.MsgRouteNavigationFailure:
166 failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload)165 failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload)
167 if derr != nil {166 if derr != nil {
@@ -181,29 +180,29 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
181 case ports.RouteFailureNoSuchRoute:180 case ports.RouteFailureNoSuchRoute:
182 message = "that recent route no longer exists"181 message = "that recent route no longer exists"
183 }182 }
184- d.notify(token.sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil)183+ d.notify(effect.sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil)
185 case ports.MsgCommand:184 case ports.MsgCommand:
186 request, derr := ports.UnmarshalCommandRequest(f.Payload)185 request, derr := ports.UnmarshalCommandRequest(f.Payload)
187 if derr != nil {186 if derr != nil {
188 return false187 return false
189 }188 }
190- result := d.executeAttachedCommand(token, request)189+ result := d.executeAttachedCommand(effect, request)
191- resultToken, ok := d.commandResultToken(token)190+ resultEffect, ok := d.commandResultEffect(effect)
192 if !ok {191 if !ok {
193 // A remote handoff or explicit detach deliberately retired the old192 // A remote handoff or explicit detach deliberately retired the old
194 // attachment; it must not be treated as a stale-result send error.193 // attachment; it must not be treated as a stale-result send error.
195 return true194 return true
196 }195 }
197- if err := resultToken.sendControl(frameCommandResult(result)); err != nil {196+ if err := resultEffect.sendControl(frameCommandResult(result)); err != nil {
198- resultToken.endAttachmentEffect()197+ resultEffect.End()
199- d.detachOnAttachmentSendError(resultToken, resultToken.transport.transport)198+ d.detachOnAttachmentSendError(resultEffect.capability(), resultEffect.transport.transport)
200 return true199 return true
201 }200 }
202- resultToken.endAttachmentEffect()201+ resultEffect.End()
203 case ports.MsgPing:202 case ports.MsgPing:
204- if err := token.sendControl(framePong()); err != nil {203+ if err := effect.sendControl(framePong()); err != nil {
205- token.endAttachmentEffect()204+ effect.End()
206- d.detachOnAttachmentSendError(token, token.transport.transport)205+ d.detachOnAttachmentSendError(effect.capability(), effect.transport.transport)
207 return true206 return true
208 }207 }
209 default:208 default:
@@ -213,34 +212,28 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
213 return false212 return false
214 }213 }
215 214
216-// commandResultToken renews the attachment effect after a local navigation215+// commandResultEffect renews admission after a local navigation transition
217-// transition ends the command's initiating effect. A retired transport/session216+// ends the command's initiating effect. A retired Transport or Session means
218-// means the command deliberately handed off or detached and has no result to217+// the command deliberately handed off or detached and has no result carriage.
219-// send on the old carriage.218+func (d *Daemon) commandResultEffect(effect *attachmentEffect) (*attachmentEffect, bool) {
220-func (d *Daemon) commandResultToken(token attachmentConnectionToken) (attachmentConnectionToken, bool) {219+ if effect == nil || effect.ac == nil || !effect.ac.currentTransportIs(effect.transport.transport) {
221- if token.ac == nil || !token.ac.currentTransportIs(token.transport.transport) {220+ return nil, false
222- return attachmentConnectionToken{}, false
223 }221 }
224- sess := token.ac.currentAttachmentSession()222+ if effect.current() && effect.attachmentCapability.current() {
225- if sess == nil {223+ return effect, true
226- return attachmentConnectionToken{}, false
227- }
228- resultToken := attachmentToken(sess, token.ac, token.transport.transport)
229- if resultToken.ac == nil {
230- return attachmentConnectionToken{}, false
231 }224 }
232- if token.effect != nil && !token.effect.ended.Load() && token.current() {225+ sess := effect.ac.currentAttachmentSession()
233- return token, true226+ if sess == nil {
227+ return nil, false
234 }228 }
235- ticket, admitted := token.ac.beginAttachmentEffect(resultToken)229+ capability := captureAttachmentCapability(sess, effect.ac, effect.transport.transport)
236- if !admitted {230+ if capability.ac == nil {
237- return attachmentConnectionToken{}, false231+ return nil, false
238 }232 }
239- resultToken.effect = ticket233+ return effect.ac.beginAttachmentEffect(capability)
240- return resultToken, true
241 }234 }
242 235
243-func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request ports.CommandRequest) ports.CommandResult {236+func (d *Daemon) executeAttachedCommand(effect *attachmentEffect, request ports.CommandRequest) ports.CommandResult {
244 result := ports.CommandResult{RequestID: request.RequestID}237 result := ports.CommandResult{RequestID: request.RequestID}
245 if request.Version != ports.ProtocolVersion {238 if request.Version != ports.ProtocolVersion {
246 result.Code = ports.ErrInvalidCommandArgs239 result.Code = ports.ErrInvalidCommandArgs
@@ -267,8 +260,8 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
267 result.Text = "attached commands cannot override their active session target"260 result.Text = "attached commands cannot override their active session target"
268 return result261 return result
269 }262 }
270- sess := token.sess263+ sess := effect.sess
271- if sess == nil || !token.attachmentEffectCurrent() {264+ if sess == nil || !effect.current() {
272 result.Code = ports.ErrNoSuchTarget265 result.Code = ports.ErrNoSuchTarget
273 result.Text = "attached session is no longer active"266 result.Text = "attached session is no longer active"
274 return result267 return result
@@ -292,7 +285,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
292 // The frame's attachment token is the target capability. No registry/name lookup is285 // The frame's attachment token is the target capability. No registry/name lookup is
293 // performed, so the command cannot escape to another remote session.286 // performed, so the command cannot escape to another remote session.
294 err := sess.runMutation(func() error {287 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)
296 })289 })
297 if err == nil {290 if err == nil {
298 result.OK = true291 result.OK = true
@@ -311,13 +304,13 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request
311 // resetOutput rebases only the exact attachment and transport admitted304 // resetOutput rebases only the exact attachment and transport admitted
312 // by the frame router. sendMu serializes the rebase with every output send;305 // by the frame router. sendMu serializes the rebase with every output send;
313 // transport revalidation under that lock rejects a link replaced while waiting.306 // transport revalidation under that lock rejects a link replaced while waiting.
314-func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {307+func (d *Daemon) resetOutput(effect *attachmentEffect) bool {
315- ac := token.ac308+ if !effect.current() || effect.ac == nil {
316- if ac == nil || token.effect == nil || token.effect.ended.Load() {
317 return false309 return false
318 }310 }
311+ ac := effect.ac
319 ac.sendMu.Lock()312 ac.sendMu.Lock()
320- if token.effect.ended.Load() || !token.attachmentCurrent() || ac.output == nil {313+ if !effect.current() || ac.output == nil {
321 ac.sendMu.Unlock()314 ac.sendMu.Unlock()
322 return false315 return false
323 }316 }
@@ -325,15 +318,15 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
325 ac.pipelineCache = composeCacheInput{}318 ac.pipelineCache = composeCacheInput{}
326 ac.pipelineScratch = composeCacheInput{}319 ac.pipelineScratch = composeCacheInput{}
327 ac.sendMu.Unlock()320 ac.sendMu.Unlock()
328- go d.paint(token.sess, ac, true, token.lease)321+ go d.paint(effect.sess, ac, true, effect.lease)
329 return true322 return true
330 }323 }
331 324
332-func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64) bool {325+func (d *Daemon) ackOutput(effect *attachmentEffect, epoch, state uint64) bool {
333- ac := token.ac326+ if !effect.current() || effect.ac == nil {
334- if token.effect == nil || token.effect.ended.Load() {
335 return false327 return false
336 }328 }
329+ ac := effect.ac
337 ac.sendMu.Lock()330 ac.sendMu.Lock()
338 if ac.output == nil {331 if ac.output == nil {
339 ac.sendMu.Unlock()332 ac.sendMu.Unlock()
@@ -342,8 +335,8 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64)
342 acknowledged := ac.output.ack(epoch, state)335 acknowledged := ac.output.ack(epoch, state)
343 ac.sendMu.Unlock()336 ac.sendMu.Unlock()
344 if acknowledged {337 if acknowledged {
345- if rc := token.sess.renderCoordinator(); rc != nil {338+ if rc := effect.sess.renderCoordinator(); rc != nil {
346- rc.notifyAckForLease(token.lease)339+ rc.notifyAckForLease(effect.lease)
347 }340 }
348 }341 }
349 return acknowledged342 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
39 d.finishClientGone(sess, ac, failed, explicit, notice)39 d.finishClientGone(sess, ac, failed, explicit, notice)
40 }40 }
41 41
42-func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explicit bool) bool {42+func (d *Daemon) clientGoneForAttachment(effect *attachmentEffect, explicit bool) bool {
43- if token.effect == nil {43+ if effect == nil {
44 return false44 return false
45 }45 }
46- token.effect.bindActionEnd(d, "detach")46+ effect.bindActionEnd(d, "detach")
47- token.effect.End()47+ effect.End()
48- sess := token.sess48+ capability := effect.capability()
49+ sess := capability.sess
49 if sess == nil {50 if sess == nil {
50 return false51 return false
51 }52 }
52 var parkingToken uint6453 var parkingToken uint64
53 if !explicit {54 if !explicit {
54- parkingToken = d.markParkingInFlight(sess, token.ac)55+ parkingToken = d.markParkingInFlight(sess, capability.ac)
55 }56 }
56- if !d.detachIfAttachmentCurrent(token) {57+ if !d.detachIfAttachmentCurrent(capability) {
57- d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken)58+ d.clearParkingInFlightIfAbandoned(sess, capability.ac, parkingToken)
58 return false59 return false
59 }60 }
60- d.finishClientGone(sess, token.ac, token.transport.transport, explicit, true)61+ d.finishClientGone(sess, capability.ac, capability.transport.transport, explicit, true)
61 return true62 return true
62 }63 }
63 64
@@ -131,11 +132,11 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
131 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)132 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
132 }133 }
133 134
134-func (d *Daemon) detachOnAttachmentSendError(token attachmentConnectionToken, failed ports.Transport) {135+func (d *Daemon) detachOnAttachmentSendError(token attachmentCapability, failed ports.Transport) {
135 d.detachOnAttachmentSendErrorUntil(token, failed, nil)136 d.detachOnAttachmentSendErrorUntil(token, failed, nil)
136 }137 }
137 138
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{}) {
139 // A delayed sender may report after the client has rebound. Only the exact140 // A delayed sender may report after the client has rebound. Only the exact
140 // transport captured by this attachment is allowed to detach either lifecycle.141 // transport captured by this attachment is allowed to detach either lifecycle.
141 if failed == nil || failed != token.transport.transport {142 if failed == nil || failed != token.transport.transport {
@@ -156,7 +157,7 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke
156 // reserveAttachmentSendErrorCleanup accounts for cleanup before End releases the157 // reserveAttachmentSendErrorCleanup accounts for cleanup before End releases the
157 // attachment gate. This closes the WaitGroup Add/Wait race with terminal teardown;158 // attachment gate. This closes the WaitGroup Add/Wait race with terminal teardown;
158 // the returned launch function must be invoked immediately after ticket End.159 // 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() {
160 d.attachmentCleanupWg.Add(1)161 d.attachmentCleanupWg.Add(1)
161 return func() {162 return func() {
162 go func() {163 go func() {
M internal/usecase/daemon/clipboard.go +24 -24
@@ -26,27 +26,27 @@ var (
26 clipPasteCloseMarker = []byte("\x1b[201~")26 clipPasteCloseMarker = []byte("\x1b[201~")
27 )27 )
28 28
29-func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) {29+func (d *Daemon) handleSequencedImagePushForAttachment(effect *attachmentEffect, _ uint64, ip ports.ImagePush) {
30- if !token.attachmentEffectCurrent() {30+ if !effect.current() {
31 return31 return
32 }32 }
33- d.handleImagePushForAttachment(token, ip)33+ d.handleImagePushForAttachment(effect, ip)
34 }34 }
35 35
36-func (d *Daemon) handleImagePushForAttachment(token attachmentConnectionToken, ip ports.ImagePush) {36+func (d *Daemon) handleImagePushForAttachment(effect *attachmentEffect, ip ports.ImagePush) {
37- if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() {37+ if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !effect.current() {
38 return38 return
39 }39 }
40- path, err := d.writeClipboardImageForAttachment(token, ip)40+ path, err := d.writeClipboardImageForAttachment(effect, ip)
41 if err != nil {41 if err != nil {
42 d.log.Error("writing clipboard image failed", "err", err)42 d.log.Error("writing clipboard image failed", "err", err)
43 return43 return
44 }44 }
45- if !token.attachmentEffectCurrent() {45+ if !effect.current() {
46 _ = os.Remove(path)46 _ = os.Remove(path)
47 return47 return
48 }48 }
49- d.injectClipboardPathForAttachment(token, path)49+ d.injectClipboardPathForAttachment(effect, path)
50 }50 }
51 51
52 // handleImagePush writes an ImagePush's bytes to a temp file and injects the52 // handleImagePush writes an ImagePush's bytes to a temp file and injects the
@@ -84,18 +84,18 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
84 return path, nil84 return path, nil
85 }85 }
86 86
87-func (d *Daemon) writeClipboardImageForAttachment(token attachmentConnectionToken, ip ports.ImagePush) (string, error) {87+func (d *Daemon) writeClipboardImageForAttachment(effect *attachmentEffect, ip ports.ImagePush) (string, error) {
88 path, err := d.createClipboardImage(ip)88 path, err := d.createClipboardImage(ip)
89 if err != nil {89 if err != nil {
90 return "", err90 return "", err
91 }91 }
92- sess := token.sess92+ sess := effect.sess
93 if sess == nil {93 if sess == nil {
94 _ = os.Remove(path)94 _ = os.Remove(path)
95 return "", errAttachmentTransition95 return "", errAttachmentTransition
96 }96 }
97 sess.mu.Lock()97 sess.mu.Lock()
98- if !token.attachmentEffectCurrentSessionLocked() {98+ if !effect.currentSessionLocked() {
99 sess.mu.Unlock()99 sess.mu.Unlock()
100 _ = os.Remove(path)100 _ = os.Remove(path)
101 return "", errAttachmentTransition101 return "", errAttachmentTransition
@@ -134,18 +134,18 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) {
134 d.injectClipboardPathToTarget(sess, path, nil)134 d.injectClipboardPathToTarget(sess, path, nil)
135 }135 }
136 136
137-func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) {137+func (d *Daemon) injectClipboardPathForAttachment(effect *attachmentEffect, path string) {
138- sess := token.sess138+ sess := effect.sess
139- if sess == nil || !token.attachmentEffectCurrent() {139+ if sess == nil || !effect.current() {
140 return140 return
141 }141 }
142- d.injectClipboardPathToTarget(sess, path, &token)142+ d.injectClipboardPathToTarget(sess, path, effect)
143 }143 }
144 144
145-func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentConnectionToken) {145+func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, effect *attachmentEffect) {
146 var ac *attachedClient146 var ac *attachedClient
147- if token != nil {147+ if effect != nil {
148- ac = token.ac148+ ac = effect.ac
149 }149 }
150 var tb *tab150 var tb *tab
151 if ac != nil {151 if ac != nil {
@@ -175,7 +175,7 @@ func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *
175 wrapped = append(wrapped, clipPasteCloseMarker...)175 wrapped = append(wrapped, clipPasteCloseMarker...)
176 data = wrapped176 data = wrapped
177 }177 }
178- if token != nil && !token.attachmentEffectCurrent() {178+ if effect != nil && !effect.current() {
179 return179 return
180 }180 }
181 d.writeToPane(sess, p, data)181 d.writeToPane(sess, p, data)
@@ -202,7 +202,7 @@ func clipboardExt(mime string) string {
202 202
203 type clipboardForward struct {203 type clipboardForward struct {
204 owner paneEffectLease204 owner paneEffectLease
205- token attachmentConnectionToken205+ token attachmentCapability
206 seq []byte206 seq []byte
207 }207 }
208 208
@@ -237,8 +237,8 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
237 items := make([]clipboardForward, 0, len(attachments))237 items := make([]clipboardForward, 0, len(attachments))
238 for _, ac := range attachments {238 for _, ac := range attachments {
239 transport := ac.transport()239 transport := ac.transport()
240- token := sess.attachmentToken(ac, transport)240+ token := sess.captureAttachmentCapability(ac, transport)
241- if token.attachmentCurrent() {241+ if token.current() {
242 items = append(items, clipboardForward{owner: owner, token: token, seq: seq})242 items = append(items, clipboardForward{owner: owner, token: token, seq: seq})
243 }243 }
244 }244 }
@@ -254,7 +254,7 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) {
254 sess.mu.Unlock()254 sess.mu.Unlock()
255 }255 }
256 256
257-func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *attachmentEffectTicket) (ports.Transport, error) {257+func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *attachmentEffect) (ports.Transport, error) {
258 token := item.token258 token := item.token
259 if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil {259 if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil {
260 return token.transport.transport, errAttachmentTransition260 return token.transport.transport, errAttachmentTransition
@@ -300,7 +300,7 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta
300 // admission and marks the transport interval while ownership publication is300 // admission and marks the transport interval while ownership publication is
301 // excluded. Publication therefore orders either before this check (the send is301 // excluded. Publication therefore orders either before this check (the send is
302 // dropped) or after transport admission (the send belongs to the old owner).302 // 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 {
304 if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil {304 if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil {
305 return false305 return false
306 }306 }
M internal/usecase/daemon/clipboard_test.go +3 -3
@@ -204,9 +204,9 @@ func chunkReadPTY(t *testing.T, chunks ...[]byte) *portsmocks.MockPTY {
204 204
205 func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) {205 func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) {
206 rc := d.attachCoordinator(sess, nil, ac, true)206 rc := d.attachCoordinator(sess, nil, ac, true)
207- token := sess.attachmentToken(ac, tr)207+ token := sess.captureAttachmentCapability(ac, tr)
208 token.lease = rc.attachmentLease(ac)208 token.lease = rc.attachmentLease(ac)
209- ac.publishAttachmentCapability(token)209+ ac.installTestAttachmentCapability(token)
210 }210 }
211 211
212 func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {212 func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease {
@@ -419,7 +419,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing
419 close(workerDone)419 close(workerDone)
420 }()420 }()
421 require.Eventually(t, func() bool {421 require.Eventually(t, func() bool {
422- return ac.attachmentEffects.inFlightCount() == 1422+ return ac.lifecycle.inFlightCount() == 1
423 }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")423 }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu")
424 424
425 sourceTab := testAttachmentTab(sess)425 sourceTab := testAttachmentTab(sess)
M internal/usecase/daemon/control.go +17 -17
@@ -300,7 +300,7 @@ type daemonActionTarget struct {
300 type daemonActionRequest struct {300 type daemonActionRequest struct {
301 kind daemonActionKind301 kind daemonActionKind
302 target daemonActionTarget302 target daemonActionTarget
303- effect *attachmentEffectTicket303+ effect *attachmentEffect
304 direction layout.Direction304 direction layout.Direction
305 axis layout.Axis305 axis layout.Axis
306 delta int306 delta int
@@ -513,21 +513,21 @@ func (e controlExec) MovePane(destinationSession, destinationTabID string) error
513 return errMovePaneInvalid513 return errMovePaneInvalid
514 }514 }
515 attachment := e.target.attachment515 attachment := e.target.attachment
516- token := attachmentConnectionToken{}516+ token := attachmentCapability{}
517 if attachment != nil {517 if attachment != nil {
518- token = e.sess.attachmentToken(attachment, attachment.transport())518+ token = e.sess.captureAttachmentCapability(attachment, attachment.transport())
519 if token.ac == nil {519 if token.ac == nil {
520 return errMoveStaleTarget520 return errMoveStaleTarget
521 }521 }
522 }522 }
523 return e.d.movePane(movePaneRequest{523 return e.d.movePane(movePaneRequest{
524- Attachment: attachment,524+ Attachment: attachment,
525- AttachmentToken: token,525+ AttachmentCapability: token,
526- Source: sessionMoveLocator(e.sess),526+ Source: sessionMoveLocator(e.sess),
527- SourceTabID: domain.TabStableID(e.tab.stableID),527+ SourceTabID: domain.TabStableID(e.tab.stableID),
528- SourcePaneID: domain.PaneStableID(e.target.pane.stableID),528+ SourcePaneID: domain.PaneStableID(e.target.pane.stableID),
529- Destination: sessionMoveLocator(destination),529+ Destination: sessionMoveLocator(destination),
530- DestinationTabID: domain.TabStableID(destinationTabID),530+ DestinationTabID: domain.TabStableID(destinationTabID),
531 })531 })
532 }532 }
533 533
@@ -540,19 +540,19 @@ func (e controlExec) MoveTab(destinationSession string) error {
540 return errMovePaneInvalid540 return errMovePaneInvalid
541 }541 }
542 attachment := e.target.attachment542 attachment := e.target.attachment
543- token := attachmentConnectionToken{}543+ token := attachmentCapability{}
544 if attachment != nil {544 if attachment != nil {
545- token = e.sess.attachmentToken(attachment, attachment.transport())545+ token = e.sess.captureAttachmentCapability(attachment, attachment.transport())
546 if token.ac == nil {546 if token.ac == nil {
547 return errMoveStaleTarget547 return errMoveStaleTarget
548 }548 }
549 }549 }
550 return e.d.moveTab(moveTabRequest{550 return e.d.moveTab(moveTabRequest{
551- Attachment: attachment,551+ Attachment: attachment,
552- AttachmentToken: token,552+ AttachmentCapability: token,
553- Source: sessionMoveLocator(e.sess),553+ Source: sessionMoveLocator(e.sess),
554- SourceTabID: domain.TabStableID(e.tab.stableID),554+ SourceTabID: domain.TabStableID(e.tab.stableID),
555- Destination: sessionMoveLocator(destination),555+ Destination: sessionMoveLocator(destination),
556 })556 })
557 }557 }
558 558
M internal/usecase/daemon/daemon.go +6 -6
@@ -161,7 +161,7 @@ type Daemon struct {
161 afterConnectionSessionSnapshot func(*session)161 afterConnectionSessionSnapshot func(*session)
162 // afterAttachmentFrameDispatch is a deterministic test seam after the162 // afterAttachmentFrameDispatch is a deterministic test seam after the
163 // connection loop snapshots a token and before a decoded frame takes effect.163 // connection loop snapshots a token and before a decoded frame takes effect.
164- afterAttachmentFrameDispatch func(attachmentConnectionToken)164+ afterAttachmentFrameDispatch func(attachmentCapability)
165 // afterAttachmentEffectParticipantsSnapshotted observes immutable role-gate165 // afterAttachmentEffectParticipantsSnapshotted observes immutable role-gate
166 // participants after architecture preflight locks are released and before166 // participants after architecture preflight locks are released and before
167 // the globally ordered freeze begins.167 // the globally ordered freeze begins.
@@ -214,10 +214,10 @@ type Daemon struct {
214 beforeResumeParkedSendMu func()214 beforeResumeParkedSendMu func()
215 // afterAttachmentEffectAdmitted is a deterministic test seam after a frame/paint215 // afterAttachmentEffectAdmitted is a deterministic test seam after a frame/paint
216 // reserves its exact capability and before its first observable mutation.216 // reserves its exact capability and before its first observable mutation.
217- afterAttachmentEffectAdmitted func(attachmentConnectionToken)217+ afterAttachmentEffectAdmitted func(attachmentCapability)
218 // beforeFirstPaintSendWait is a test-only seam immediately before a218 // beforeFirstPaintSendWait is a test-only seam immediately before a
219 // transition first paint waits for the attachment send lock.219 // transition first paint waits for the attachment send lock.
220- beforeFirstPaintSendWait func(attachmentConnectionToken)220+ beforeFirstPaintSendWait func(attachmentCapability)
221 // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a221 // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a
222 // fresh exact capability before producing PTY, action, or overlay effects.222 // fresh exact capability before producing PTY, action, or overlay effects.
223 afterDelayedKeyEffectAttempt func(bool)223 afterDelayedKeyEffectAttempt func(bool)
@@ -226,7 +226,7 @@ type Daemon struct {
226 afterActionAttachmentEffectEnded func(string)226 afterActionAttachmentEffectEnded func(string)
227 // beforeAttachmentSendErrorCleanup pauses asynchronous render-failure retirement227 // beforeAttachmentSendErrorCleanup pauses asynchronous render-failure retirement
228 // after the render ticket ends and before exact lifecycle validation.228 // after the render ticket ends and before exact lifecycle validation.
229- beforeAttachmentSendErrorCleanup func(attachmentConnectionToken)229+ beforeAttachmentSendErrorCleanup func(attachmentCapability)
230 afterAttachmentSendErrorCleanup func()230 afterAttachmentSendErrorCleanup func()
231 ptys ports.PTYFactory231 ptys ports.PTYFactory
232 clock ports.Clock232 clock ports.Clock
@@ -1217,7 +1217,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1217 return1217 return
1218 }1218 }
1219 expected := ac.transportSnapshot()1219 expected := ac.transportSnapshot()
1220- welcomeToken := sess.attachmentToken(ac, tr)1220+ welcomeToken := sess.captureAttachmentCapability(ac, tr)
1221 welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)1221 welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken)
1222 if expected.transport != tr || !admitted || welcomeToken.ac == nil {1222 if expected.transport != tr || !admitted || welcomeToken.ac == nil {
1223 if admitted {1223 if admitted {
@@ -1267,7 +1267,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut <
1267 failAttachment()1267 failAttachment()
1268 return1268 return
1269 }1269 }
1270- paintToken := sess.attachmentToken(ac, tr)1270+ paintToken := sess.captureAttachmentCapability(ac, tr)
1271 painted := make(chan bool, 1)1271 painted := make(chan bool, 1)
1272 paintDone, paintErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {1272 paintDone, paintErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error {
1273 //nolint:contextcheck // firstPaintForTransition is capability-bounded; the surrounding handshake operation owns cancellation.1273 //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
42 d.ptys = newFactory(t, newQuietPTY())42 d.ptys = newFactory(t, newQuietPTY())
43 rc := d.attachCoordinator(sess, nil, first, true)43 rc := d.attachCoordinator(sess, nil, first, true)
44 d.attachCoordinator(sess, nil, second, true)44 d.attachCoordinator(sess, nil, second, true)
45- firstToken := sess.attachmentToken(first, first.transport())45+ firstToken := sess.captureAttachmentCapability(first, first.transport())
46 firstToken.lease = rc.attachmentLease(first)46 firstToken.lease = rc.attachmentLease(first)
47- first.publishAttachmentCapability(firstToken)47+ first.installTestAttachmentCapability(firstToken)
48- secondToken := sess.attachmentToken(second, second.transport())48+ secondToken := sess.captureAttachmentCapability(second, second.transport())
49 secondToken.lease = rc.attachmentLease(second)49 secondToken.lease = rc.attachmentLease(second)
50- second.publishAttachmentCapability(secondToken)50+ second.installTestAttachmentCapability(secondToken)
51 51
52 firstView := first.viewSnapshot()52 firstView := first.viewSnapshot()
53 secondView := second.viewSnapshot()53 secondView := second.viewSnapshot()
54 first.sendMu.Lock()54 first.sendMu.Lock()
55 firstEpoch := first.output.currentEpoch()55 firstEpoch := first.output.currentEpoch()
56 first.sendMu.Unlock()56 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()
58 61
59 d.enterPalette(sess, second)62 d.enterPalette(sess, second)
60 d.handlePaletteInput(second, []byte("CNT\r"))63 d.handlePaletteInput(second, []byte("CNT\r"))
@@ -136,12 +139,12 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing
136 139
137 rc := d.attachCoordinator(sess, nil, first, true)140 rc := d.attachCoordinator(sess, nil, first, true)
138 d.attachCoordinator(sess, nil, second, true)141 d.attachCoordinator(sess, nil, second, true)
139- firstToken := sess.attachmentToken(first, first.transport())142+ firstToken := sess.captureAttachmentCapability(first, first.transport())
140 firstToken.lease = rc.attachmentLease(first)143 firstToken.lease = rc.attachmentLease(first)
141- first.publishAttachmentCapability(firstToken)144+ first.installTestAttachmentCapability(firstToken)
142- secondToken := sess.attachmentToken(second, second.transport())145+ secondToken := sess.captureAttachmentCapability(second, second.transport())
143 secondToken.lease = rc.attachmentLease(second)146 secondToken.lease = rc.attachmentLease(second)
144- second.publishAttachmentCapability(secondToken)147+ second.installTestAttachmentCapability(secondToken)
145 firstEpoch := first.output.currentEpoch()148 firstEpoch := first.output.currentEpoch()
146 secondEpoch := second.output.currentEpoch()149 secondEpoch := second.output.currentEpoch()
147 150
@@ -152,7 +155,10 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing
152 require.Greater(t, second.output.currentEpoch(), secondEpoch)155 require.Greater(t, second.output.currentEpoch(), secondEpoch)
153 require.Equal(t, firstEpoch, first.output.currentEpoch(), "peer output reset crossed attachment boundary")156 require.Equal(t, firstEpoch, first.output.currentEpoch(), "peer output reset crossed attachment boundary")
154 157
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()
156 require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size)162 require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size)
157 require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary")163 require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary")
158 sess.mu.Lock()164 sess.mu.Lock()
@@ -183,9 +189,9 @@ func TestAcceptanceDirectAttachmentSupportsVerticalSplit(t *testing.T) {
183 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())189 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
184 d.ptys = newFactory(t, newQuietPTY())190 d.ptys = newFactory(t, newQuietPTY())
185 rc := d.attachCoordinator(sess, nil, ac, true)191 rc := d.attachCoordinator(sess, nil, ac, true)
186- token := sess.attachmentToken(ac, ac.transport())192+ token := sess.captureAttachmentCapability(ac, ac.transport())
187 token.lease = rc.attachmentLease(ac)193 token.lease = rc.attachmentLease(ac)
188- ac.publishAttachmentCapability(token)194+ ac.installTestAttachmentCapability(token)
189 195
190 d.enterPalette(sess, ac)196 d.enterPalette(sess, ac)
191 d.handlePaletteInput(ac, []byte("SPD\r"))197 d.handlePaletteInput(ac, []byte("SPD\r"))
M internal/usecase/daemon/handshake_test.go +1 -1
@@ -117,7 +117,7 @@ func TestHandshakeFirstPaintCancellationDoesNotRacePaintResult(t *testing.T) {
117 paintStarted := make(chan struct{})117 paintStarted := make(chan struct{})
118 releasePaint := make(chan struct{})118 releasePaint := make(chan struct{})
119 paintDone := make(chan struct{})119 paintDone := make(chan struct{})
120- d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) {120+ d.afterAttachmentEffectAdmitted = func(attachmentCapability) {
121 close(paintStarted)121 close(paintStarted)
122 <-ctx.Done()122 <-ctx.Done()
123 <-releasePaint123 <-releasePaint
M internal/usecase/daemon/input.go +24 -29
@@ -20,11 +20,11 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
20 d.handleInput(sess, ac, data)20 d.handleInput(sess, ac, data)
21 }21 }
22 22
23-func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) {23+func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, _ uint64, data []byte) {
24- if !token.attachmentEffectCurrent() {24+ if !effect.current() {
25 return25 return
26 }26 }
27- d.handleInputForAttachment(token, data)27+ d.handleInputForAttachment(effect, data)
28 }28 }
29 29
30 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {30 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
@@ -40,26 +40,26 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
40 )40 )
41 }41 }
42 42
43-func (d *Daemon) handleInputForAttachment(token attachmentConnectionToken, data []byte) {43+func (d *Daemon) handleInputForAttachment(effect *attachmentEffect, data []byte) {
44- ac := token.ac44+ ac := effect.ac
45 ac.initOverlays()45 ac.initOverlays()
46 ac.mouseScan.Scan(data,46 ac.mouseScan.Scan(data,
47 func(ev mouse.Event) {47 func(ev mouse.Event) {
48- if token.attachmentEffectCurrent() {48+ if effect.current() {
49 d.handleMouse(ac, ev)49 d.handleMouse(ac, ev)
50 }50 }
51 },51 },
52 func(b []byte) {52 func(b []byte) {
53- if !token.attachmentEffectCurrent() {53+ if !effect.current() {
54 return54 return
55 }55 }
56- if ac.overlays.HandleInput(d, b, token.effect) {56+ if ac.overlays.HandleInput(d, b, effect) {
57 return57 return
58 }58 }
59- if !token.attachmentEffectCurrent() {59+ if !effect.current() {
60 return60 return
61 }61 }
62- ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, connectionToken: token})62+ ac.keys.RouteWithHandler(b, daemonKeyHandler{d: d, ac: ac, effect: effect})
63 },63 },
64 )64 )
65 }65 }
@@ -371,21 +371,21 @@ func sgrOffset(raw []byte, colDelta, rowDelta int) []byte {
371 }371 }
372 372
373 type daemonKeyHandler struct {373 type daemonKeyHandler struct {
374- d *Daemon374+ d *Daemon
375- ac *attachedClient375+ ac *attachedClient
376- actions daemonActionRunner376+ actions daemonActionRunner
377- connectionToken attachmentConnectionToken377+ effect *attachmentEffect
378 }378 }
379 379
380 // acquireAttachmentEffect preserves a synchronous frame's existing admission and380 // acquireAttachmentEffect preserves a synchronous frame's existing admission and
381 // gives delayed router callbacks a fresh ticket for the exact captured attachment.381 // gives delayed router callbacks a fresh ticket for the exact captured attachment.
382 // Direct/headless callers without an attachment token retain their existing behavior.382 // Direct/headless callers without an attachment token retain their existing behavior.
383-func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) {383+func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect, bool) {
384- if h.connectionToken.ac != nil {384+ if h.effect != nil && h.effect.ac != nil {
385- if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() {385+ if h.effect.current() {
386- return h.connectionToken.sess, effect, false386+ return h.effect.sess, h.effect, false
387 }387 }
388- effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken)388+ effect, admitted := h.effect.ac.beginAttachmentEffect(h.effect.capability())
389 if h.d.afterDelayedKeyEffectAttempt != nil {389 if h.d.afterDelayedKeyEffectAttempt != nil {
390 h.d.afterDelayedKeyEffectAttempt(admitted)390 h.d.afterDelayedKeyEffectAttempt(admitted)
391 }391 }
@@ -393,16 +393,13 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect
393 return nil, nil, false393 return nil, nil, false
394 }394 }
395 if h.d.afterAttachmentEffectAdmitted != nil {395 if h.d.afterAttachmentEffectAdmitted != nil {
396- token := h.connectionToken396+ h.d.afterAttachmentEffectAdmitted(effect.capability())
397- token.effect = effect
398- h.d.afterAttachmentEffectAdmitted(token)
399 }397 }
400- // Keep this defensive check even though token admission normally requires a session.398+ if effect.sess == nil {
401- if h.connectionToken.sess == nil {
402 effect.End()399 effect.End()
403 return nil, nil, false400 return nil, nil, false
404 }401 }
405- return h.connectionToken.sess, effect, true402+ return effect.sess, effect, true
406 }403 }
407 sess := h.ac.currentSession()404 sess := h.ac.currentSession()
408 if sess == nil {405 if sess == nil {
@@ -475,9 +472,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
475 return472 return
476 }473 }
477 effect.bindActionEnd(h.d, "jump-attention")474 effect.bindActionEnd(h.d, "jump-attention")
478- token := h.connectionToken475+ if err := h.d.jumpAttentionForAttachment(sess, h.ac, effect); err != nil {
479- token.effect = effect
480- if err := h.d.jumpAttentionForAttachment(sess, h.ac, token); err != nil {
481 h.d.reportError(sess, err)476 h.d.reportError(sess, err)
482 }477 }
483 case keys.ActionToggleFloatingPane:478 case keys.ActionToggleFloatingPane:
@@ -521,7 +516,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
521 }516 }
522 }517 }
523 518
524-func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) {519+func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffect) {
525 err := h.d.focusDir(sess, h.ac, dir, effect)520 err := h.d.focusDir(sess, h.ac, dir, effect)
526 if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) {521 if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) {
527 h.d.reportError(sess, err)522 h.d.reportError(sess, err)
M internal/usecase/daemon/input_test.go +3 -4
@@ -783,13 +783,12 @@ func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) {
783 defer releaseAll(releases)783 defer releaseAll(releases)
784 transport := ac.transport()784 transport := ac.transport()
785 rc := d.attachCoordinator(current, nil, ac, true)785 rc := d.attachCoordinator(current, nil, ac, true)
786- token := current.attachmentToken(ac, transport)786+ token := current.captureAttachmentCapability(ac, transport)
787 token.lease = rc.attachmentLease(ac)787 token.lease = rc.attachmentLease(ac)
788- ac.publishAttachmentCapability(token)788+ ac.installTestAttachmentCapability(token)
789 effect, admitted := ac.beginAttachmentEffect(token)789 effect, admitted := ac.beginAttachmentEffect(token)
790 require.True(t, admitted)790 require.True(t, admitted)
791 defer effect.End()791 defer effect.End()
792- token.effect = effect
793 ac.setRouteSnapshot(ports.RecentRouteSnapshot{792 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
794 Generation: 3,793 Generation: 3,
795 Active: ports.RouteRef{Key: 3, Generation: 3},794 Active: ports.RouteRef{Key: 3, Generation: 3},
@@ -797,7 +796,7 @@ func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) {
797 Entries: []ports.RecentRouteEntry{testRouteEntry(2, 2, "recent", 2, ports.RouteKindLocal)},796 Entries: []ports.RecentRouteEntry{testRouteEntry(2, 2, "recent", 2, ports.RouteKindLocal)},
798 })797 })
799 798
800- require.NoError(t, d.backSessionForAttachment(token))799+ require.NoError(t, d.backSessionForAttachment(effect))
801 frame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)800 frame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute)
802 action, err := ports.UnmarshalRouteNavigationAction(frame.Payload)801 action, err := ports.UnmarshalRouteNavigationAction(frame.Payload)
803 require.NoError(t, err)802 require.NoError(t, err)
M internal/usecase/daemon/lifecycle_fence_test.go +6 -6
@@ -21,7 +21,7 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation
21 sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{})21 sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{})
22 source.installRenderCoordinator(sourceCoordinator)22 source.installRenderCoordinator(sourceCoordinator)
23 sourceCoordinator.attach(ac)23 sourceCoordinator.attach(ac)
24- token := source.attachmentToken(ac, transport)24+ token := source.captureAttachmentCapability(ac, transport)
25 require.NotNil(t, token.lease)25 require.NotNil(t, token.lease)
26 d.sessions[source.id] = source26 d.sessions[source.id] = source
27 d.sessions[target.id] = target27 d.sessions[target.id] = target
@@ -37,7 +37,7 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation
37 }37 }
38 req := attachmentTransitionRequest{38 req := attachmentTransitionRequest{
39 source: source, target: target, next: ac,39 source: source, target: target, next: ac,
40- expectedTransport: ac.transportSnapshot(), sourceToken: &token,40+ expectedTransport: ac.transportSnapshot(), sourceCapability: &token,
41 preflighted: true, attachmentEffectsFrozen: true,41 preflighted: true, attachmentEffectsFrozen: true,
42 }42 }
43 d.mu.Lock()43 d.mu.Lock()
@@ -56,7 +56,7 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation
56 require.NotNil(t, publication)56 require.NotNil(t, publication)
57 }57 }
58 58
59-func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T) {59+func TestAttachmentCapabilityRevalidatesTheCapturedTransportIncarnation(t *testing.T) {
60 first := &closeTrackingTransport{}60 first := &closeTrackingTransport{}
61 second := &closeTrackingTransport{}61 second := &closeTrackingTransport{}
62 ac := &attachedClient{tr: first}62 ac := &attachedClient{tr: first}
@@ -69,12 +69,12 @@ func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T)
69 69
70 captured := make(chan struct{})70 captured := make(chan struct{})
71 release := make(chan struct{})71 release := make(chan struct{})
72- ac.beforeAttachmentTokenValidation = func() {72+ ac.beforeAttachmentCapabilityValidation = func() {
73 close(captured)73 close(captured)
74 <-release74 <-release
75 }75 }
76- result := make(chan attachmentConnectionToken, 1)76+ result := make(chan attachmentCapability, 1)
77- go func() { result <- sess.attachmentToken(ac, first) }()77+ go func() { result <- sess.captureAttachmentCapability(ac, first) }()
78 <-captured78 <-captured
79 ac.replaceTransport(second)79 ac.replaceTransport(second)
80 close(release)80 close(release)
M internal/usecase/daemon/move.go +13 -13
@@ -10,21 +10,21 @@ import (
10 // immutable identity. Session names are advisory; the ID and incarnation are10 // immutable identity. Session names are advisory; the ID and incarnation are
11 // the commit-time authority.11 // the commit-time authority.
12 type movePaneRequest struct {12 type movePaneRequest struct {
13- Attachment *attachedClient13+ Attachment *attachedClient
14- AttachmentToken attachmentConnectionToken14+ AttachmentCapability attachmentCapability
15- Source moveSessionLocator15+ Source moveSessionLocator
16- SourceTabID domain.TabStableID16+ SourceTabID domain.TabStableID
17- SourcePaneID domain.PaneStableID17+ SourcePaneID domain.PaneStableID
18- Destination moveSessionLocator18+ Destination moveSessionLocator
19- DestinationTabID domain.TabStableID19+ DestinationTabID domain.TabStableID
20 }20 }
21 21
22 type moveTabRequest struct {22 type moveTabRequest struct {
23- Attachment *attachedClient23+ Attachment *attachedClient
24- AttachmentToken attachmentConnectionToken24+ AttachmentCapability attachmentCapability
25- Source moveSessionLocator25+ Source moveSessionLocator
26- SourceTabID domain.TabStableID26+ SourceTabID domain.TabStableID
27- Destination moveSessionLocator27+ Destination moveSessionLocator
28 }28 }
29 29
30 // movePane resolves, reserves, validates, and commits one pane relocation. All30 // movePane resolves, reserves, validates, and commits one pane relocation. All
@@ -115,7 +115,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) {
115 // A final source tab is retired without transferring any attachment to the115 // A final source tab is retired without transferring any attachment to the
116 // destination. Freeze every affected source connection before publication so116 // destination. Freeze every affected source connection before publication so
117 // teardown invalidates exact generations rather than racing input or render.117 // teardown invalidates exact generations rather than racing input or render.
118- var frozen frozenAttachmentEffectGates118+ var frozen attachmentTransitionGuard
119 var sourceEffectsFrozen bool119 var sourceEffectsFrozen bool
120 if finalSourceTab && source != destination && len(admission.sourceAttachments) != 0 {120 if finalSourceTab && source != destination && len(admission.sourceAttachments) != 0 {
121 interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))121 interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))
M internal/usecase/daemon/move_attachments.go +0 -7
@@ -1,11 +1,5 @@
1 package daemon1 package daemon
2 2
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-
9 // sameMoveAttachmentsLocked reports whether the source membership is unchanged3 // sameMoveAttachmentsLocked reports whether the source membership is unchanged
10 // since admission. Caller holds the source session lock.4 // since admission. Caller holds the source session lock.
11 func sameMoveAttachmentsLocked(sess *session, admitted []*attachedClient) bool {5 func sameMoveAttachmentsLocked(sess *session, admitted []*attachedClient) bool {
@@ -33,7 +27,6 @@ func detachMoveAttachmentsLocked(sess *session, transports map[*attachedClient]t
33 retired := make([]detachedAttachmentSnapshot, 0, len(attachments))27 retired := make([]detachedAttachmentSnapshot, 0, len(attachments))
34 for _, ac := range attachments {28 for _, ac := range attachments {
35 retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: transports[ac]})29 retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: transports[ac]})
36- ac.connectionGeneration.Add(1)
37 ac.setSession(nil)30 ac.setSession(nil)
38 ac.invalidateFrozenAttachmentCapability()31 ac.invalidateFrozenAttachmentCapability()
39 }32 }
M internal/usecase/daemon/move_cleanup_test.go +1 -1
@@ -103,7 +103,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) {
103 require.False(t, awaitTestValue(t, resizeDone, "resize commit did not drop stale owner"))103 require.False(t, awaitTestValue(t, resizeDone, "resize commit did not drop stale owner"))
104 }104 }
105 105
106-func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) {106+func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) {
107 movedPTY, releaseMoved := newBlockingPTY(t)107 movedPTY, releaseMoved := newBlockingPTY(t)
108 remainingPTY, releaseRemaining := newBlockingPTY(t)108 remainingPTY, releaseRemaining := newBlockingPTY(t)
109 destinationPTY, releaseDestination := newBlockingPTY(t)109 destinationPTY, releaseDestination := newBlockingPTY(t)
M internal/usecase/daemon/move_gate_race_test.go +2 -2
@@ -194,11 +194,11 @@ func TestPaletteFinalCloseEndsCurrentEffectBeforeDrainingPeers(t *testing.T) {
194 sess.mu.Unlock()194 sess.mu.Unlock()
195 d.attachCoordinator(sess, nil, other, true)195 d.attachCoordinator(sess, nil, other, true)
196 196
197- currentToken := sess.attachmentToken(current, current.transport())197+ currentToken := sess.captureAttachmentCapability(current, current.transport())
198 currentEffect, admitted := current.beginAttachmentEffect(currentToken)198 currentEffect, admitted := current.beginAttachmentEffect(currentToken)
199 require.True(t, admitted)199 require.True(t, admitted)
200 t.Cleanup(currentEffect.End)200 t.Cleanup(currentEffect.End)
201- otherToken := sess.attachmentToken(other, otherTransport)201+ otherToken := sess.captureAttachmentCapability(other, otherTransport)
202 otherEffect, admitted := other.beginAttachmentEffect(otherToken)202 otherEffect, admitted := other.beginAttachmentEffect(otherToken)
203 require.True(t, admitted)203 require.True(t, admitted)
204 t.Cleanup(otherEffect.End)204 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
32 if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) {32 if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) {
33 return nil, errMoveStaleTarget33 return nil, errMoveStaleTarget
34 }34 }
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)) {
36 return nil, errMoveStaleTarget36 return nil, errMoveStaleTarget
37 }37 }
38 38
M internal/usecase/daemon/move_pane_commit.go +2 -2
@@ -17,7 +17,7 @@ type movePaneCommit struct {
17 sourceTransports map[*attachedClient]transportSnapshot17 sourceTransports map[*attachedClient]transportSnapshot
18 sourceGeneration uint6418 sourceGeneration uint64
19 destinationGeneration uint6419 destinationGeneration uint64
20- frozenEffects frozenAttachmentEffectGates20+ frozenEffects attachmentTransitionGuard
21 err error21 err error
22 22
23 syncCleanup syncTimerCleanup23 syncCleanup syncTimerCleanup
@@ -51,7 +51,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool {
51 if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) {51 if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) {
52 return false52 return false
53 }53 }
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)) {
55 return false55 return false
56 }56 }
57 57
M internal/usecase/daemon/move_pane_postcommit.go +1 -1
@@ -29,7 +29,7 @@ type movePanePostcommitPlan struct {
29 operation string29 operation string
30 sourceAttachments []*attachedClient30 sourceAttachments []*attachedClient
31 syncCleanup syncTimerCleanup31 syncCleanup syncTimerCleanup
32- frozenEffects frozenAttachmentEffectGates32+ frozenEffects attachmentTransitionGuard
33 effectsFrozen bool33 effectsFrozen bool
34 unlockDispatch func()34 unlockDispatch func()
35 reservation *moveLifecycleReservation35 reservation *moveLifecycleReservation
M internal/usecase/daemon/move_picker.go +20 -20
@@ -16,7 +16,7 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error {
16 }16 }
17 sess.mu.Lock()17 sess.mu.Lock()
18 defer sess.mu.Unlock()18 defer sess.mu.Unlock()
19- if source.AttachmentToken.ac != nil && !moveAttachmentTokenCurrentLocked(source.AttachmentToken, sess) {19+ if source.AttachmentCapability.ac != nil && !source.AttachmentCapability.currentInSessionLocked(sess) {
20 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid)20 return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid)
21 }21 }
22 if source.Attachment != nil && !attachmentRegisteredLocked(sess, source.Attachment) {22 if source.Attachment != nil && !attachmentRegisteredLocked(sess, source.Attachment) {
@@ -52,11 +52,11 @@ type moveSessionLocator struct {
52 }52 }
53 53
54 type moveSourceLocator struct {54 type moveSourceLocator struct {
55- Session moveSessionLocator55+ Session moveSessionLocator
56- TabID domain.TabStableID56+ TabID domain.TabStableID
57- PaneID domain.PaneStableID57+ PaneID domain.PaneStableID
58- Attachment *attachedClient58+ Attachment *attachedClient
59- AttachmentToken attachmentConnectionToken59+ AttachmentCapability attachmentCapability
60 }60 }
61 61
62 // enterPickerForIntent returns errNoMoveDestination only for move intents.62 // enterPickerForIntent returns errNoMoveDestination only for move intents.
@@ -64,8 +64,8 @@ func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent
64 if intent != pickerNavigate && !sess.capabilities().yieldsMoves() {64 if intent != pickerNavigate && !sess.capabilities().yieldsMoves() {
65 return errSessionCannotYieldMoves65 return errSessionCannotYieldMoves
66 }66 }
67- if source.Attachment != nil && source.AttachmentToken.ac == nil {67+ if source.Attachment != nil && source.AttachmentCapability.ac == nil {
68- source.AttachmentToken = sess.attachmentToken(source.Attachment, source.Attachment.transport())68+ source.AttachmentCapability = sess.captureAttachmentCapability(source.Attachment, source.Attachment.transport())
69 }69 }
70 model := d.newPickerModel(sess, ac, intent, source, picker.SourceFilter{})70 model := d.newPickerModel(sess, ac, intent, source, picker.SourceFilter{})
71 if intent != pickerNavigate {71 if intent != pickerNavigate {
@@ -97,21 +97,21 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc
97 return errMovePaneInvalid97 return errMovePaneInvalid
98 }98 }
99 return d.movePane(movePaneRequest{99 return d.movePane(movePaneRequest{
100- Attachment: source.Attachment,100+ Attachment: source.Attachment,
101- AttachmentToken: source.AttachmentToken,101+ AttachmentCapability: source.AttachmentCapability,
102- Source: source.Session,102+ Source: source.Session,
103- SourceTabID: source.TabID,103+ SourceTabID: source.TabID,
104- SourcePaneID: source.PaneID,104+ SourcePaneID: source.PaneID,
105- Destination: destination,105+ Destination: destination,
106- DestinationTabID: target.TabID,106+ DestinationTabID: target.TabID,
107 })107 })
108 case pickerMoveTab:108 case pickerMoveTab:
109 return d.moveTab(moveTabRequest{109 return d.moveTab(moveTabRequest{
110- Attachment: source.Attachment,110+ Attachment: source.Attachment,
111- AttachmentToken: source.AttachmentToken,111+ AttachmentCapability: source.AttachmentCapability,
112- Source: source.Session,112+ Source: source.Session,
113- SourceTabID: source.TabID,113+ SourceTabID: source.TabID,
114- Destination: destination,114+ Destination: destination,
115 })115 })
116 default:116 default:
117 return errMovePaneInvalid117 return errMovePaneInvalid
M internal/usecase/daemon/move_tab.go +4 -4
@@ -28,7 +28,7 @@ type moveTabCommit struct {
28 destination *session28 destination *session
29 admission moveTabAdmission29 admission moveTabAdmission
30 sourceEffectsFrozen bool30 sourceEffectsFrozen bool
31- frozenEffects frozenAttachmentEffectGates31+ frozenEffects attachmentTransitionGuard
32 syncCleanup syncTimerCleanup32 syncCleanup syncTimerCleanup
33 oldTabCancel context.CancelFunc33 oldTabCancel context.CancelFunc
34 sourceEmpty bool34 sourceEmpty bool
@@ -110,7 +110,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) {
110 d.afterMoveTabSourceSnapshot()110 d.afterMoveTabSourceSnapshot()
111 }111 }
112 commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}112 commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission}
113- var frozen frozenAttachmentEffectGates113+ var frozen attachmentTransitionGuard
114 if admission.finalSource && len(admission.sourceAttachments) != 0 {114 if admission.finalSource && len(admission.sourceAttachments) != 0 {
115 interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))115 interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments))
116 for _, ac := range admission.sourceAttachments {116 for _, ac := range admission.sourceAttachments {
@@ -190,7 +190,7 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio
190 if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) {190 if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) {
191 return nil, errMoveStaleTarget191 return nil, errMoveStaleTarget
192 }192 }
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)) {
194 return nil, errMoveStaleTarget194 return nil, errMoveStaleTarget
195 }195 }
196 moved := findMoveTabLocked(source, req.SourceTabID)196 moved := findMoveTabLocked(source, req.SourceTabID)
@@ -251,7 +251,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool {
251 if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) {251 if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) {
252 return false252 return false
253 }253 }
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)) {
255 return false255 return false
256 }256 }
257 moved := c.admission.tab257 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.
408 sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()408 sourceFenceUnlocked := r.source.layoutApplyMu.TryLock()
409 destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()409 destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock()
410 tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()410 tabFenceUnlocked := r.moved.layoutApplyMu.TryLock()
411- roleUnlocked := r.client.attachmentEffects.mu.TryLock()411+ roleUnlocked := r.client.lifecycle.mu.TryLock()
412- roleStable := roleUnlocked && r.client.attachmentEffects.phase == attachmentEffectsStable412+ roleStable := roleUnlocked && r.client.lifecycle.phase == attachmentEffectsStable
413 r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&413 r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked &&
414 sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable414 sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable
415 if roleUnlocked {415 if roleUnlocked {
416- r.client.attachmentEffects.mu.Unlock()416+ r.client.lifecycle.mu.Unlock()
417 }417 }
418 if tabFenceUnlocked {418 if tabFenceUnlocked {
419 r.moved.layoutApplyMu.Unlock()419 r.moved.layoutApplyMu.Unlock()
M internal/usecase/daemon/navigation_attachment_handoff_test.go +15 -15
@@ -17,25 +17,25 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
17 name string17 name string
18 releasedAction string18 releasedAction string
19 wantErr bool19 wantErr bool
20- run func(*Daemon, *session, *session, *attachedClient, *attachmentEffectTicket) error20+ run func(*Daemon, *session, *session, *attachedClient, *attachmentEffect) error
21 check func(*testing.T, *Daemon, *session, *session)21 check func(*testing.T, *Daemon, *session, *session)
22 }{22 }{
23 {23 {
24 name: "picker selection", releasedAction: "detach",24 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 {
26 d.enterPicker(source, ac)26 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")
28 },28 },
29 },29 },
30 {30 {
31 name: "palette session", releasedAction: "detach",31 name: "palette session", releasedAction: "detach",
32- run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error {32+ run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error {
33- return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")33+ return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session")
34 },34 },
35 },35 },
36 {36 {
37 name: "session overflow", releasedAction: "detach",37 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 {
39 source.mu.Lock()39 source.mu.Lock()
40 source.name = "alpha"40 source.name = "alpha"
41 source.mu.Unlock()41 source.mu.Unlock()
@@ -48,13 +48,13 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
48 },48 },
49 {49 {
50 name: "stopped session resume", releasedAction: "detach",50 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 {
52 d.mu.Lock()52 d.mu.Lock()
53 delete(d.sessions, target.id)53 delete(d.sessions, target.id)
54 d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 9, incarnation: domain.IncarnationID{2}, state: ports.SessionDown}54 d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 9, incarnation: domain.IncarnationID{2}, state: ports.SessionDown}
55 d.mu.Unlock()55 d.mu.Unlock()
56 expectedCreatedAt := int64(9)56 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")
58 },58 },
59 check: func(t *testing.T, d *Daemon, _, _ *session) {59 check: func(t *testing.T, d *Daemon, _, _ *session) {
60 d.mu.Lock()60 d.mu.Lock()
@@ -67,8 +67,8 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
67 },67 },
68 {68 {
69 name: "transition prompt creation", releasedAction: "create-session", wantErr: true,69 name: "transition prompt creation", releasedAction: "create-session", wantErr: true,
70- run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffectTicket) error {70+ run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffect) error {
71- return d.createSessionAndSwitchForAttachment(effect.connectionToken(), "created")71+ return d.createSessionAndSwitchForAttachment(effect, "created")
72 },72 },
73 check: func(t *testing.T, d *Daemon, _, _ *session) {73 check: func(t *testing.T, d *Daemon, _, _ *session) {
74 d.mu.Lock()74 d.mu.Lock()
@@ -93,9 +93,9 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) {
93 d.mu.Unlock()93 d.mu.Unlock()
94 94
95 rc := d.attachCoordinator(source, nil, old, true)95 rc := d.attachCoordinator(source, nil, old, true)
96- token := source.attachmentToken(old, old.transport())96+ token := source.captureAttachmentCapability(old, old.transport())
97 token.lease = rc.attachmentLease(old)97 token.lease = rc.attachmentLease(old)
98- old.publishAttachmentCapability(token)98+ old.installTestAttachmentCapability(token)
99 effect, admitted := old.beginAttachmentEffect(token)99 effect, admitted := old.beginAttachmentEffect(token)
100 require.True(t, admitted)100 require.True(t, admitted)
101 101
@@ -153,9 +153,9 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
153 d.mu.Unlock()153 d.mu.Unlock()
154 154
155 rc := d.attachCoordinator(source, nil, old, true)155 rc := d.attachCoordinator(source, nil, old, true)
156- token := source.attachmentToken(old, old.transport())156+ token := source.captureAttachmentCapability(old, old.transport())
157 token.lease = rc.attachmentLease(old)157 token.lease = rc.attachmentLease(old)
158- old.publishAttachmentCapability(token)158+ old.installTestAttachmentCapability(token)
159 effect, admitted := old.beginAttachmentEffect(token)159 effect, admitted := old.beginAttachmentEffect(token)
160 require.True(t, admitted)160 require.True(t, admitted)
161 161
@@ -171,7 +171,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing.
171 expectedCreatedAt := int64(7)171 expectedCreatedAt := int64(7)
172 done := make(chan error, 1)172 done := make(chan error, 1)
173 go func() {173 go func() {
174- done <- d.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")
175 }()175 }()
176 select {176 select {
177 case <-ended:177 case <-ended:
M internal/usecase/daemon/navigation_attachment_incarnation_test.go +8 -8
@@ -19,9 +19,9 @@ func TestNavigationHandoffRejectsReplacedTargetLifecycleAtPublication(t *testing
19 d.mu.Unlock()19 d.mu.Unlock()
20 20
21 rc := d.attachCoordinator(source, nil, ac, true)21 rc := d.attachCoordinator(source, nil, ac, true)
22- token := source.attachmentToken(ac, ac.transport())22+ token := source.captureAttachmentCapability(ac, ac.transport())
23 token.lease = rc.attachmentLease(ac)23 token.lease = rc.attachmentLease(ac)
24- ac.publishAttachmentCapability(token)24+ ac.installTestAttachmentCapability(token)
25 effect, admitted := ac.beginAttachmentEffect(token)25 effect, admitted := ac.beginAttachmentEffect(token)
26 require.True(t, admitted)26 require.True(t, admitted)
27 27
@@ -30,7 +30,7 @@ func TestNavigationHandoffRejectsReplacedTargetLifecycleAtPublication(t *testing
30 // transition's lifecycle fence is validated.30 // transition's lifecycle fence is validated.
31 target.incarnation = domain.SessionLifecycleID{2}31 target.incarnation = domain.SessionLifecycleID{2}
32 }32 }
33- err := d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{33+ err := d.switchToTargetForAttachment(effect, picker.Target{
34 Session: target.id,34 Session: target.id,
35 Incarnation: domain.SessionLifecycleID{1},35 Incarnation: domain.SessionLifecycleID{1},
36 TabID: domain.TabStableID(target.tabs[1].stableID),36 TabID: domain.TabStableID(target.tabs[1].stableID),
@@ -69,9 +69,9 @@ func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testi
69 name: "transport incarnation rebound on same attachment",69 name: "transport incarnation rebound on same attachment",
70 rebind: func(t *testing.T, _ *Daemon, source *session, ac *attachedClient) {70 rebind: func(t *testing.T, _ *Daemon, source *session, ac *attachedClient) {
71 ac.replaceTransport(&closeTrackingTransport{})71 ac.replaceTransport(&closeTrackingTransport{})
72- current := source.attachmentToken(ac, ac.transport())72+ current := source.captureAttachmentCapability(ac, ac.transport())
73 current.lease = source.renderCoordinator().attachmentLease(ac)73 current.lease = source.renderCoordinator().attachmentLease(ac)
74- ac.publishAttachmentCapability(current)74+ ac.installTestAttachmentCapability(current)
75 },75 },
76 },76 },
77 }77 }
@@ -88,9 +88,9 @@ func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testi
88 d.mu.Unlock()88 d.mu.Unlock()
89 89
90 rc := d.attachCoordinator(source, nil, ac, true)90 rc := d.attachCoordinator(source, nil, ac, true)
91- token := source.attachmentToken(ac, ac.transport())91+ token := source.captureAttachmentCapability(ac, ac.transport())
92 token.lease = rc.attachmentLease(ac)92 token.lease = rc.attachmentLease(ac)
93- ac.publishAttachmentCapability(token)93+ ac.installTestAttachmentCapability(token)
94 effect, admitted := ac.beginAttachmentEffect(token)94 effect, admitted := ac.beginAttachmentEffect(token)
95 require.True(t, admitted)95 require.True(t, admitted)
96 96
@@ -104,7 +104,7 @@ func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testi
104 }104 }
105 done := make(chan error, 1)105 done := make(chan error, 1)
106 go func() {106 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")
108 }()108 }()
109 awaitTestCompletion(t, ended, "test handoff did not release its role ticket")109 awaitTestCompletion(t, ended, "test handoff did not release its role ticket")
110 tt.rebind(t, d, source, ac)110 tt.rebind(t, d, source, ac)
M internal/usecase/daemon/overlay_runtime.go +3 -3
@@ -53,7 +53,7 @@ type overlayRuntime struct {
53 promptMu sync.Mutex53 promptMu sync.Mutex
54 prompt *promptui.Model54 prompt *promptui.Model
55 promptSubmit func(string) error55 promptSubmit func(string) error
56- promptTransitionSubmit func(string, attachmentConnectionToken) error56+ promptTransitionSubmit func(string, *attachmentEffect) error
57 promptPending []byte57 promptPending []byte
58 58
59 resizeMu sync.Mutex59 resizeMu sync.Mutex
@@ -247,12 +247,12 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
247 return active || prePublication247 return active || prePublication
248 }248 }
249 249
250-func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attachmentEffectTicket) bool {250+func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attachmentEffect) bool {
251 if rt == nil || rt.ac == nil {251 if rt == nil || rt.ac == nil {
252 return false252 return false
253 }253 }
254 ac := rt.ac254 ac := rt.ac
255- var effect *attachmentEffectTicket255+ var effect *attachmentEffect
256 if len(effects) != 0 {256 if len(effects) != 0 {
257 effect = effects[0]257 effect = effects[0]
258 }258 }
M internal/usecase/daemon/palette.go +22 -16
@@ -329,7 +329,7 @@ func (d *Daemon) recordPaletteUse(code string) {
329 d.paletteRecent = recent329 d.paletteRecent = recent
330 }330 }
331 331
332-func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {332+func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
333 entry := ac.currentAttachmentSession()333 entry := ac.currentAttachmentSession()
334 if entry == nil {334 if entry == nil {
335 return335 return
@@ -350,7 +350,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
350 var rawQuery string350 var rawQuery string
351 changed, cancel, execute := false, false, false351 changed, cancel, execute := false, false, false
352 var closedDiscovery remoteDiscoveryInstance352 var closedDiscovery remoteDiscoveryInstance
353- var effect *attachmentEffectTicket353+ var effect *attachmentEffect
354 if len(effects) != 0 {354 if len(effects) != 0 {
355 effect = effects[0]355 effect = effects[0]
356 }356 }
@@ -490,7 +490,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
490 RemoteKey: &remoteKey, RemoteTarget: &remoteTarget, RemoteHost: remoteKey.Host,490 RemoteKey: &remoteKey, RemoteTarget: &remoteTarget, RemoteHost: remoteKey.Host,
491 UnavailableReason: remoteUnavailableReason, TabIndex: -1,491 UnavailableReason: remoteUnavailableReason, TabIndex: -1,
492 }492 }
493- err := d.switchToTargetForAttachment(effect.connectionToken(), target, sessionHandoffGuard{}, "palette-remote-session")493+ err := d.switchToTargetForAttachment(effect, target, sessionHandoffGuard{}, "palette-remote-session")
494 if err == nil {494 if err == nil {
495 d.closeExecutedPalette(ac, generation, rawQuery)495 d.closeExecutedPalette(ac, generation, rawQuery)
496 return496 return
@@ -529,7 +529,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
529 }529 }
530 var err error530 var err error
531 if effect != nil {531 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")
533 } else {533 } else {
534 err = d.switchToTarget(sess, ac, target)534 err = d.switchToTarget(sess, ac, target)
535 }535 }
@@ -582,7 +582,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
582 sess.dispatchMu.Unlock()582 sess.dispatchMu.Unlock()
583 if attachmentHandoff {583 if attachmentHandoff {
584 if current := ac.currentSession(); current != nil {584 if current := ac.currentSession(); current != nil {
585- currentToken := current.attachmentToken(ac, ac.transport())585+ currentToken := current.captureAttachmentCapability(ac, ac.transport())
586 fresh, admitted := ac.beginAttachmentEffect(currentToken)586 fresh, admitted := ac.beginAttachmentEffect(currentToken)
587 if d.closeExecutedPalette(ac, generation, rawQuery) {587 if d.closeExecutedPalette(ac, generation, rawQuery) {
588 d.invalidateRender(current, ac, true, "palette.go")588 d.invalidateRender(current, ac, true, "palette.go")
@@ -681,7 +681,7 @@ type paletteExec struct {
681 ac *attachedClient681 ac *attachedClient
682 routeSnapshot ports.RecentRouteSnapshot682 routeSnapshot ports.RecentRouteSnapshot
683 actions daemonActionRunner683 actions daemonActionRunner
684- effect *attachmentEffectTicket684+ effect *attachmentEffect
685 redrawClosedPalette bool685 redrawClosedPalette bool
686 }686 }
687 687
@@ -719,14 +719,14 @@ func (e paletteExec) CreateSession() error {
719 if entry == nil {719 if entry == nil {
720 entry = e.sess720 entry = e.sess
721 }721 }
722- e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error {722+ e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, effect *attachmentEffect) error {
723- if token.ac == nil {723+ if effect == nil || effect.ac == nil {
724 if e.sess == nil {724 if e.sess == nil {
725 return errAttachmentTransition725 return errAttachmentTransition
726 }726 }
727 return e.d.createSessionAndSwitch(e.sess, e.ac, name)727 return e.d.createSessionAndSwitch(e.sess, e.ac, name)
728 }728 }
729- return e.d.createSessionAndSwitchForAttachment(token, name)729+ return e.d.createSessionAndSwitchForAttachment(effect, name)
730 })730 })
731 return nil731 return nil
732 }732 }
@@ -735,14 +735,14 @@ func (e paletteExec) CreateSessionNamed(name string) error {
735 if e.effect == nil {735 if e.effect == nil {
736 return e.d.createSessionAndSwitch(e.sess, e.ac, name)736 return e.d.createSessionAndSwitch(e.sess, e.ac, name)
737 }737 }
738- return e.d.createSessionAndSwitchForAttachment(e.effect.connectionToken(), name)738+ return e.d.createSessionAndSwitchForAttachment(e.effect, name)
739 }739 }
740 740
741 func (e paletteExec) CreateEphemeralSession() error {741 func (e paletteExec) CreateEphemeralSession() error {
742 if e.effect == nil {742 if e.effect == nil {
743 return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)743 return e.d.createEphemeralSessionAndSwitch(e.sess, e.ac)
744 }744 }
745- return e.d.createEphemeralSessionAndSwitchForAttachment(e.effect.connectionToken())745+ return e.d.createEphemeralSessionAndSwitchForAttachment(e.effect)
746 }746 }
747 747
748 func (e paletteExec) CloseTab() error {748 func (e paletteExec) CloseTab() error {
@@ -839,7 +839,7 @@ func (e paletteExec) ToggleFloatingPane() error {
839 839
840 func (e paletteExec) BackSession() error {840 func (e paletteExec) BackSession() error {
841 if e.effect != nil {841 if e.effect != nil {
842- return e.d.backSessionForAttachment(e.effect.connectionToken())842+ return e.d.backSessionForAttachment(e.effect)
843 }843 }
844 e.d.backSession(e.sess, e.ac)844 e.d.backSession(e.sess, e.ac)
845 return nil845 return nil
@@ -847,7 +847,7 @@ func (e paletteExec) BackSession() error {
847 847
848 func (e paletteExec) Detach() error {848 func (e paletteExec) Detach() error {
849 if e.effect != nil {849 if e.effect != nil {
850- if !e.d.clientGoneForAttachment(e.effect.connectionToken(), true) {850+ if !e.d.clientGoneForAttachment(e.effect, true) {
851 return errAttachmentTransition851 return errAttachmentTransition
852 }852 }
853 return nil853 return nil
@@ -912,7 +912,7 @@ func (e paletteExec) RenameTabTo(name string) error {
912 912
913 func (e paletteExec) OpenSessionPicker() error {913 func (e paletteExec) OpenSessionPicker() error {
914 if e.ac != nil && e.ac.navigationCapabilities&ports.NavigationCapabilityHomePicker != 0 && e.effect != nil {914 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)
916 }916 }
917 e.d.enterPicker(e.sess, e.ac)917 e.d.enterPicker(e.sess, e.ac)
918 return nil918 return nil
@@ -957,12 +957,18 @@ func (e paletteExec) NavigateRecentRoute(action ports.RouteNavigationAction) err
957 e.d.beforeRecentSessionHandoff()957 e.d.beforeRecentSessionHandoff()
958 }958 }
959 if e.effect != nil {959 if e.effect != nil {
960- return e.d.sendRecentRouteNavigationActionForAttachment(e.effect.connectionToken(), action)960+ return e.d.sendRecentRouteNavigationActionForAttachment(e.effect, action)
961 }961 }
962 if e.ac == nil || e.attachment == nil {962 if e.ac == nil || e.attachment == nil {
963 return errAttachmentTransition963 return errAttachmentTransition
964 }964 }
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)
966 }972 }
967 973
968 func composePaletteClientFrame(model *palette.Model, base renderer.Frame, cfg domain.PaletteConfig, guidance string, styles ...themeui.Styles) (renderer.Frame, []renderer.Damage) {974 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 {
32 }32 }
33 }33 }
34 34
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 {
36 t.Helper()36 t.Helper()
37 transport := ac.transport()37 transport := ac.transport()
38 rc := d.attachCoordinator(sess, nil, ac, true)38 rc := d.attachCoordinator(sess, nil, ac, true)
39- token := sess.attachmentToken(ac, transport)39+ token := sess.captureAttachmentCapability(ac, transport)
40 token.lease = rc.attachmentLease(ac)40 token.lease = rc.attachmentLease(ac)
41- ac.publishAttachmentCapability(token)41+ ac.installTestAttachmentCapability(token)
42 effect, admitted := ac.beginAttachmentEffect(token)42 effect, admitted := ac.beginAttachmentEffect(token)
43 require.True(t, admitted)43 require.True(t, admitted)
44 t.Cleanup(effect.End)44 t.Cleanup(effect.End)
45- token.effect = effect45+ return effect
46- return token
47 }46 }
48 47
49 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {48 func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) {
@@ -355,14 +354,14 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
355 354
356 d.handleInput(current, ac, []byte("\x1b "))355 d.handleInput(current, ac, []byte("\x1b "))
357 awaitFrame(t, sends, ports.MsgOutput)356 awaitFrame(t, sends, ports.MsgOutput)
358- generation := ac.connectionGeneration.Load()357+ generation := ac.lifecycle.generationValue()
359 d.handleInput(current, ac, []byte("stopped\r"))358 d.handleInput(current, ac, []byte("stopped\r"))
360 359
361 resumed := ac.currentSession()360 resumed := ac.currentSession()
362 require.Equal(t, "stopped", resumed.name)361 require.Equal(t, "stopped", resumed.name)
363 require.Equal(t, int64(42), resumed.createdAt)362 require.Equal(t, int64(42), resumed.createdAt)
364 require.Equal(t, true, resumed.attachmentRegistered(ac))363 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")
366 require.False(t, ac.overlays.paletteActive())365 require.False(t, ac.overlays.paletteActive())
367 firstPaint := awaitFrame(t, sends, ports.MsgOutput)366 firstPaint := awaitFrame(t, sends, ports.MsgOutput)
368 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)367 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
@@ -1004,7 +1003,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
1004 require.True(t, ac.overlays.promptActive())1003 require.True(t, ac.overlays.promptActive())
1005 require.Contains(t, string(promptOutput.Data), "Create session")1004 require.Contains(t, string(promptOutput.Data), "Create session")
1006 1005
1007- generation := ac.connectionGeneration.Load()1006+ generation := ac.lifecycle.generationValue()
1008 d.handleInput(sess, ac, []byte("scratch\r"))1007 d.handleInput(sess, ac, []byte("scratch\r"))
1009 // The submit first paints the newly attached session while the prompt is1008 // The submit first paints the newly attached session while the prompt is
1010 // still open, then handlePromptInput closes the prompt and repaints the1009 // still open, then handlePromptInput closes the prompt and repaints the
@@ -1026,7 +1025,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
1026 require.False(t, newSess.ephemeral)1025 require.False(t, newSess.ephemeral)
1027 require.Contains(t, newSess.snapshotAttachments(), ac)1026 require.Contains(t, newSess.snapshotAttachments(), ac)
1028 require.Equal(t, true, newSess.attachmentRegistered(ac))1027 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")
1030 require.Contains(t, string(finalOutput.Data), "scratch")1029 require.Contains(t, string(finalOutput.Data), "scratch")
1031 require.NotContains(t, string(finalOutput.Data), "Create session")1030 require.NotContains(t, string(finalOutput.Data), "Create session")
1032 }1031 }
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
348 return d.closePaneLockedWithEffect(sess, tb, id, ac, repaint, nil)348 return d.closePaneLockedWithEffect(sess, tb, id, ac, repaint, nil)
349 }349 }
350 350
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 {
352 if tb == nil {352 if tb == nil {
353 return layout.ErrNotFound353 return layout.ErrNotFound
354 }354 }
@@ -406,7 +406,7 @@ func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.Pan
406 return nil406 return nil
407 }407 }
408 408
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 {
410 target := resolveDaemonActionTargetForAttachment(sess, ac)410 target := resolveDaemonActionTargetForAttachment(sess, ac)
411 oldFocus := layout.PaneID("")411 oldFocus := layout.PaneID("")
412 if target.pane != nil {412 if target.pane != nil {
@@ -434,7 +434,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
434 return errNoNeighbor434 return errNoNeighbor
435 }435 }
436 if effect != nil {436 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")
438 }438 }
439 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)439 return d.commitSessionOverflow(sess, ac, target.tab, sessionTarget)
440 }440 }
M internal/usecase/daemon/parked_route.go +85 -60
@@ -50,9 +50,12 @@ func stopParkedRouteLeaseLocked(lease *parkedRouteLease) {
50 }50 }
51 }51 }
52 52
53-func (d *Daemon) armParkedRoute(token attachmentConnectionToken) (ports.ParkedRouteLeaseID, error) {53+func (d *Daemon) armParkedRoute(effect *attachmentEffect) (ports.ParkedRouteLeaseID, error) {
54- ac := token.ac54+ if !effect.current() {
55- if ac == nil || !token.attachmentCurrent() || ac.navigationCapabilities&ports.NavigationCapabilityHomePicker == 0 {55+ return ports.ParkedRouteLeaseID{}, errAttachmentTransition
56+ }
57+ ac := effect.ac
58+ if ac == nil || ac.navigationCapabilities&ports.NavigationCapabilityHomePicker == 0 {
56 return ports.ParkedRouteLeaseID{}, errAttachmentTransition59 return ports.ParkedRouteLeaseID{}, errAttachmentTransition
57 }60 }
58 id, err := newParkedRouteLeaseID()61 id, err := newParkedRouteLeaseID()
@@ -60,7 +63,7 @@ func (d *Daemon) armParkedRoute(token attachmentConnectionToken) (ports.ParkedRo
60 return ports.ParkedRouteLeaseID{}, err63 return ports.ParkedRouteLeaseID{}, err
61 }64 }
62 lease := &parkedRouteLease{65 lease := &parkedRouteLease{
63- id: id, generation: token.generation, transport: token.transport,66+ id: id, generation: effect.generation, transport: effect.transport,
64 expiresAt: d.clock.Now().Add(parkedRouteLeaseTTL),67 expiresAt: d.clock.Now().Add(parkedRouteLeaseTTL),
65 timer: d.clock.NewTimer(parkedRouteLeaseTTL), stop: make(chan struct{}),68 timer: d.clock.NewTimer(parkedRouteLeaseTTL), stop: make(chan struct{}),
66 }69 }
@@ -103,7 +106,7 @@ func (d *Daemon) expireParkedRoute(ac *attachedClient, lease *parkedRouteLease)
103 ac.parkedRouteOutput.Store(false)106 ac.parkedRouteOutput.Store(false)
104 ac.parkedRouteFullPending.Store(false)107 ac.parkedRouteFullPending.Store(false)
105 ac.parkedRouteMu.Unlock()108 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) {
107 _ = ac.closeCapturedTransport(lease.transport.transport)110 _ = ac.closeCapturedTransport(lease.transport.transport)
108 }111 }
109 }112 }
@@ -120,8 +123,8 @@ func (ac *attachedClient) clearParkedRoute() {
120 ac.parkedRouteMu.Unlock()123 ac.parkedRouteMu.Unlock()
121 }124 }
122 125
123-func (ac *attachedClient) parkedRouteStatus(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time, suspended bool) ports.ParkedRouteStatus {126+func (ac *attachedClient) parkedRouteStatus(token attachmentCapability, id ports.ParkedRouteLeaseID, now time.Time, suspended bool) ports.ParkedRouteStatus {
124- if ac == nil || !token.attachmentCurrent() {127+ if ac == nil || !token.current() {
125 return ports.ParkedRouteRejected128 return ports.ParkedRouteRejected
126 }129 }
127 ac.parkedRouteMu.Lock()130 ac.parkedRouteMu.Lock()
@@ -143,7 +146,7 @@ func (ac *attachedClient) parkedRouteStatus(token attachmentConnectionToken, id
143 return 0146 return 0
144 }147 }
145 148
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 {
147 if status := ac.parkedRouteStatus(token, id, now, false); status != 0 {150 if status := ac.parkedRouteStatus(token, id, now, false); status != 0 {
148 return status151 return status
149 }152 }
@@ -158,7 +161,7 @@ func (ac *attachedClient) prepareParkedRoute(token attachmentConnectionToken, id
158 return 0161 return 0
159 }162 }
160 163
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 {
162 if status := ac.parkedRouteStatus(token, id, now, true); status != 0 {165 if status := ac.parkedRouteStatus(token, id, now, true); status != 0 {
163 return status166 return status
164 }167 }
@@ -173,8 +176,8 @@ func (ac *attachedClient) consumeParkedRoute(token attachmentConnectionToken, id
173 return 0176 return 0
174 }177 }
175 178
176-func (ac *attachedClient) beginParkedRouteSwitch(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {179+func (ac *attachedClient) beginParkedRouteSwitch(token attachmentCapability, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus {
177- if ac == nil || !token.attachmentCurrent() {180+ if ac == nil || !token.current() {
178 return ports.ParkedRouteRejected181 return ports.ParkedRouteRejected
179 }182 }
180 ac.parkedRouteMu.Lock()183 ac.parkedRouteMu.Lock()
@@ -194,7 +197,7 @@ func (ac *attachedClient) beginParkedRouteSwitch(token attachmentConnectionToken
194 return 0197 return 0
195 }198 }
196 199
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 {
198 if ac == nil {201 if ac == nil {
199 return ports.ParkedRouteRejected202 return ports.ParkedRouteRejected
200 }203 }
@@ -215,8 +218,8 @@ func (ac *attachedClient) rejectParkedRouteSwitch(token attachmentConnectionToke
215 return 0218 return 0
216 }219 }
217 220
218-func (ac *attachedClient) consumePublishedParkedRoute(previous, published attachmentConnectionToken, id ports.ParkedRouteLeaseID) ports.ParkedRouteStatus {221+func (ac *attachedClient) consumePublishedParkedRoute(previous, published attachmentCapability, id ports.ParkedRouteLeaseID) ports.ParkedRouteStatus {
219- if ac == nil || published.ac != ac || !published.attachmentCurrent() {222+ if ac == nil || published.ac != ac || !published.current() {
220 return ports.ParkedRouteRejected223 return ports.ParkedRouteRejected
221 }224 }
222 ac.parkedRouteMu.Lock()225 ac.parkedRouteMu.Lock()
@@ -241,12 +244,12 @@ func parkedRouteResponseFrame(response ports.ParkedRouteResponse) (ports.Frame,
241 return ports.Frame{Type: ports.MsgParkedRouteResponse, Payload: payload}, nil244 return ports.Frame{Type: ports.MsgParkedRouteResponse, Payload: payload}, nil
242 }245 }
243 246
244-func (d *Daemon) sendParkedRouteResponse(token attachmentConnectionToken, response ports.ParkedRouteResponse) error {247+func (d *Daemon) sendParkedRouteResponse(effect *attachmentEffect, response ports.ParkedRouteResponse) error {
245 frame, err := parkedRouteResponseFrame(response)248 frame, err := parkedRouteResponseFrame(response)
246 if err != nil {249 if err != nil {
247 return err250 return err
248 }251 }
249- return token.sendControl(frame)252+ return effect.sendControl(frame)
250 }253 }
251 254
252 // sendParkedRouteResponseLocked preserves response -> full-output ordering.255 // sendParkedRouteResponseLocked preserves response -> full-output ordering.
@@ -270,77 +273,98 @@ func (ac *attachedClient) releaseParkedOutputLocked() {
270 ac.parkedRouteOutput.Store(false)273 ac.parkedRouteOutput.Store(false)
271 }274 }
272 275
273-func (d *Daemon) parkedRouteResponseFailed(token attachmentConnectionToken) {276+func (d *Daemon) parkedRouteResponseFailed(effect *attachmentEffect) {
274- if token.effect == nil || token.effect.ended.Load() {277+ if effect == nil {
275 return278 return
276 }279 }
277- launch := d.reserveAttachmentSendErrorCleanup(token, token.transport.transport)280+ capability := effect.capability()
278- token.effect.End()281+ launch := d.reserveAttachmentSendErrorCleanup(capability, capability.transport.transport)
282+ effect.End()
279 launch()283 launch()
280 }284 }
281 285
282-func (d *Daemon) respondParkedRoute(token attachmentConnectionToken, response ports.ParkedRouteResponse) bool {286+func (d *Daemon) respondParkedRoute(effect *attachmentEffect, response ports.ParkedRouteResponse) bool {
283- if err := d.sendParkedRouteResponse(token, response); err != nil {287+ if effect == nil || effect.ac == nil {
284- d.parkedRouteResponseFailed(token)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)
285 return false305 return false
286 }306 }
287 return true307 return true
288 }308 }
289 309
290-func (d *Daemon) handleParkedRouteRequest(token attachmentConnectionToken, request ports.ParkedRouteRequest) {310+func (d *Daemon) handleParkedRouteRequest(effect *attachmentEffect, request ports.ParkedRouteRequest) {
291- if ports.ValidateParkedRouteRequest(request) != nil || token.ac == nil {311+ if ports.ValidateParkedRouteRequest(request) != nil || !effect.current() || effect.ac == nil {
292 return312 return
293 }313 }
314+ capability := effect.capability()
294 switch request.Action {315 switch request.Action {
295 case ports.ParkedRoutePrepare:316 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())
297 if status == 0 {318 if status == 0 {
298 status = ports.ParkedRouteReady319 status = ports.ParkedRouteReady
299 }320 }
300- d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status})321+ d.respondParkedRoute(effect, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status})
301 case ports.ParkedRouteResume:322 case ports.ParkedRouteResume:
302- d.resumeParkedRoute(token, request)323+ d.resumeParkedRoute(effect, request)
303 case ports.ParkedRouteSwitch:324 case ports.ParkedRouteSwitch:
304- d.switchParkedRoute(token, request)325+ d.switchParkedRoute(effect, request)
305 }326 }
306 }327 }
307 328
308-func (d *Daemon) resumeParkedRoute(token attachmentConnectionToken, request ports.ParkedRouteRequest) {329+func (d *Daemon) resumeParkedRoute(effect *attachmentEffect, request ports.ParkedRouteRequest) {
309- status := token.ac.consumeParkedRoute(token, request.LeaseID, d.clock.Now())330+ capability := effect.capability()
331+ status := effect.ac.consumeParkedRoute(capability, request.LeaseID, d.clock.Now())
310 if status != 0 {332 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})
312 return334 return
313 }335 }
314- ac := token.ac336+ ac := effect.ac
315 ac.sendMu.Lock()337 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})
317 if err == nil {339 if err == nil {
318 ac.releaseParkedOutputLocked()340 ac.releaseParkedOutputLocked()
319 }341 }
320 ac.sendMu.Unlock()342 ac.sendMu.Unlock()
321 if err != nil {343 if err != nil {
322- d.parkedRouteResponseFailed(token)344+ d.parkedRouteResponseFailed(effect)
323 return345 return
324 }346 }
325- if token.sess != nil {347+ if effect.sess != nil {
326- d.firstPaintWithLease(token.sess, ac, token.lease, true)348+ d.firstPaintWithLease(effect.sess, ac, effect.lease, true)
327 }349 }
328 }350 }
329 351
330-func (d *Daemon) rejectParkedRouteSwitch(token attachmentConnectionToken, request ports.ParkedRouteRequest, fallback ports.ParkedRouteStatus) {352+func (d *Daemon) rejectParkedRouteSwitch(effect *attachmentEffect, request ports.ParkedRouteRequest, fallback ports.ParkedRouteStatus) {
331- status := token.ac.rejectParkedRouteSwitch(token, request.LeaseID, d.clock.Now())353+ capability := effect.capability()
354+ status := effect.ac.rejectParkedRouteSwitch(capability, request.LeaseID, d.clock.Now())
332 if status == 0 {355 if status == 0 {
333 status = fallback356 status = fallback
334 }357 }
335- if d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired {358+ if d.respondParkedRoute(effect, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired {
336- _ = token.ac.closeCapturedTransport(token.transport.transport)359+ _ = effect.ac.closeCapturedTransport(effect.transport.transport)
337 }360 }
338 }361 }
339 362
340-func (d *Daemon) switchParkedRoute(token attachmentConnectionToken, request ports.ParkedRouteRequest) {363+func (d *Daemon) switchParkedRoute(effect *attachmentEffect, request ports.ParkedRouteRequest) {
341- if status := token.ac.beginParkedRouteSwitch(token, request.LeaseID, d.clock.Now()); status != 0 {364+ capability := effect.capability()
342- if d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired {365+ if status := effect.ac.beginParkedRouteSwitch(capability, request.LeaseID, d.clock.Now()); status != 0 {
343- _ = token.ac.closeCapturedTransport(token.transport.transport)366+ if d.respondParkedRoute(effect, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired {
367+ _ = effect.ac.closeCapturedTransport(effect.transport.transport)
344 }368 }
345 return369 return
346 }370 }
@@ -350,34 +374,34 @@ func (d *Daemon) switchParkedRoute(token attachmentConnectionToken, request port
350 ctx = context.Background()374 ctx = context.Background()
351 }375 }
352 if err := d.waitForParkedTargetRestore(ctx, target.SessionName); err != nil {376 if err := d.waitForParkedTargetRestore(ctx, target.SessionName); err != nil {
353- d.rejectParkedRouteSwitch(token, request, ports.ParkedRouteStaleTarget)377+ d.rejectParkedRouteSwitch(effect, request, ports.ParkedRouteStaleTarget)
354 return378 return
355 }379 }
356 380
357- transition, targetSession, err := d.transitionParkedRouteTarget(token, target)381+ transition, targetSession, err := d.transitionParkedRouteTarget(effect, target)
358 if err != nil {382 if err != nil {
359- d.rejectParkedRouteSwitch(token, request, ports.ParkedRouteStaleTarget)383+ d.rejectParkedRouteSwitch(effect, request, ports.ParkedRouteStaleTarget)
360 return384 return
361 }385 }
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 {
363 d.abortPublishedAttachmentTransition(transition)387 d.abortPublishedAttachmentTransition(transition)
364 return388 return
365 }389 }
366- fresh, admitted := token.ac.beginAttachmentEffect(transition.published)390+ fresh, admitted := effect.ac.beginAttachmentEffect(transition.published)
367 if !admitted {391 if !admitted {
368 d.abortPublishedAttachmentTransition(transition)392 d.abortPublishedAttachmentTransition(transition)
369 return393 return
370 }394 }
371- freshToken := fresh.connectionToken()395+ freshCapability := fresh.capability()
372- ac := token.ac396+ ac := effect.ac
373 ac.sendMu.Lock()397 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})
375 if responseErr == nil {399 if responseErr == nil {
376 ac.releaseParkedOutputLocked()400 ac.releaseParkedOutputLocked()
377 }401 }
378 ac.sendMu.Unlock()402 ac.sendMu.Unlock()
379 if responseErr != nil {403 if responseErr != nil {
380- launch := d.reserveAttachmentSendErrorCleanup(freshToken, freshToken.transport.transport)404+ launch := d.reserveAttachmentSendErrorCleanup(freshCapability, freshCapability.transport.transport)
381 fresh.End()405 fresh.End()
382 launch()406 launch()
383 d.deferAttachmentTransitionCleanups(transition)407 d.deferAttachmentTransitionCleanups(transition)
@@ -409,7 +433,8 @@ func (d *Daemon) waitForParkedTargetRestore(parent context.Context, name string)
409 return err433 return err
410 }434 }
411 435
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()
413 d.mu.Lock()438 d.mu.Lock()
414 if d.closing {439 if d.closing {
415 d.mu.Unlock()440 d.mu.Unlock()
@@ -430,8 +455,8 @@ func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, ta
430 live.mu.Unlock()455 live.mu.Unlock()
431 d.mu.Unlock()456 d.mu.Unlock()
432 transition, err := d.transitionAttachment(attachmentTransitionRequest{457 transition, err := d.transitionAttachment(attachmentTransitionRequest{
433- source: token.sess, target: live, next: token.ac,458+ source: effect.sess, target: live, next: effect.ac,
434- expectedTransport: token.transport, sourceToken: &token, action: "parked-route-switch",459+ expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: "parked-route-switch",
435 expectedTargetLifecycle: &attachmentLifecycleFence{460 expectedTargetLifecycle: &attachmentLifecycleFence{
436 name: target.SessionName, incarnation: target.LifecycleID, checkIncarnation: true,461 name: target.SessionName, incarnation: target.LifecycleID, checkIncarnation: true,
437 tabID: tabID, tabIndex: tabIndex, checkTab: true,462 tabID: tabID, tabIndex: tabIndex, checkTab: true,
@@ -455,8 +480,8 @@ func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, ta
455 480
456 var created *session481 var created *session
457 transition, err := d.transitionAttachment(attachmentTransitionRequest{482 transition, err := d.transitionAttachment(attachmentTransitionRequest{
458- source: token.sess, next: token.ac,483+ source: effect.sess, next: effect.ac,
459- expectedTransport: token.transport, sourceToken: &token, action: "parked-route-restore",484+ expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: "parked-route-restore",
460 activateTargetTab: true, targetTabIndex: tabIndex, ready: true,485 activateTargetTab: true, targetTabIndex: tabIndex, ready: true,
461 createTargetLocked: func() (*session, error) {486 createTargetLocked: func() (*session, error) {
462 current, ok := d.inactive[target.SessionName]487 current, ok := d.inactive[target.SessionName]
@@ -470,7 +495,7 @@ func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, ta
470 env := copyEnvironment(d.baseEnv)495 env := copyEnvironment(d.baseEnv)
471 cwd := d.dirOrHome(current.cwd)496 cwd := d.dirOrHome(current.cwd)
472 var createErr error497 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)
474 return created, createErr499 return created, createErr
475 },500 },
476 })501 })
M internal/usecase/daemon/parked_route_test.go +18 -16
@@ -64,18 +64,17 @@ func (c *parkedRouteClock) setNow(now time.Time) {
64 func armAndPrepareParkedRoute(t *testing.T, d *Daemon, source *session, ac *attachedClient, sends chan ports.Frame) ports.ParkedRouteLeaseID {64 func armAndPrepareParkedRoute(t *testing.T, d *Daemon, source *session, ac *attachedClient, sends chan ports.Frame) ports.ParkedRouteLeaseID {
65 t.Helper()65 t.Helper()
66 ac.navigationCapabilities |= ports.NavigationCapabilityHomePicker66 ac.navigationCapabilities |= ports.NavigationCapabilityHomePicker
67- token := source.attachmentToken(ac, ac.transport())67+ token := source.captureAttachmentCapability(ac, ac.transport())
68 effect, admitted := ac.beginAttachmentEffect(token)68 effect, admitted := ac.beginAttachmentEffect(token)
69 require.True(t, admitted)69 require.True(t, admitted)
70- token.effect = effect70+ require.NoError(t, d.sendNavigationActionForAttachment(effect, ports.NavigationOpenHomePicker))
71- require.NoError(t, d.sendNavigationActionForAttachment(token, ports.NavigationOpenHomePicker))
72 effect.End()71 effect.End()
73 directiveFrame := awaitFrame(t, sends, ports.MsgNavigationAction)72 directiveFrame := awaitFrame(t, sends, ports.MsgNavigationAction)
74 directive, err := ports.UnmarshalNavigationDirective(directiveFrame.Payload)73 directive, err := ports.UnmarshalNavigationDirective(directiveFrame.Payload)
75 require.NoError(t, err)74 require.NoError(t, err)
76 75
77 prepare := ports.ParkedRouteRequest{RequestID: 1, LeaseID: directive.LeaseID, Action: ports.ParkedRoutePrepare}76 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)}))
79 readyFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)78 readyFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
80 ready, err := ports.UnmarshalParkedRouteResponse(readyFrame.Payload)79 ready, err := ports.UnmarshalParkedRouteResponse(readyFrame.Payload)
81 require.NoError(t, err)80 require.NoError(t, err)
@@ -108,7 +107,7 @@ func TestParkedRouteSwitchesExactLiveTargetOnRetainedTransport(t *testing.T) {
108 SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID),107 SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID),
109 }108 }
110 switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &targetRequest}109 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)}))
112 111
113 require.Same(t, target, ac.currentAttachmentSession())112 require.Same(t, target, ac.currentAttachmentSession())
114 require.False(t, ac.parkedRouteOutput.Load())113 require.False(t, ac.parkedRouteOutput.Load())
@@ -147,7 +146,7 @@ func TestParkedRouteSwitchAcceptedBeforeLeaseExpiryCommitsAtomically(t *testing.
147 SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID),146 SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID),
148 }147 }
149 switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &targetRequest}148 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)})
151 150
152 require.Same(t, target, ac.currentAttachmentSession())151 require.Same(t, target, ac.currentAttachmentSession())
153 responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)152 responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
@@ -185,7 +184,7 @@ func TestParkedRouteRestoresExactStoppedTargetWithDaemonEnvironment(t *testing.T
185 SessionName: "stopped", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-b"),184 SessionName: "stopped", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-b"),
186 }185 }
187 switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &target}186 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)})
189 188
190 restored := ac.currentAttachmentSession()189 restored := ac.currentAttachmentSession()
191 require.NotNil(t, restored)190 require.NotNil(t, restored)
@@ -210,7 +209,7 @@ func TestParkedRouteStaleTargetLeavesSourceResumable(t *testing.T) {
210 SessionName: "missing", LiveTabID: "tab-1",209 SessionName: "missing", LiveTabID: "tab-1",
211 }210 }
212 switchRequest := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteSwitch, Target: &missing}211 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)})
214 staleFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)213 staleFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
215 stale, err := ports.UnmarshalParkedRouteResponse(staleFrame.Payload)214 stale, err := ports.UnmarshalParkedRouteResponse(staleFrame.Payload)
216 require.NoError(t, err)215 require.NoError(t, err)
@@ -219,7 +218,7 @@ func TestParkedRouteStaleTargetLeavesSourceResumable(t *testing.T) {
219 require.True(t, ac.parkedRouteOutput.Load())218 require.True(t, ac.parkedRouteOutput.Load())
220 219
221 resume := ports.ParkedRouteRequest{RequestID: 3, LeaseID: leaseID, Action: ports.ParkedRouteResume}220 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)})
223 resumedFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)222 resumedFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
224 resumed, err := ports.UnmarshalParkedRouteResponse(resumedFrame.Payload)223 resumed, err := ports.UnmarshalParkedRouteResponse(resumedFrame.Payload)
225 require.NoError(t, err)224 require.NoError(t, err)
@@ -245,7 +244,7 @@ func TestParkedRouteDoesNotReplayOneShotEffectsAfterResume(t *testing.T) {
245 }244 }
246 245
247 resume := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteResume}246 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)})
249 _ = awaitFrame(t, sends, ports.MsgParkedRouteResponse)248 _ = awaitFrame(t, sends, ports.MsgParkedRouteResponse)
250 outputFrame := awaitFrame(t, sends, ports.MsgOutput)249 outputFrame := awaitFrame(t, sends, ports.MsgOutput)
251 output, err := ports.UnmarshalOutput(outputFrame.Payload)250 output, err := ports.UnmarshalOutput(outputFrame.Payload)
@@ -270,7 +269,7 @@ func TestParkedRouteExpiredLeaseFailsClosed(t *testing.T) {
270 ac.parkedRoute.expiresAt = d.clock.Now()269 ac.parkedRoute.expiresAt = d.clock.Now()
271 ac.parkedRouteMu.Unlock()270 ac.parkedRouteMu.Unlock()
272 resume := ports.ParkedRouteRequest{RequestID: 2, LeaseID: leaseID, Action: ports.ParkedRouteResume}271 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)})
274 responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)273 responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse)
275 response, err := ports.UnmarshalParkedRouteResponse(responseFrame.Payload)274 response, err := ports.UnmarshalParkedRouteResponse(responseFrame.Payload)
276 require.NoError(t, err)275 require.NoError(t, err)
@@ -288,7 +287,7 @@ func TestParkedRouteResumeRejectsSwitchInFlight(t *testing.T) {
288 ac.parkedRouteMu.Lock()287 ac.parkedRouteMu.Lock()
289 ac.parkedRoute.inFlight = true288 ac.parkedRoute.inFlight = true
290 ac.parkedRouteMu.Unlock()289 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())
292 291
293 require.Equal(t, ports.ParkedRouteRejected, status)292 require.Equal(t, ports.ParkedRouteRejected, status)
294 ac.parkedRouteMu.Lock()293 ac.parkedRouteMu.Lock()
@@ -335,7 +334,11 @@ func TestParkedRouteMalformedSwitchRequestsFailClosed(t *testing.T) {
335 {RequestID: 2, Action: ports.ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}},334 {RequestID: 2, Action: ports.ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}},
336 {RequestID: 3, LeaseID: leaseID, Action: 99},335 {RequestID: 3, LeaseID: leaseID, Action: 99},
337 } {336 } {
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()
339 }342 }
340 require.Same(t, source, ac.currentAttachmentSession())343 require.Same(t, source, ac.currentAttachmentSession())
341 select {344 select {
@@ -349,14 +352,13 @@ func TestParkedRouteResponseFailureDoesNotDrainItsOwnFrameEffect(t *testing.T) {
349 d, source, ac, _, releases := newManualTabSession(t, 1)352 d, source, ac, _, releases := newManualTabSession(t, 1)
350 defer releaseAll(releases)353 defer releaseAll(releases)
351 ac.replaceTransport(parkedRouteFailTransport{})354 ac.replaceTransport(parkedRouteFailTransport{})
352- token := source.attachmentToken(ac, ac.transport())355+ token := source.captureAttachmentCapability(ac, ac.transport())
353 effect, admitted := ac.beginAttachmentEffect(token)356 effect, admitted := ac.beginAttachmentEffect(token)
354 require.True(t, admitted)357 require.True(t, admitted)
355- token.effect = effect
356 358
357 result := make(chan bool, 1)359 result := make(chan bool, 1)
358 go func() {360 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})
360 }()362 }()
361 require.False(t, awaitTestValue(t, result, "parked-route send failure cleanup"))363 require.False(t, awaitTestValue(t, result, "parked-route send failure cleanup"))
362 d.attachmentCleanupWg.Wait()364 d.attachmentCleanupWg.Wait()
M internal/usecase/daemon/picker.go +83 -76
@@ -291,7 +291,7 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
291 }291 }
292 }292 }
293 293
294-func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {294+func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
295 sess := ac.currentAttachmentSession()295 sess := ac.currentAttachmentSession()
296 if sess == nil {296 if sess == nil {
297 return297 return
@@ -344,7 +344,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
344 return344 return
345 }345 }
346 if result.action == 'x' {346 if result.action == 'x' {
347- var effect *attachmentEffectTicket347+ var effect *attachmentEffect
348 if len(effects) != 0 {348 if len(effects) != 0 {
349 effect = effects[0]349 effect = effects[0]
350 }350 }
@@ -352,7 +352,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
352 var err error352 var err error
353 if effect != nil {353 if effect != nil {
354 effect.bindActionEnd(d, "picker-delete")354 effect.bindActionEnd(d, "picker-delete")
355- err = d.killPickerTargetForAttachment(target, effect.connectionToken())355+ err = d.killPickerTargetForAttachment(target, effect)
356 } else {356 } else {
357 err = d.killPickerTarget(target)357 err = d.killPickerTarget(target)
358 }358 }
@@ -360,8 +360,8 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
360 d.reportAttachmentError(sess, err)360 d.reportAttachmentError(sess, err)
361 }361 }
362 }362 }
363- if effect != nil && effect.ended.Load() {363+ if effect != nil && !effect.current() {
364- fresh, admitted := ac.beginAttachmentEffect(effect.token)364+ fresh, admitted := ac.beginAttachmentEffect(effect.capability())
365 if !admitted {365 if !admitted {
366 return366 return
367 }367 }
@@ -379,20 +379,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
379 if result.exit && !committing {379 if result.exit && !committing {
380 back := ac.startupOverlay == ports.StartupOverlaySessionPicker && ac.navigationCapabilities&ports.NavigationCapabilityBack != 0380 back := ac.startupOverlay == ports.StartupOverlaySessionPicker && ac.navigationCapabilities&ports.NavigationCapabilityBack != 0
381 if back {381 if back {
382- var token attachmentConnectionToken382+ if len(effects) == 0 || effects[0] == nil {
383- if len(effects) != 0 && effects[0] != nil {383+ d.invalidateRender(sess, ac, true, "picker.go")
384- token = effects[0].connectionToken()384+ return
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()
394 }385 }
395- if err := d.sendNavigationActionForAttachment(token, ports.NavigationBack); err != nil {386+ effect := effects[0]
387+ if err := d.sendNavigationActionForAttachment(effect, ports.NavigationBack); err != nil {
396 d.reportAttachmentError(sess, err)388 d.reportAttachmentError(sess, err)
397 d.invalidateRender(sess, ac, true, "picker.go")389 d.invalidateRender(sess, ac, true, "picker.go")
398 return390 return
@@ -423,7 +415,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*
423 }415 }
424 var err error416 var err error
425 if len(effects) != 0 && effects[0] != nil {417 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")
427 } else {419 } else {
428 d.closePicker(ac)420 d.closePicker(ac)
429 err = d.switchToTarget(sess, ac, target)421 err = d.switchToTarget(sess, ac, target)
@@ -793,8 +785,8 @@ type sessionHandoffGuard struct {
793 // centralized transition. The transition releases admission at its freeze seam785 // centralized transition. The transition releases admission at its freeze seam
794 // and revalidates the exact initiating token before changing target focus or786 // and revalidates the exact initiating token before changing target focus or
795 // attachment ownership.787 // attachment ownership.
796-func (d *Daemon) switchActiveTargetForAttachment(token attachmentConnectionToken, target picker.Target) error {788+func (d *Daemon) switchActiveTargetForAttachment(effect *attachmentEffect, target picker.Target) error {
797- return d.switchActiveTargetForAttachmentGuarded(token, target, sessionHandoffGuard{}, "jump-attention")789+ return d.switchActiveTargetForAttachmentGuarded(effect, target, sessionHandoffGuard{}, "jump-attention")
798 }790 }
799 791
800 func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {792 func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
@@ -819,43 +811,44 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence
819 return fence811 return fence
820 }812 }
821 813
822-func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {814+func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, action string) error {
823- if token.sess == nil || token.ac == nil {815+ if !effect.current() || effect.sess == nil || effect.ac == nil {
824 return nil816 return nil
825 }817 }
826 d.mu.Lock()818 d.mu.Lock()
827 targetSess := d.sessions[target.Session]819 targetSess := d.sessions[target.Session]
828 d.mu.Unlock()820 d.mu.Unlock()
829 if targetSess == nil {821 if targetSess == nil {
830- if !token.attachmentCurrent() {822+ if !effect.current() {
831 return nil823 return nil
832 }824 }
833- d.invalidateRender(token.sess, token.ac, true, "picker.go")825+ d.invalidateRender(effect.sess, effect.ac, true, "picker.go")
834 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)826 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
835 }827 }
836- if targetSess == token.sess {828+ if targetSess == effect.sess {
837 return nil829 return nil
838 }830 }
839 831
832+ capability := effect.capability()
840 transition, err := d.transitionAttachment(attachmentTransitionRequest{833 transition, err := d.transitionAttachment(attachmentTransitionRequest{
841- source: token.sess, target: targetSess, next: token.ac,834+ source: effect.sess, target: targetSess, next: effect.ac,
842 835
843- expectedTransport: token.transport, sourceToken: &token, action: action,836+ expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: action,
844 expectedTargetLifecycle: pickerTargetLifecycleFence(target),837 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
845 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,838 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
846 })839 })
847 if err != nil {840 if err != nil {
848 // Losing the exact source role is a benign stale action, not a notice for841 // Losing the exact source role is a benign stale action, not a notice for
849 // whichever attachment replaced the initiator.842 // whichever attachment replaced the initiator.
850- if !token.attachmentCurrent() {843+ if !capability.current() {
851 return nil844 return nil
852 }845 }
853 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)846 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
854 }847 }
855 if guard.closePicker {848 if guard.closePicker {
856- fresh, admitted := token.ac.beginAttachmentEffect(transition.published)849+ fresh, admitted := effect.ac.beginAttachmentEffect(transition.published)
857 if admitted {850 if admitted {
858- d.closePicker(token.ac)851+ d.closePicker(effect.ac)
859 fresh.End()852 fresh.End()
860 }853 }
861 }854 }
@@ -876,21 +869,21 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
876 // intent captures the exact initiating capability before admission is released;869 // intent captures the exact initiating capability before admission is released;
877 // every active, stopped, and same-session target then uses transitionAttachment870 // every active, stopped, and same-session target then uses transitionAttachment
878 // for frozen, atomic source-token preflight.871 // for frozen, atomic source-token preflight.
879-func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {872+func (d *Daemon) switchToTargetForAttachment(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, action string) error {
880- if token.sess == nil || token.ac == nil || token.effect == nil {873+ if !effect.current() || effect.sess == nil || effect.ac == nil {
881 return nil874 return nil
882 }875 }
883 if target.RemoteTarget != nil || target.RemoteKey != nil {876 if target.RemoteTarget != nil || target.RemoteKey != nil {
884- return d.sendRemoteAttachTargetForAttachment(token, target, guard, action)877+ return d.sendRemoteAttachTargetForAttachment(effect, target, guard, action)
885 }878 }
886- return d.sendLocalAttachTargetForAttachment(token, target, guard, action)879+ return d.sendLocalAttachTargetForAttachment(effect, target, guard, action)
887 }880 }
888 881
889 // sendLocalAttachTargetForAttachment offers an endpoint-empty, exact local882 // sendLocalAttachTargetForAttachment offers an endpoint-empty, exact local
890 // target on the current authenticated connection. A current client confirms it883 // target on the current authenticated connection. A current client confirms it
891 // with MsgSamePeerSwitchRequest; an interrupted or older client retains the884 // with MsgSamePeerSwitchRequest; an interrupted or older client retains the
892 // existing close-and-redial fallback without any daemon-origin inference.885 // 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 {
894 d.mu.Lock()887 d.mu.Lock()
895 var targetSess *session888 var targetSess *session
896 var sessionName string889 var sessionName string
@@ -915,13 +908,13 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
915 targetSess.mu.Unlock()908 targetSess.mu.Unlock()
916 }909 }
917 d.mu.Unlock()910 d.mu.Unlock()
918- if !matches || !token.attachmentCurrent() {911+ if !matches || !effect.current() {
919 return errAttachmentTransition912 return errAttachmentTransition
920 }913 }
921 if targetSess != nil && target.TabID != "" {914 if targetSess != nil && target.TabID != "" {
922 // An explicit tab row is a direct user choice, not route memory; retain915 // An explicit tab row is a direct user choice, not route memory; retain
923 // the established daemon-side transition so it opens exactly that tab.916 // 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)
925 }918 }
926 919
927 // An active session row with no explicit non-default tab is on the920 // An active session row with no explicit non-default tab is on the
@@ -943,15 +936,15 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
943 // same-peer transition keeps the attachment and its namespace; the target936 // same-peer transition keeps the attachment and its namespace; the target
944 // scene diff deletes and replaces the source session's placements.937 // scene diff deletes and replaces the source session's placements.
945 if !samePeerEligible {938 if !samePeerEligible {
946- if err := d.cleanupAttachmentOutput(token.ac); err != nil {939+ if err := d.cleanupAttachmentOutput(effect.ac); err != nil {
947 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't clean up attachment output before local handoff", err)940 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't clean up attachment output before local handoff", err)
948 }941 }
949 } else {942 } else {
950- token.ac.offerSamePeerTarget(*exactTarget)943+ effect.ac.offerSamePeerTarget(*exactTarget)
951 }944 }
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 {
953 if samePeerEligible {946 if samePeerEligible {
954- token.ac.clearSamePeerOffer()947+ effect.ac.clearSamePeerOffer()
955 }948 }
956 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't offer local session switch", err)949 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't offer local session switch", err)
957 }950 }
@@ -961,10 +954,10 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
961 // Keep the source link open until the client receives the ordered cleanup954 // Keep the source link open until the client receives the ordered cleanup
962 // and handoff frames, then let the client's close drive ordinary parking.955 // and handoff frames, then let the client's close drive ordinary parking.
963 if guard.closePicker {956 if guard.closePicker {
964- d.closePicker(token.ac)957+ d.closePicker(effect.ac)
965 }958 }
966- token.effect.bindActionEnd(d, "detach")959+ effect.bindActionEnd(d, "detach")
967- token.effect.End()960+ effect.End()
968 }961 }
969 return nil962 return nil
970 }963 }
@@ -973,10 +966,10 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo
973 // endpoint to the thin client. The daemon owns no remote session shadow: after966 // endpoint to the thin client. The daemon owns no remote session shadow: after
974 // the target is sent, the local attachment is detached and the client opens a967 // the target is sent, the local attachment is detached and the client opens a
975 // fresh connection to the owning daemon.968 // 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 {
977 failUnavailable := func() error {970 failUnavailable := func() error {
978- if token.attachmentCurrent() {971+ if effect.current() {
979- d.notifyRemotePickerUnavailable(token.sess, target)972+ d.notifyRemotePickerUnavailable(effect.sess, target)
980 }973 }
981 return errAttachmentTransition974 return errAttachmentTransition
982 }975 }
@@ -999,13 +992,13 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionT
999 if payload == nil {992 if payload == nil {
1000 return failUnavailable()993 return failUnavailable()
1001 }994 }
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 {
1003 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)996 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)
1004 }997 }
1005 if guard.closePicker {998 if guard.closePicker {
1006- d.closePicker(token.ac)999+ d.closePicker(effect.ac)
1007 }1000 }
1008- d.clientGoneForAttachment(token, false)1001+ d.clientGoneForAttachment(effect, false)
1009 return nil1002 return nil
1010 }1003 }
1011 1004
@@ -1054,14 +1047,19 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target
1054 return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")1047 return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")
1055 }1048 }
1056 1049
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+ }
1058 if target.Name != "" {1056 if target.Name != "" {
1059 ctx := d.serveCtx1057 ctx := d.serveCtx
1060 if ctx == nil {1058 if ctx == nil {
1061 ctx = context.Background()1059 ctx = context.Background()
1062 }1060 }
1063 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {1061 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
1064- if sourceToken != nil && !sourceToken.attachmentCurrent() {1062+ if sourceCapability != nil && !sourceCapability.current() {
1065 return errAttachmentTransition1063 return errAttachmentTransition
1066 }1064 }
1067 d.invalidateRender(from, ac, true, "picker.go")1065 d.invalidateRender(from, ac, true, "picker.go")
@@ -1080,22 +1078,22 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
1080 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)1078 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
1081 if ok {1079 if ok {
1082 if isStopped {1080 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)
1084 } else {1082 } else {
1085 // The snapshot ID may describe the stopped representation. The1083 // The snapshot ID may describe the stopped representation. The
1086 // locked name/lifecycle resolver chose this active incarnation.1084 // locked name/lifecycle resolver chose this active incarnation.
1087 resolvedTarget := target1085 resolvedTarget := target
1088 resolvedTarget.Session = active.id1086 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)
1090 }1088 }
1091 }1089 }
1092 } else if active := d.sessions[target.Session]; active != nil {1090 } 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)
1094 }1092 }
1095 d.mu.Unlock()1093 d.mu.Unlock()
1096 1094
1097 if !switched {1095 if !switched {
1098- if sourceToken != nil && !sourceToken.attachmentCurrent() {1096+ if sourceCapability != nil && !sourceCapability.current() {
1099 return errAttachmentTransition1097 return errAttachmentTransition
1100 }1098 }
1101 if guard.expectedSource != nil {1099 if guard.expectedSource != nil {
@@ -1105,7 +1103,7 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
1105 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)1103 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
1106 }1104 }
1107 if guard.closePicker {1105 if guard.closePicker {
1108- if sourceToken != nil {1106+ if sourceEffect != nil {
1109 fresh, admitted := ac.beginAttachmentEffect(transition.published)1107 fresh, admitted := ac.beginAttachmentEffect(transition.published)
1110 if admitted {1108 if admitted {
1111 d.closePicker(ac)1109 d.closePicker(ac)
@@ -1117,7 +1115,7 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC
1117 }1115 }
1118 d.deferAttachmentTransitionCleanups(transition)1116 d.deferAttachmentTransitionCleanups(transition)
1119 if targetSess == from {1117 if targetSess == from {
1120- if sourceToken != nil {1118+ if sourceEffect != nil {
1121 fresh, admitted := ac.beginAttachmentEffect(transition.published)1119 fresh, admitted := ac.beginAttachmentEffect(transition.published)
1122 if admitted {1120 if admitted {
1123 d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)1121 d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)
@@ -1156,12 +1154,17 @@ func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*sessi
1156 1154
1157 // switchToActiveTargetLocked commits an active target handoff through the1155 // switchToActiveTargetLocked commits an active target handoff through the
1158 // centralized attachment transition. Caller holds d.mu.1156 // 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+ }
1160 if d.sessions[target.Session] != targetSess {1163 if d.sessions[target.Session] != targetSess {
1161 return nil, attachmentTransitionResult{}, false1164 return nil, attachmentTransitionResult{}, false
1162 }1165 }
1163 if targetSess == from {1166 if targetSess == from {
1164- if sourceToken == nil {1167+ if sourceEffect == nil {
1165 targetSess.mu.Lock()1168 targetSess.mu.Lock()
1166 defer targetSess.mu.Unlock()1169 defer targetSess.mu.Unlock()
1167 _, attached := targetSess.attachments[ac]1170 _, attached := targetSess.attachments[ac]
@@ -1177,8 +1180,9 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1177 transition, err := d.transitionAttachment(attachmentTransitionRequest{1180 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1178 source: from, target: targetSess, next: ac,1181 source: from, target: targetSess, next: ac,
1179 1182
1180- expectedTransport: sourceToken.transport,1183+ expectedTransport: sourceCapability.transport,
1181- sourceToken: sourceToken,1184+ sourceCapability: sourceCapability,
1185+ sourceEffect: sourceEffect,
1182 action: action,1186 action: action,
1183 expectedTargetLifecycle: pickerTargetLifecycleFence(target),1187 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
1184 activateTargetTab: true,1188 activateTargetTab: true,
@@ -1215,8 +1219,8 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1215 // d.mu and revalidates both registered lifecycles for publication.1219 // d.mu and revalidates both registered lifecycles for publication.
1216 d.mu.Unlock()1220 d.mu.Unlock()
1217 expectedTransport := ac.transportSnapshot()1221 expectedTransport := ac.transportSnapshot()
1218- if sourceToken != nil {1222+ if sourceCapability != nil {
1219- expectedTransport = sourceToken.transport1223+ expectedTransport = sourceCapability.transport
1220 }1224 }
1221 transition, err := d.transitionAttachment(attachmentTransitionRequest{1225 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1222 source: from,1226 source: from,
@@ -1224,7 +1228,8 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1224 next: ac,1228 next: ac,
1225 1229
1226 expectedTransport: expectedTransport,1230 expectedTransport: expectedTransport,
1227- sourceToken: sourceToken,1231+ sourceCapability: sourceCapability,
1232+ sourceEffect: sourceEffect,
1228 action: action,1233 action: action,
1229 expectedTargetLifecycle: pickerTargetLifecycleFence(target),1234 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
1230 expectedSourceTab: guard.expectedSource,1235 expectedSourceTab: guard.expectedSource,
@@ -1244,7 +1249,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
1244 // resumeStoppedAndSwitchLocked creates the stopped representation and commits1249 // resumeStoppedAndSwitchLocked creates the stopped representation and commits
1245 // the handoff while d.mu is held. Creation failure leaves the source client1250 // the handoff while d.mu is held. Creation failure leaves the source client
1246 // and stopped record untouched.1251 // 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) {
1248 if stopped.broken() {1253 if stopped.broken() {
1249 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}1254 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
1250 }1255 }
@@ -1252,12 +1257,13 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
1252 return nil, attachmentTransitionResult{}, false, errAttachmentTransition1257 return nil, attachmentTransitionResult{}, false, errAttachmentTransition
1253 }1258 }
1254 1259
1255- if sourceToken != nil {1260+ if sourceEffect != nil {
1261+ sourceCapability := sourceEffect.capability()
1256 var targetSess *session1262 var targetSess *session
1257 d.mu.Unlock()1263 d.mu.Unlock()
1258 transition, err := d.transitionAttachment(attachmentTransitionRequest{1264 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1259 source: from, next: ac,1265 source: from, next: ac,
1260- expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,1266+ expectedTransport: sourceCapability.transport, sourceCapability: &sourceCapability, sourceEffect: sourceEffect, action: action,
1261 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,1267 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
1262 createTargetLocked: func() (*session, error) {1268 createTargetLocked: func() (*session, error) {
1263 current, ok := d.inactive[target.Name]1269 current, ok := d.inactive[target.Name]
@@ -1359,19 +1365,20 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
1359 return true1365 return true
1360 }1366 }
1361 1367
1362-func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attachmentConnectionToken) error {1368+func (d *Daemon) killPickerTargetForAttachment(target picker.Target, effect *attachmentEffect) error {
1363- if token.ac == nil {1369+ if effect == nil || effect.ac == nil {
1364 return d.killPickerTarget(target)1370 return d.killPickerTarget(target)
1365 }1371 }
1366 if target.RemoteKey != nil {1372 if target.RemoteKey != nil {
1367 return nil1373 return nil
1368 }1374 }
1375+ capability := effect.capability()
1369 if target.Stopped {1376 if target.Stopped {
1370- token.effect.bindActionEnd(d, "picker-delete")1377+ effect.bindActionEnd(d, "picker-delete")
1371- token.effect.End()1378+ effect.End()
1372- frozen := freezeAttachmentEffectGates(token.ac)1379+ frozen := freezeAttachmentEffectGates(effect.ac)
1373 defer frozen.unfreeze()1380 defer frozen.unfreeze()
1374- if !d.sourceAttachmentTokenCurrentFrozen(token) {1381+ if !d.sourceAttachmentCapabilityCurrentFrozen(capability) {
1375 return nil1382 return nil
1376 }1383 }
1377 return d.killPickerTarget(target)1384 return d.killPickerTarget(target)
@@ -1382,7 +1389,7 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attac
1382 if targetSess == nil {1389 if targetSess == nil {
1383 return nil1390 return nil
1384 }1391 }
1385- return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")1392+ return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, effect, "picker-delete")
1386 }1393 }
1387 1394
1388 func (d *Daemon) killPickerTarget(target picker.Target) error {1395 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) {
900 ac.overlays.pickerMu.Unlock()900 ac.overlays.pickerMu.Unlock()
901 901
902 rc := d.attachCoordinator(sess, nil, ac, true)902 rc := d.attachCoordinator(sess, nil, ac, true)
903- token := sess.attachmentToken(ac, tr)903+ token := sess.captureAttachmentCapability(ac, tr)
904 token.lease = rc.attachmentLease(ac)904 token.lease = rc.attachmentLease(ac)
905- ac.publishAttachmentCapability(token)905+ ac.installTestAttachmentCapability(token)
906 effect, admitted := ac.beginAttachmentEffect(token)906 effect, admitted := ac.beginAttachmentEffect(token)
907 require.True(t, admitted)907 require.True(t, admitted)
908 defer effect.End()908 defer effect.End()
909- token.effect = effect
910 909
911 d.handlePickerInput(ac, []byte("\r"), effect)910 d.handlePickerInput(ac, []byte("\r"), effect)
912 require.True(t, ac.overlays.pickerActive(), "failed navigation send must leave the picker open")911 require.True(t, ac.overlays.pickerActive(), "failed navigation send must leave the picker open")
@@ -1063,7 +1062,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing
1063 case <-time.After(2 * time.Second):1062 case <-time.After(2 * time.Second):
1064 t.Fatal("session switch waited for in-flight transport send")1063 t.Fatal("session switch waited for in-flight transport send")
1065 }1064 }
1066- require.True(t, result.published.attachmentCurrent())1065+ require.True(t, result.published.current())
1067 select {1066 select {
1068 case <-handoffAtSendMu:1067 case <-handoffAtSendMu:
1069 t.Fatal("output rebase ran during architecture publication")1068 t.Fatal("output rebase ran during architecture publication")
@@ -1584,9 +1583,9 @@ func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testi
1584 require.True(t, ok)1583 require.True(t, ok)
1585 require.Equal(t, target.id, selected.Session)1584 require.Equal(t, target.id, selected.Session)
1586 1585
1587- token := current.attachmentToken(ac, ac.transport())1586+ token := current.captureAttachmentCapability(ac, ac.transport())
1588 token.lease = current.renderCoordinator().attachmentLease(ac)1587 token.lease = current.renderCoordinator().attachmentLease(ac)
1589- ac.publishAttachmentCapability(token)1588+ ac.installTestAttachmentCapability(token)
1590 effect, admitted := ac.beginAttachmentEffect(token)1589 effect, admitted := ac.beginAttachmentEffect(token)
1591 require.True(t, admitted)1590 require.True(t, admitted)
1592 d.handlePickerInput(ac, []byte("x"), effect)1591 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
23 d.invalidateRender(sess, ac, true, "prompt.go")23 d.invalidateRender(sess, ac, true, "prompt.go")
24 }24 }
25 25
26-func (d *Daemon) enterTransitionPrompt(sess *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) {
27 d.closePrompt(ac)27 d.closePrompt(ac)
28 ac.overlays.promptMu.Lock()28 ac.overlays.promptMu.Lock()
29 ac.overlays.prompt = promptui.New(title, initial)29 ac.overlays.prompt = promptui.New(title, initial)
@@ -50,7 +50,7 @@ func promptValidationError(err error) bool {
50 errors.Is(err, errSessionNameInUse)50 errors.Is(err, errSessionNameInUse)
51 }51 }
52 52
53-func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {53+func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*attachmentEffect) {
54 sess := ac.currentAttachmentSession()54 sess := ac.currentAttachmentSession()
55 if sess == nil {55 if sess == nil {
56 return56 return
@@ -66,7 +66,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
66 changed := false66 changed := false
67 exit := false67 exit := false
68 var submit func(string) error68 var submit func(string) error
69- var transitionSubmit func(string, attachmentConnectionToken) error69+ var transitionSubmit func(string, *attachmentEffect) error
70 var submittedPrompt *promptui.Model70 var submittedPrompt *promptui.Model
71 var value string71 var value string
72 72
@@ -94,11 +94,11 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...*
94 if submit != nil || transitionSubmit != nil {94 if submit != nil || transitionSubmit != nil {
95 var err error95 var err error
96 if transitionSubmit != nil {96 if transitionSubmit != nil {
97- var token attachmentConnectionToken97+ var effect *attachmentEffect
98- if len(effects) != 0 && effects[0] != nil {98+ if len(effects) != 0 {
99- token = effects[0].connectionToken()99+ effect = effects[0]
100 }100 }
101- err = transitionSubmit(value, token)101+ err = transitionSubmit(value, effect)
102 } else {102 } else {
103 err = submit(value)103 err = submit(value)
104 }104 }
M internal/usecase/daemon/recent_route_snapshot_test.go +4 -5
@@ -64,8 +64,8 @@ func TestAttachmentStatusResolvesSubscribedRouteAttention(t *testing.T) {
64 64
65 func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) {65 func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) {
66 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)66 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
67- token := sess.attachmentToken(ac, ac.transport())67+ token := sess.captureAttachmentCapability(ac, ac.transport())
68- ac.publishAttachmentCapability(token)68+ ac.installTestAttachmentCapability(token)
69 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})69 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
70 70
71 payload, err := ports.MarshalRecentRouteSnapshot(ports.RecentRouteSnapshot{Generation: 2})71 payload, err := ports.MarshalRecentRouteSnapshot(ports.RecentRouteSnapshot{Generation: 2})
@@ -93,13 +93,12 @@ func TestPaletteRecentRouteSelectionSendsTypedClientAction(t *testing.T) {
93 transport := &closeTrackingTransport{}93 transport := &closeTrackingTransport{}
94 ac.replaceTransport(transport)94 ac.replaceTransport(transport)
95 rc := d.attachCoordinator(source, nil, ac, true)95 rc := d.attachCoordinator(source, nil, ac, true)
96- token := source.attachmentToken(ac, transport)96+ token := source.captureAttachmentCapability(ac, transport)
97 token.lease = rc.attachmentLease(ac)97 token.lease = rc.attachmentLease(ac)
98- ac.publishAttachmentCapability(token)98+ ac.installTestAttachmentCapability(token)
99 effect, admitted := ac.beginAttachmentEffect(token)99 effect, admitted := ac.beginAttachmentEffect(token)
100 require.True(t, admitted)100 require.True(t, admitted)
101 defer effect.End()101 defer effect.End()
102- token.effect = effect
103 102
104 exec := paletteExec{103 exec := paletteExec{
105 d: d, sess: source, attachment: source, ac: ac,104 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
102 return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, geometry, env, &expected, validate, expected.tabNames)102 return d.createSessionLockedWithModeAndInactiveFence(target.SessionName, false, cwd, geometry, env, &expected, validate, expected.tabNames)
103 }103 }
104 104
105-func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionToken, action ports.NavigationAction) error {105+func (d *Daemon) sendNavigationActionForAttachment(effect *attachmentEffect, action ports.NavigationAction) error {
106 directive := ports.NavigationDirective{Action: action}106 directive := ports.NavigationDirective{Action: action}
107 armed := false107 armed := false
108 if action == ports.NavigationOpenHomePicker {108 if action == ports.NavigationOpenHomePicker {
109- leaseID, err := d.armParkedRoute(token)109+ leaseID, err := d.armParkedRoute(effect)
110 if err != nil {110 if err != nil {
111 return err111 return err
112 }112 }
@@ -115,7 +115,7 @@ func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionTok
115 }115 }
116 rollback := func() {116 rollback := func() {
117 if armed {117 if armed {
118- token.ac.clearParkedRoute()118+ effect.ac.clearParkedRoute()
119 }119 }
120 }120 }
121 payload := ports.MarshalNavigationDirective(directive)121 payload := ports.MarshalNavigationDirective(directive)
@@ -123,36 +123,36 @@ func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionTok
123 rollback()123 rollback()
124 return errAttachmentTransition124 return errAttachmentTransition
125 }125 }
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 {
127 rollback()127 rollback()
128 return err128 return err
129 }129 }
130 return nil130 return nil
131 }131 }
132 132
133-func (d *Daemon) sendRecentRouteNavigationActionForAttachment(token attachmentConnectionToken, action ports.RouteNavigationAction) error {133+func (d *Daemon) sendRecentRouteNavigationActionForAttachment(effect *attachmentEffect, action ports.RouteNavigationAction) error {
134 payload, err := ports.MarshalRouteNavigationAction(action)134 payload, err := ports.MarshalRouteNavigationAction(action)
135 if err != nil {135 if err != nil {
136 return errAttachmentTransition136 return errAttachmentTransition
137 }137 }
138- return token.sendControl(ports.Frame{Type: ports.MsgNavigateRecentRoute, Payload: payload})138+ return effect.sendControl(ports.Frame{Type: ports.MsgNavigateRecentRoute, Payload: payload})
139 }139 }
140 140
141-func (d *Daemon) sendCommittedRouteIdentityForAttachment(token attachmentConnectionToken) error {141+func (d *Daemon) sendCommittedRouteIdentityForAttachment(effect *attachmentEffect) error {
142- if token.sess == nil {142+ if !effect.current() || effect.sess == nil {
143 return errAttachmentTransition143 return errAttachmentTransition
144 }144 }
145- token.sess.mu.Lock()145+ effect.sess.mu.Lock()
146 identity := ports.CommittedRouteIdentity{146 identity := ports.CommittedRouteIdentity{
147- Target: ports.ExactSessionTarget{LifecycleID: token.sess.incarnation, SessionName: token.sess.name},147+ Target: ports.ExactSessionTarget{LifecycleID: effect.sess.incarnation, SessionName: effect.sess.name},
148- Ephemeral: token.sess.ephemeral,148+ Ephemeral: effect.sess.ephemeral,
149 }149 }
150- token.sess.mu.Unlock()150+ effect.sess.mu.Unlock()
151 payload, err := ports.MarshalCommittedRouteIdentity(identity)151 payload, err := ports.MarshalCommittedRouteIdentity(identity)
152 if err != nil {152 if err != nil {
153 return errAttachmentTransition153 return errAttachmentTransition
154 }154 }
155- return token.sendControl(ports.Frame{Type: ports.MsgCommittedRouteIdentity, Payload: payload})155+ return effect.sendControl(ports.Frame{Type: ports.MsgCommittedRouteIdentity, Payload: payload})
156 }156 }
157 157
158 func (d *Daemon) finishRouteAttach(sess *session, tr ports.Transport, sz domain.Size, h ports.Hello, routeCreated, purge bool) (*attachedClient, error) {158 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) {
22 source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source")22 source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source")
23 target, _, _ := addRemoteRefreshPickerOwner(t, d, "target")23 target, _, _ := addRemoteRefreshPickerOwner(t, d, "target")
24 target.incarnation = remoteLifecycleForTest()24 target.incarnation = remoteLifecycleForTest()
25- token := source.attachmentToken(ac, ac.transport())25+ token := source.captureAttachmentCapability(ac, ac.transport())
26 effect, admitted := ac.beginAttachmentEffect(token)26 effect, admitted := ac.beginAttachmentEffect(token)
27 require.True(t, admitted)27 require.True(t, admitted)
28- token.effect = effect
29 defer effect.End()28 defer effect.End()
30 29
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"))
32 frame := receiveRemotePicker(t, sends, "local attach target")31 frame := receiveRemotePicker(t, sends, "local attach target")
33 got, err := ports.UnmarshalAttachTarget(frame.Payload)32 got, err := ports.UnmarshalAttachTarget(frame.Payload)
34 require.NoError(t, err)33 require.NoError(t, err)
@@ -51,13 +50,12 @@ func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) {
51 graphics.mayHaveEmitted = true50 graphics.mayHaveEmitted = true
52 ac.output.graphicsOutput = graphics51 ac.output.graphicsOutput = graphics
53 52
54- token := source.attachmentToken(ac, ac.transport())53+ token := source.captureAttachmentCapability(ac, ac.transport())
55 effect, admitted := ac.beginAttachmentEffect(token)54 effect, admitted := ac.beginAttachmentEffect(token)
56 require.True(t, admitted)55 require.True(t, admitted)
57- token.effect = effect
58 defer effect.End()56 defer effect.End()
59 57
60- require.NoError(t, d.switchToTargetForAttachment(token, picker.Target{58+ require.NoError(t, d.switchToTargetForAttachment(effect, picker.Target{
61 Name: "stopped", Incarnation: lifecycle,59 Name: "stopped", Incarnation: lifecycle,
62 }, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select"))60 }, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select"))
63 61
@@ -85,15 +83,14 @@ func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
85 d.remoteCatalog.status["arch"] = remoteHostFresh83 d.remoteCatalog.status["arch"] = remoteHostFresh
86 d.remoteCatalog.mu.Unlock()84 d.remoteCatalog.mu.Unlock()
87 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")85 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
88- token := sess.attachmentToken(ac, ac.transport())86+ token := sess.captureAttachmentCapability(ac, ac.transport())
89 effect, admitted := ac.beginAttachmentEffect(token)87 effect, admitted := ac.beginAttachmentEffect(token)
90 require.True(t, admitted)88 require.True(t, admitted)
91- token.effect = effect
92 defer effect.End()89 defer effect.End()
93 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}90 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
94 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}91 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
95 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}92 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"))
97 frame := receiveRemotePicker(t, sends, "rich attach target")94 frame := receiveRemotePicker(t, sends, "rich attach target")
98 got, err := ports.UnmarshalAttachTarget(frame.Payload)95 got, err := ports.UnmarshalAttachTarget(frame.Payload)
99 require.NoError(t, err)96 require.NoError(t, err)
@@ -128,10 +125,9 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) {
128 d.remoteCatalog.status["arch"] = remoteHostFresh125 d.remoteCatalog.status["arch"] = remoteHostFresh
129 d.remoteCatalog.mu.Unlock()126 d.remoteCatalog.mu.Unlock()
130 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")127 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
131- token := sess.attachmentToken(ac, ac.transport())128+ token := sess.captureAttachmentCapability(ac, ac.transport())
132 effect, admitted := ac.beginAttachmentEffect(token)129 effect, admitted := ac.beginAttachmentEffect(token)
133 require.True(t, admitted)130 require.True(t, admitted)
134- token.effect = effect
135 defer effect.End()131 defer effect.End()
136 132
137 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}133 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
@@ -139,7 +135,7 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) {
139 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}135 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}
140 test.mutate(&target)136 test.mutate(&target)
141 137
142- require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(token, target, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)138+ require.ErrorIs(t, d.sendRemoteAttachTargetForAttachment(effect, target, sessionHandoffGuard{}, "picker-select"), errAttachmentTransition)
143 for {139 for {
144 select {140 select {
145 case frame := <-sends:141 case frame := <-sends:
@@ -166,15 +162,14 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
166 d.remoteCatalog.status["arch"] = remoteHostFresh162 d.remoteCatalog.status["arch"] = remoteHostFresh
167 d.remoteCatalog.mu.Unlock()163 d.remoteCatalog.mu.Unlock()
168 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")164 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
169- token := sess.attachmentToken(ac, ac.transport())165+ token := sess.captureAttachmentCapability(ac, ac.transport())
170 effect, admitted := ac.beginAttachmentEffect(token)166 effect, admitted := ac.beginAttachmentEffect(token)
171 require.True(t, admitted)167 require.True(t, admitted)
172- token.effect = effect
173 defer effect.End()168 defer effect.End()
174 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}169 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
175 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}170 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
176 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}171 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)
178 for {173 for {
179 select {174 select {
180 case frame := <-sends:175 case frame := <-sends:
M internal/usecase/daemon/remote_picker_test.go +11 -15
@@ -686,14 +686,13 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
686 d.remoteCatalog.status["arch"] = remoteHostFresh686 d.remoteCatalog.status["arch"] = remoteHostFresh
687 d.remoteCatalog.mu.Unlock()687 d.remoteCatalog.mu.Unlock()
688 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")688 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
689- token := sess.attachmentToken(ac, ac.transport())689+ token := sess.captureAttachmentCapability(ac, ac.transport())
690 effect, admitted := ac.beginAttachmentEffect(token)690 effect, admitted := ac.beginAttachmentEffect(token)
691 require.True(t, admitted)691 require.True(t, admitted)
692- token.effect = effect
693 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}692 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
694 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}693 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
695 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}694 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"))
697 696
698 frame := receiveRemotePicker(t, sends, "attach target")697 frame := receiveRemotePicker(t, sends, "attach target")
699 require.Equal(t, ports.MsgAttachTarget, frame.Type)698 require.Equal(t, ports.MsgAttachTarget, frame.Type)
@@ -742,11 +741,10 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
742 local.remoteCatalog.status["arch"] = remoteHostFresh741 local.remoteCatalog.status["arch"] = remoteHostFresh
743 local.remoteCatalog.mu.Unlock()742 local.remoteCatalog.mu.Unlock()
744 localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local")743 localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local")
745- token := localSession.attachmentToken(localAttachment, localAttachment.transport())744+ token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport())
746 effect, admitted := localAttachment.beginAttachmentEffect(token)745 effect, admitted := localAttachment.beginAttachmentEffect(token)
747 require.True(t, admitted)746 require.True(t, admitted)
748- token.effect = effect747+ require.NoError(t, local.sendRemoteAttachTargetForAttachment(effect, selected, sessionHandoffGuard{closePicker: true}, "picker-select"))
749- require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, sessionHandoffGuard{closePicker: true}, "picker-select"))
750 748
751 frame := receiveRemotePicker(t, sends, "stopped remote target")749 frame := receiveRemotePicker(t, sends, "stopped remote target")
752 handoff, err := ports.UnmarshalAttachTarget(frame.Payload)750 handoff, err := ports.UnmarshalAttachTarget(frame.Payload)
@@ -814,11 +812,10 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing
814 local.remoteCatalog.status["arch"] = remoteHostFresh812 local.remoteCatalog.status["arch"] = remoteHostFresh
815 local.remoteCatalog.mu.Unlock()813 local.remoteCatalog.mu.Unlock()
816 localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local")814 localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local")
817- token := localSession.attachmentToken(localAttachment, localAttachment.transport())815+ token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport())
818 effect, admitted := localAttachment.beginAttachmentEffect(token)816 effect, admitted := localAttachment.beginAttachmentEffect(token)
819 require.True(t, admitted)817 require.True(t, admitted)
820- token.effect = effect818+ require.NoError(t, local.sendRemoteAttachTargetForAttachment(effect, selected, sessionHandoffGuard{closePicker: true}, "picker-select"))
821- require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, sessionHandoffGuard{closePicker: true}, "picker-select"))
822 819
823 frame := receiveRemotePicker(t, sends, "stopped remote target without tab metadata")820 frame := receiveRemotePicker(t, sends, "stopped remote target without tab metadata")
824 handoff, err := ports.UnmarshalAttachTarget(frame.Payload)821 handoff, err := ports.UnmarshalAttachTarget(frame.Payload)
@@ -858,11 +855,11 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
858 if tt.action == ports.NavigationOpenHomePicker {855 if tt.action == ports.NavigationOpenHomePicker {
859 ac.navigationCapabilities = ports.NavigationCapabilityHomePicker856 ac.navigationCapabilities = ports.NavigationCapabilityHomePicker
860 }857 }
861- token := sess.attachmentToken(ac, ac.transport())858+ token := sess.captureAttachmentCapability(ac, ac.transport())
862 effect, admitted := ac.beginAttachmentEffect(token)859 effect, admitted := ac.beginAttachmentEffect(token)
863 require.True(t, admitted)860 require.True(t, admitted)
864- token.effect = effect861+ defer effect.End()
865- require.NoError(t, d.sendNavigationActionForAttachment(token, tt.action))862+ require.NoError(t, d.sendNavigationActionForAttachment(effect, tt.action))
866 frame := receiveRemotePicker(t, sends, "navigation action")863 frame := receiveRemotePicker(t, sends, "navigation action")
867 require.Equal(t, ports.MsgNavigationAction, frame.Type)864 require.Equal(t, ports.MsgNavigationAction, frame.Type)
868 directive, err := ports.UnmarshalNavigationDirective(frame.Payload)865 directive, err := ports.UnmarshalNavigationDirective(frame.Payload)
@@ -901,15 +898,14 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
901 898
902 gone := make(chan struct{})899 gone := make(chan struct{})
903 d.afterClientGoneDetach = func() { close(gone) }900 d.afterClientGoneDetach = func() { close(gone) }
904- token := sess.attachmentToken(ac, tr)901+ token := sess.captureAttachmentCapability(ac, tr)
905 effect, admitted := ac.beginAttachmentEffect(token)902 effect, admitted := ac.beginAttachmentEffect(token)
906 require.True(t, admitted)903 require.True(t, admitted)
907- token.effect = effect
908 defer effect.End()904 defer effect.End()
909 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}905 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
910 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}906 remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
911 target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"}907 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")
913 var userErr *domain.UserError909 var userErr *domain.UserError
914 require.ErrorAs(t, err, &userErr)910 require.ErrorAs(t, err, &userErr)
915 require.Equal(t, "couldn't attach to remote session", userErr.Msg)911 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) {
457 client.release = nil457 client.release = nil
458 client.startedSignal = false458 client.startedSignal = false
459 client.mu.Unlock()459 client.mu.Unlock()
460- token := sess.attachmentToken(ac, ac.transport())460+ token := sess.captureAttachmentCapability(ac, ac.transport())
461 effect, admitted := ac.beginAttachmentEffect(token)461 effect, admitted := ac.beginAttachmentEffect(token)
462 require.True(t, admitted)462 require.True(t, admitted)
463- token.effect = effect
464 defer effect.End()463 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}))
466 <-secondStarted465 <-secondStarted
467 wantWidth, wantHeight := remotePickerPreviewSize(domain.Size{Cols: 100, Rows: 30})466 wantWidth, wantHeight := remotePickerPreviewSize(domain.Size{Cols: 100, Rows: 30})
468 gotWidth, gotHeight := client.LastSize()467 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
294 type runtimeMarkBatch struct {294 type runtimeMarkBatch struct {
295 observer ports.RuntimeObserver295 observer ports.RuntimeObserver
296 marks []ports.RuntimeMark296 marks []ports.RuntimeMark
297- attachmentEffect *attachmentEffectTicket297+ attachmentEffect *attachmentEffect
298 }298 }
299 299
300 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {300 func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
@@ -338,13 +338,15 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
338 sess := entry338 sess := entry
339 local := sess != nil339 local := sess != nil
340 marks := d.newRuntimeMarkBatch()340 marks := d.newRuntimeMarkBatch()
341+ var paintEffect *attachmentEffect
341 if lease != nil {342 if lease != nil {
342- token := attachmentToken(entry, ac, ac.transport())343+ token := captureAttachmentCapability(entry, ac, ac.transport())
343 token.lease = lease344 token.lease = lease
344 ticket, admitted := ac.beginAttachmentEffect(token)345 ticket, admitted := ac.beginAttachmentEffect(token)
345 if !admitted {346 if !admitted {
346 return paintRejected347 return paintRejected
347 }348 }
349+ paintEffect = ticket
348 marks.attachmentEffect = ticket350 marks.attachmentEffect = ticket
349 defer ticket.End()351 defer ticket.End()
350 }352 }
@@ -357,25 +359,24 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at
357 }359 }
358 360
359 ac.sendMu.Lock()361 ac.sendMu.Lock()
360- // sendMu is the attachment ownership boundary. Check the session's362+ if paintEffect != nil {
361- // published identity while holding it so a deadline captured before an363+ if !paintEffect.current() || ac.parkedRouteOutput.Load() {
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) {
376 ac.sendMu.Unlock()364 ac.sendMu.Unlock()
377 return paintRejected365 return paintRejected
378 }366 }
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
379 }380 }
380 // Capacity is checked before any destructive capture. Refresh the atomic381 // Capacity is checked before any destructive capture. Refresh the atomic
381 // readiness snapshot while sendMu is held so direct test/setup mutations of382 // 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
424 ActiveTabID: state.view.tabID,424 ActiveTabID: state.view.tabID,
425 }425 }
426 entry.core().mu.Unlock()426 entry.core().mu.Unlock()
427- if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {427+ if marks.attachmentEffect != nil {
428- ac.sendMu.Unlock()428+ if !marks.attachmentEffect.current() || state.attachment != ac {
429- return false429+ ac.sendMu.Unlock()
430- }430+ return false
431- if state.lease != nil {431+ }
432- rc := attachmentRenderCoordinator(entry)432+ } else {
433- if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {433+ if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
434 ac.sendMu.Unlock()434 ac.sendMu.Unlock()
435 return false435 return false
436 }436 }
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+ }
437 }444 }
438 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)445 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
439 prepared, err := ac.output.prepareFrame(d, state, composed.frame, composed.damage, composed.reset, composed.cursor)446 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
581 // Capture the exact admitted capability, including its coordinator588 // Capture the exact admitted capability, including its coordinator
582 // lease, before End permits a new attachment publication. Reserve589 // lease, before End permits a new attachment publication. Reserve
583 // cleanup accounting before End so terminal Wait cannot race a later Add.590 // cleanup accounting before End so terminal Wait cannot race a later Add.
584- token := marks.attachmentEffect.connectionToken()591+ capability := marks.attachmentEffect.capability()
585- launchCleanup := d.reserveAttachmentSendErrorCleanup(token, sendTr)592+ launchCleanup := d.reserveAttachmentSendErrorCleanup(capability, sendTr)
586 marks.attachmentEffect.End()593 marks.attachmentEffect.End()
587 launchCleanup()594 launchCleanup()
588 }595 }
M internal/usecase/daemon/render_pipeline_test.go +4 -4
@@ -686,12 +686,12 @@ func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
686 686
687 func TestEmitFrameSkipsTransportSendWhenAttachmentEffectFenceRejects(t *testing.T) {687 func TestEmitFrameSkipsTransportSendWhenAttachmentEffectFenceRejects(t *testing.T) {
688 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)688 d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
689- token := sess.attachmentToken(ac, ac.transport())689+ token := sess.captureAttachmentCapability(ac, ac.transport())
690 effect, admitted := ac.beginAttachmentEffect(token)690 effect, admitted := ac.beginAttachmentEffect(token)
691 require.True(t, admitted)691 require.True(t, admitted)
692- ac.attachmentEffects.mu.Lock()692+ ac.lifecycle.mu.Lock()
693- ac.attachmentEffects.capability.transport = transportSnapshot{}693+ ac.lifecycle.capability.transport = transportSnapshot{}
694- ac.attachmentEffects.mu.Unlock()694+ ac.lifecycle.mu.Unlock()
695 695
696 state := cacheState("stale", 1)696 state := cacheState("stale", 1)
697 state.attachment = ac697 state.attachment = ac
M internal/usecase/daemon/resize_publication.go +8 -14
@@ -11,7 +11,7 @@ type resizeCommitPublication struct {
11 attachment *attachedClient11 attachment *attachedClient
12 lease *attachmentLease12 lease *attachmentLease
13 epoch uint6413 epoch uint64
14- connection attachmentConnectionToken14+ effect *attachmentEffect
15 coordinator *renderCoordinator15 coordinator *renderCoordinator
16 observer ports.RuntimeObserver16 observer ports.RuntimeObserver
17 mark ports.RuntimeMark17 mark ports.RuntimeMark
@@ -24,19 +24,13 @@ func (p resizeCommitPublication) current() bool {
24 if p.attachment == nil {24 if p.attachment == nil {
25 return true25 return true
26 }26 }
27- // Attached resize publication is admitted only through a live attachment effect ticket.27+ // Attached resize publication retains the exact admitted effect; lifecycle
28- if p.connection.effect == nil {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 {
29 return false31 return false
30 }32 }
31- if p.connection.effect.ended.Load() || p.connection.sess != p.session ||33+ return p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false)
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)
40 }34 }
41 35
42 // emit performs observer work only after every resize-owner fence and36 // emit performs observer work only after every resize-owner fence and
@@ -57,7 +51,7 @@ func (p resizeCommitPublication) emit() bool {
57 // reservation with pane ownership. sendMu is acquired before resize fences, so51 // reservation with pane ownership. sendMu is acquired before resize fences, so
58 // the canonical rule never acquires an attachment lock while a pane fence is52 // the canonical rule never acquires an attachment lock while a pane fence is
59 // held. Observer callbacks run only after both lock families are released.53 // 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 {
61 if ac != nil && ticket == nil {55 if ac != nil && ticket == nil {
62 return false56 return false
63 }57 }
@@ -85,7 +79,7 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *
85 mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),79 mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true),
86 }80 }
87 if ticket != nil {81 if ticket != nil {
88- publication.connection = ticket.connectionToken()82+ publication.effect = ticket
89 publication.coordinator = sess.renderCoordinator()83 publication.coordinator = sess.renderCoordinator()
90 }84 }
91 if !publication.current() {85 if !publication.current() {
M internal/usecase/daemon/resume.go +18 -3
@@ -362,16 +362,32 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
362 d.mu.Unlock()362 d.mu.Unlock()
363 return false363 return false
364 }364 }
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+ }
365 parked.claimed = false377 parked.claimed = false
366 ac.resumeClaimToken = 0378 ac.resumeClaimToken = 0
367 ac.resumeToken = token379 ac.resumeToken = token
368 ac.parked = true380 ac.parked = true
369- ac.connectionGeneration.Add(1)
370 sess := parked.sess381 sess := parked.sess
371 if sess != nil {382 if sess != nil {
372 sess.mu.Lock()383 sess.mu.Lock()
373 sess.unregisterAttachmentLocked(ac)384 sess.unregisterAttachmentLocked(ac)
385+ ac.setSession(nil)
386+ ac.invalidateFrozenAttachmentCapability()
374 sess.mu.Unlock()387 sess.mu.Unlock()
388+ } else {
389+ ac.setSession(nil)
390+ ac.invalidateFrozenAttachmentCapability()
375 }391 }
376 // Recreate the parked entry and its watcher instead of restoring a timer392 // Recreate the parked entry and its watcher instead of restoring a timer
377 // that may already have fired while the claim held the old entry. This also393 // 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 {
390 }406 }
391 d.parked[token] = rearmed407 d.parked[token] = rearmed
392 captured := ac.transportSnapshot().transport408 captured := ac.transportSnapshot().transport
393- ac.setSession(nil)
394 d.mu.Unlock()409 d.mu.Unlock()
395 if sess != nil {410 if sess != nil {
396 d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")411 d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
@@ -414,8 +429,8 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach
414 d.discardAttachmentOutputLocked(parked.ac)429 d.discardAttachmentOutputLocked(parked.ac)
415 parked.ac.resumeToken = 0430 parked.ac.resumeToken = 0
416 parked.ac.parked = false431 parked.ac.parked = false
417- parked.ac.connectionGeneration.Add(1)
418 parked.ac.setSession(nil)432 parked.ac.setSession(nil)
433+ parked.ac.invalidateRetiredAttachmentCapability()
419 return parkedAttachmentRetirement{434 return parkedAttachmentRetirement{
420 parked: parked,435 parked: parked,
421 pickerGeneration: parked.pickerGeneration,436 pickerGeneration: parked.pickerGeneration,
M internal/usecase/daemon/resume_test.go +2 -2
@@ -1150,7 +1150,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
1150 token := ac.resumeToken1150 token := ac.resumeToken
1151 require.True(t, sess.detachIfCurrent(ac))1151 require.True(t, sess.detachIfCurrent(ac))
1152 require.True(t, d.parkAttachment(sess, ac))1152 require.True(t, d.parkAttachment(sess, ac))
1153- generation := ac.connectionGeneration.Load()1153+ generation := ac.lifecycle.generationValue()
1154 1154
1155 newTr := &closeTrackingTransport{}1155 newTr := &closeTrackingTransport{}
1156 resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})1156 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.
1158 require.True(t, ok)1158 require.True(t, ok)
1159 require.Same(t, sess, resumedSess)1159 require.Same(t, sess, resumedSess)
1160 require.Same(t, ac, resumedAC)1160 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")
1162 1162
1163 _ = ac.closeCapturedTransport(oldTr)1163 _ = ac.closeCapturedTransport(oldTr)
1164 require.True(t, oldTr.Closed(), "old transport is closed")1164 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
88 }()88 }()
89 <-wakeDispatched89 <-wakeDispatched
90 90
91- oldGeneration := ac.connectionGeneration.Load()91+ oldGeneration := ac.lifecycle.generationValue()
92 result, err := d.transitionAttachment(attachmentTransitionRequest{92 result, err := d.transitionAttachment(attachmentTransitionRequest{
93 source: sess, target: sess, next: ac,93 source: sess, target: sess, next: ac,
94 94
@@ -96,7 +96,7 @@ func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testi
96 })96 })
97 require.NoError(t, err)97 require.NoError(t, err)
98 d.deferAttachmentTransitionCleanups(result)98 d.deferAttachmentTransitionCleanups(result)
99- require.Greater(t, ac.connectionGeneration.Load(), oldGeneration)99+ require.Greater(t, ac.lifecycle.generationValue(), oldGeneration)
100 freshLease := rc.attachmentLease(ac)100 freshLease := rc.attachmentLease(ac)
101 require.NotSame(t, oldLease, freshLease)101 require.NotSame(t, oldLease, freshLease)
102 102
M internal/usecase/daemon/route_position_test.go +1 -1
@@ -89,7 +89,7 @@ func TestPaintPublishesChangedAttachmentRoutePosition(t *testing.T) {
89 require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))89 require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
90 sess.repairAttachmentView(ac)90 sess.repairAttachmentView(ac)
91 91
92- require.True(t, d.firstPaintForTransition(sess.attachmentToken(ac, tr)))92+ require.True(t, d.firstPaintForTransition(sess.captureAttachmentCapability(ac, tr)))
93 sess.selectAttachmentTab(ac, "tab-2")93 sess.selectAttachmentTab(ac, "tab-2")
94 d.paint(sess, ac, false, nil)94 d.paint(sess, ac, false, nil)
95 95
M internal/usecase/daemon/same_peer_switch.go +28 -17
@@ -33,27 +33,29 @@ func (ac *attachedClient) consumeSamePeerOffer(target ports.ExactSessionTarget)
33 // switchSamePeerForAttachment commits one client-confirmed endpoint-empty33 // switchSamePeerForAttachment commits one client-confirmed endpoint-empty
34 // target. The request never names an endpoint: its authority is the current34 // target. The request never names an endpoint: its authority is the current
35 // authenticated attachment plus the target's exact lifecycle identity.35 // authenticated attachment plus the target's exact lifecycle identity.
36-func (d *Daemon) switchSamePeerForAttachment(token attachmentConnectionToken, request ports.SamePeerSwitchRequest) {36+func (d *Daemon) switchSamePeerForAttachment(effect *attachmentEffect, request ports.SamePeerSwitchRequest) {
37- if err := request.Validate(); err != nil || token.sess == nil || token.ac == nil {37+ if err := request.Validate(); err != nil || !effect.current() || effect.sess == nil || effect.ac == nil {
38 return38 return
39 }39 }
40- if !token.ac.consumeSamePeerOffer(request.Target) {40+ if !effect.ac.consumeSamePeerOffer(request.Target) {
41- d.sendSamePeerSwitchFailure(token, request.RequestID, ports.SamePeerSwitchStaleTarget)41+ d.sendSamePeerSwitchFailure(effect, request.RequestID, ports.SamePeerSwitchStaleTarget)
42 return42 return
43 }43 }
44 44
45 target, targetTabIndex, ok := d.samePeerTarget(request)45 target, targetTabIndex, ok := d.samePeerTarget(request)
46- if !ok || target == token.sess {46+ if !ok || target == effect.sess {
47- d.sendSamePeerSwitchFailure(token, request.RequestID, ports.SamePeerSwitchStaleTarget)47+ d.sendSamePeerSwitchFailure(effect, request.RequestID, ports.SamePeerSwitchStaleTarget)
48 return48 return
49 }49 }
50 50
51+ capability := effect.capability()
51 transition, err := d.transitionAttachment(attachmentTransitionRequest{52 transition, err := d.transitionAttachment(attachmentTransitionRequest{
52- source: token.sess,53+ source: effect.sess,
53 target: target,54 target: target,
54- next: token.ac,55+ next: effect.ac,
55- expectedTransport: token.transport,56+ expectedTransport: effect.transport,
56- sourceToken: &token,57+ sourceCapability: &capability,
58+ sourceEffect: effect,
57 action: "same-peer-switch",59 action: "same-peer-switch",
58 expectedTargetLifecycle: &attachmentLifecycleFence{60 expectedTargetLifecycle: &attachmentLifecycleFence{
59 name: request.Target.SessionName,61 name: request.Target.SessionName,
@@ -65,12 +67,12 @@ func (d *Daemon) switchSamePeerForAttachment(token attachmentConnectionToken, re
65 ready: true,67 ready: true,
66 })68 })
67 if err != nil {69 if err != nil {
68- d.sendSamePeerSwitchFailure(token, request.RequestID, ports.SamePeerSwitchStaleTarget)70+ d.sendSamePeerSwitchFailure(effect, request.RequestID, ports.SamePeerSwitchStaleTarget)
69 return71 return
70 }72 }
71 73
72- if fresh, admitted := token.ac.beginAttachmentEffect(transition.published); admitted {74+ if fresh, admitted := effect.ac.beginAttachmentEffect(transition.published); admitted {
73- d.closePicker(token.ac)75+ d.closePicker(effect.ac)
74 fresh.End()76 fresh.End()
75 }77 }
76 d.touchMRU(target)78 d.touchMRU(target)
@@ -106,15 +108,24 @@ func (d *Daemon) samePeerTarget(request ports.SamePeerSwitchRequest) (*session,
106 return target, tabIndex, true108 return target, tabIndex, true
107 }109 }
108 110
109-func (d *Daemon) sendSamePeerSwitchFailure(token attachmentConnectionToken, requestID uint64, code ports.SamePeerSwitchFailureCode) {111+func (d *Daemon) sendSamePeerSwitchFailure(effect *attachmentEffect, requestID uint64, code ports.SamePeerSwitchFailureCode) {
110- if !token.attachmentCurrent() {112+ if effect == nil || effect.ac == nil {
111 return113 return
112 }114 }
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+ }
113 payload, err := ports.MarshalSamePeerSwitchFailure(ports.SamePeerSwitchFailure{RequestID: requestID, Code: code})124 payload, err := ports.MarshalSamePeerSwitchFailure(ports.SamePeerSwitchFailure{RequestID: requestID, Code: code})
114 if err != nil {125 if err != nil {
115 return126 return
116 }127 }
117- if err := token.sendControl(ports.Frame{Type: ports.MsgSamePeerSwitchFailure, Payload: payload}); err != nil {128+ if err := sender.sendControl(ports.Frame{Type: ports.MsgSamePeerSwitchFailure, Payload: payload}); err != nil {
118- d.detachOnAttachmentSendError(token, token.transport.transport)129+ d.detachOnAttachmentSendError(sender.capability(), sender.transport.transport)
119 }130 }
120 }131 }
M internal/usecase/daemon/same_peer_switch_test.go +4 -6
@@ -70,15 +70,14 @@ func TestSamePeerSwitchTransitionsExactTargetAndPreferredTab(t *testing.T) {
70 d.mu.Unlock()70 d.mu.Unlock()
71 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})71 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
72 72
73- token := source.attachmentToken(ac, ac.transport())73+ token := source.captureAttachmentCapability(ac, ac.transport())
74 effect, admitted := ac.beginAttachmentEffect(token)74 effect, admitted := ac.beginAttachmentEffect(token)
75 require.True(t, admitted)75 require.True(t, admitted)
76- token.effect = effect
77 defer effect.End()76 defer effect.End()
78 requestTarget := ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"}77 requestTarget := ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"}
79 ac.offerSamePeerTarget(requestTarget)78 ac.offerSamePeerTarget(requestTarget)
80 79
81- d.switchSamePeerForAttachment(token, ports.SamePeerSwitchRequest{80+ d.switchSamePeerForAttachment(effect, ports.SamePeerSwitchRequest{
82 RequestID: 1,81 RequestID: 1,
83 Target: requestTarget,82 Target: requestTarget,
84 PreferredTabID: domain.TabStableID(target.tabs[1].stableID),83 PreferredTabID: domain.TabStableID(target.tabs[1].stableID),
@@ -109,13 +108,12 @@ func TestSamePeerSwitchRejectsStaleTargetWithoutMutation(t *testing.T) {
109 defer releaseAll(releases)108 defer releaseAll(releases)
110 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})109 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
111 110
112- token := source.attachmentToken(ac, ac.transport())111+ token := source.captureAttachmentCapability(ac, ac.transport())
113 effect, admitted := ac.beginAttachmentEffect(token)112 effect, admitted := ac.beginAttachmentEffect(token)
114 require.True(t, admitted)113 require.True(t, admitted)
115- token.effect = effect
116 defer effect.End()114 defer effect.End()
117 115
118- d.switchSamePeerForAttachment(token, ports.SamePeerSwitchRequest{116+ d.switchSamePeerForAttachment(effect, ports.SamePeerSwitchRequest{
119 RequestID: 1,117 RequestID: 1,
120 Target: ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{9}, SessionName: "missing"},118 Target: ports.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{9}, SessionName: "missing"},
121 })119 })
M internal/usecase/daemon/session.go +45 -59
@@ -511,27 +511,28 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
511 511
512 // createSessionAndSwitchForAttachment creates no target until the initiating role512 // createSessionAndSwitchForAttachment creates no target until the initiating role
513 // ticket has ended and the exact source capability is frozen and revalidated.513 // 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 {
515 if name == "" {515 if name == "" {
516 return errSessionNameRequired516 return errSessionNameRequired
517 }517 }
518 if err := domain.ValidateSessionName(name); err != nil {518 if err := domain.ValidateSessionName(name); err != nil {
519 return err519 return err
520 }520 }
521- if token.sess == nil || token.ac == nil || token.effect == nil {521+ if effect == nil || effect.sess == nil || effect.ac == nil {
522 return errAttachmentTransition522 return errAttachmentTransition
523 }523 }
524- token.effect.bindActionEnd(d, "create-session")524+ effect.bindActionEnd(d, "create-session")
525- token.effect.End()525+ effect.End()
526+ capability := effect.capability()
526 527
527 var created *session528 var created *session
528 transition, err := d.transitionAttachment(attachmentTransitionRequest{529 transition, err := d.transitionAttachment(attachmentTransitionRequest{
529- source: token.sess, next: token.ac,530+ source: capability.sess, next: capability.ac,
530 531
531- expectedTransport: token.transport, sourceToken: &token, action: "create-session",532+ expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-session",
532 copySourceEnvironment: true, ready: true,533 copySourceEnvironment: true, ready: true,
533 createTargetLocked: func() (*session, error) {534 createTargetLocked: func() (*session, error) {
534- source := token.sess535+ source := capability.sess
535 if source == nil {536 if source == nil {
536 return nil, errAttachmentTransition537 return nil, errAttachmentTransition
537 }538 }
@@ -545,7 +546,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
545 cwd, env := source.cwd, copyEnvironment(source.env)546 cwd, env := source.cwd, copyEnvironment(source.env)
546 source.mu.Unlock()547 source.mu.Unlock()
547 var createErr error548 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)
549 return created, createErr550 return created, createErr
550 },551 },
551 })552 })
@@ -557,7 +558,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
557 }558 }
558 559
559 d.touchMRU(created)560 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)
561 d.deferAttachmentTransitionCleanups(transition)562 d.deferAttachmentTransitionCleanups(transition)
562 d.firstPaintForTransition(transition.published)563 d.firstPaintForTransition(transition.published)
563 return nil564 return nil
@@ -599,29 +600,30 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie
599 return nil600 return nil
600 }601 }
601 602
602-func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(token attachmentConnectionToken) error {603+func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(effect *attachmentEffect) error {
603- if token.sess == nil || token.ac == nil || token.effect == nil {604+ if effect == nil || effect.sess == nil || effect.ac == nil {
604 return errAttachmentTransition605 return errAttachmentTransition
605 }606 }
606- token.effect.bindActionEnd(d, "create-ephemeral-session")607+ effect.bindActionEnd(d, "create-ephemeral-session")
607- token.effect.End()608+ effect.End()
609+ capability := effect.capability()
608 610
609 var created *session611 var created *session
610 transition, err := d.transitionAttachment(attachmentTransitionRequest{612 transition, err := d.transitionAttachment(attachmentTransitionRequest{
611- source: token.sess, next: token.ac,613+ source: capability.sess, next: capability.ac,
612- expectedTransport: token.transport, sourceToken: &token, action: "create-ephemeral-session",614+ expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-ephemeral-session",
613 copySourceEnvironment: true, ready: true,615 copySourceEnvironment: true, ready: true,
614 createTargetLocked: func() (*session, error) {616 createTargetLocked: func() (*session, error) {
615- if token.sess == nil || d.closing {617+ if capability.sess == nil || d.closing {
616 return nil, errAttachmentTransition618 return nil, errAttachmentTransition
617 }619 }
618- source := token.sess620+ source := capability.sess
619 source.mu.Lock()621 source.mu.Lock()
620 cwd, env := source.cwd, copyEnvironment(source.env)622 cwd, env := source.cwd, copyEnvironment(source.env)
621 source.mu.Unlock()623 source.mu.Unlock()
622 name := d.allocEphemeralNameLocked()624 name := d.allocEphemeralNameLocked()
623 var createErr error625 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)
625 return created, createErr627 return created, createErr
626 },628 },
627 })629 })
@@ -632,7 +634,7 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(token attachmentCo
632 return err634 return err
633 }635 }
634 d.touchMRU(created)636 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)
636 d.deferAttachmentTransitionCleanups(transition)638 d.deferAttachmentTransitionCleanups(transition)
637 d.firstPaintForTransition(transition.published)639 d.firstPaintForTransition(transition.published)
638 return nil640 return nil
@@ -855,17 +857,6 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
855 go d.readPanePTY(p)857 go d.readPanePTY(p)
856 }858 }
857 859
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-
869 // detachIfCurrent publishes terminal attachment invalidation through the attachment860 // detachIfCurrent publishes terminal attachment invalidation through the attachment
870 // gate. Freeze/drain happens before routing or session locks are acquired.861 // gate. Freeze/drain happens before routing or session locks are acquired.
871 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {862 func (d *Daemon) detachIfCurrent(sess *session, ac *attachedClient) bool {
@@ -892,7 +883,6 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
892 }883 }
893 if current {884 if current {
894 sess.unregisterAttachmentLocked(ac)885 sess.unregisterAttachmentLocked(ac)
895- ac.connectionGeneration.Add(1)
896 ac.setSession(nil)886 ac.setSession(nil)
897 ac.invalidateFrozenAttachmentCapability()887 ac.invalidateFrozenAttachmentCapability()
898 }888 }
@@ -904,11 +894,11 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp
904 // detachIfAttachmentCurrent invalidates only the exact attachment generation and transport894 // detachIfAttachmentCurrent invalidates only the exact attachment generation and transport
905 // whose admitted send failed. A queued stale error cannot detach a later895 // whose admitted send failed. A queued stale error cannot detach a later
906 // attachment publication or a rebound transport on the same attachment object.896 // 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 {
908 return d.detachIfAttachmentCurrentUntil(token, nil)898 return d.detachIfAttachmentCurrentUntil(token, nil)
909 }899 }
910 900
911-func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, done func() <-chan struct{}) bool {901+func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentCapability, done func() <-chan struct{}) bool {
912 if token.sess == nil || token.ac == nil || token.transport.transport == nil {902 if token.sess == nil || token.ac == nil || token.transport.transport == nil {
913 return false903 return false
914 }904 }
@@ -938,13 +928,9 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken,
938 if coordinator != nil {928 if coordinator != nil {
939 coordinator.mu.Lock()929 coordinator.mu.Lock()
940 }930 }
941- req := attachmentTransitionRequest{931+ current := coordinator != nil && token.currentInSessionAndLeaseLocked(token.sess, token.ac, coordinator)
942- source: token.sess, next: token.ac, expectedTransport: token.transport,
943- }
944- current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req)
945 if current {932 if current {
946 unregisterAttachmentSessionLocked(token.sess, token.ac)933 unregisterAttachmentSessionLocked(token.sess, token.ac)
947- token.ac.connectionGeneration.Add(1)
948 token.ac.setSession(nil)934 token.ac.setSession(nil)
949 token.ac.invalidateFrozenAttachmentCapability()935 token.ac.invalidateFrozenAttachmentCapability()
950 }936 }
@@ -1059,7 +1045,7 @@ func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) {
1059 if ac.deferRouteIdentityUntilSnapshot() {1045 if ac.deferRouteIdentityUntilSnapshot() {
1060 continue1046 continue
1061 }1047 }
1062- token := sess.attachmentToken(ac, ac.transport())1048+ token := sess.captureAttachmentCapability(ac, ac.transport())
1063 if token.ac == nil {1049 if token.ac == nil {
1064 continue1050 continue
1065 }1051 }
@@ -1067,11 +1053,11 @@ func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) {
1067 if !admitted {1053 if !admitted {
1068 continue1054 continue
1069 }1055 }
1070- token = effect.connectionToken()1056+ err := d.sendCommittedRouteIdentityForAttachment(effect)
1071- err := d.sendCommittedRouteIdentityForAttachment(token)1057+ capability := effect.capability()
1072 effect.End()1058 effect.End()
1073 if err != nil {1059 if err != nil {
1074- d.detachOnAttachmentSendError(token, token.transport.transport)1060+ d.detachOnAttachmentSendError(capability, capability.transport.transport)
1075 }1061 }
1076 }1062 }
1077 }1063 }
@@ -1149,7 +1135,7 @@ func (d *Daemon) closeTabLocked(sess *session, tb *tab, repaint bool) error {
1149 return d.closeTabLockedWithEffect(sess, tb, repaint, nil)1135 return d.closeTabLockedWithEffect(sess, tb, repaint, nil)
1150 }1136 }
1151 1137
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 {
1153 if sess == nil || tb == nil {1139 if sess == nil || tb == nil {
1154 return layout.ErrNotFound1140 return layout.ErrNotFound
1155 }1141 }
@@ -1178,7 +1164,7 @@ func (d *Daemon) closeTabLockedWithEffect(sess *session, tb *tab, repaint bool,
1178 var err error1164 var err error
1179 if effect != nil {1165 if effect != nil {
1180 effect.bindActionEnd(d, "close-tab")1166 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")
1182 } else {1168 } else {
1183 err = d.killSession(sess, ports.ReasonSessionKilled, false)1169 err = d.killSession(sess, ports.ReasonSessionKilled, false)
1184 }1170 }
@@ -1305,8 +1291,9 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
1305 }1291 }
1306 1292
1307 type sessionKillAdmission struct {1293 type sessionKillAdmission struct {
1308- token attachmentConnectionToken1294+ capability attachmentCapability
1309- action string1295+ effect *attachmentEffect
1296+ action string
1310 }1297 }
1311 1298
1312 type sessionKillParticipants struct {1299 type sessionKillParticipants struct {
@@ -1315,12 +1302,15 @@ type sessionKillParticipants struct {
1315 effectGates []*attachedClient1302 effectGates []*attachedClient
1316 interrupts []attachmentTransportInterrupt1303 interrupts []attachmentTransportInterrupt
1317 source *session1304 source *session
1318- sourceToken *attachmentConnectionToken1305+ sourceToken *attachmentCapability
1319 target *session1306 target *session
1320 }1307 }
1321 1308
1322-func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge bool, token attachmentConnectionToken, action string) error {1309+func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge bool, effect *attachmentEffect, action string) error {
1323- return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action})1310+ if effect == nil {
1311+ return errAttachmentTransition
1312+ }
1313+ return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{capability: effect.capability(), effect: effect, action: action})
1324 }1314 }
1325 1315
1326 // snapshotSessionKillParticipants captures attachment membership under the canonical1316 // snapshotSessionKillParticipants captures attachment membership under the canonical
@@ -1330,7 +1320,7 @@ func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge boo
1330 func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission, requireEmpty bool) (sessionKillParticipants, bool) {1320 func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission, requireEmpty bool) (sessionKillParticipants, bool) {
1331 snapshot := sessionKillParticipants{target: target}1321 snapshot := sessionKillParticipants{target: target}
1332 if admission != nil {1322 if admission != nil {
1333- token := admission.token1323+ token := admission.capability
1334 if token.sess == nil || token.ac == nil || token.transport.transport == nil {1324 if token.sess == nil || token.ac == nil || token.transport.transport == nil {
1335 return sessionKillParticipants{}, false1325 return sessionKillParticipants{}, false
1336 }1326 }
@@ -1338,7 +1328,7 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1338 if snapshot.source == nil {1328 if snapshot.source == nil {
1339 return sessionKillParticipants{}, false1329 return sessionKillParticipants{}, false
1340 }1330 }
1341- snapshot.sourceToken = &admission.token1331+ snapshot.sourceToken = &admission.capability
1342 }1332 }
1343 1333
1344 d.mu.Lock()1334 d.mu.Lock()
@@ -1381,7 +1371,7 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses
1381 snapshot.effectGates = append(snapshot.effectGates, ac)1371 snapshot.effectGates = append(snapshot.effectGates, ac)
1382 }1372 }
1383 if admission != nil {1373 if admission != nil {
1384- add(admission.token.ac)1374+ add(admission.capability.ac)
1385 }1375 }
1386 for ac := range snapshot.attachments {1376 for ac := range snapshot.attachments {
1387 add(ac)1377 add(ac)
@@ -1440,10 +1430,7 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour
1440 return true1430 return true
1441 }1431 }
1442 token := *snapshot.sourceToken1432 token := *snapshot.sourceToken
1443- req := attachmentTransitionRequest{1433+ return token.currentInSessionAndLeaseLocked(token.sess, token.ac, sourceCoordinator)
1444- source: token.sess, next: token.ac, expectedTransport: token.transport,
1445- }
1446- return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req)
1447 }1434 }
1448 1435
1449 // stopInMemoryLifecycle cancels every session producer and closes every PTY1436 // stopInMemoryLifecycle cancels every session producer and closes every PTY
@@ -1570,7 +1557,7 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
1570 return nil1557 return nil
1571 }1558 }
1572 if admission != nil {1559 if admission != nil {
1573- d.endActionAttachmentEffect(admission.token.effect, admission.action)1560+ d.endActionAttachmentEffect(admission.effect, admission.action)
1574 }1561 }
1575 action := ""1562 action := ""
1576 if admission != nil {1563 if admission != nil {
@@ -1740,7 +1727,6 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
1740 return1727 return
1741 }1728 }
1742 attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})1729 attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]})
1743- attached.connectionGeneration.Add(1)
1744 attached.setSession(nil)1730 attached.setSession(nil)
1745 attached.invalidateFrozenAttachmentCapability()1731 attached.invalidateFrozenAttachmentCapability()
1746 }1732 }
@@ -1769,7 +1755,7 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas
1769 d.mu.Unlock()1755 d.mu.Unlock()
1770 d.finishParkedAttachmentRetirements(parkedRetirements)1756 d.finishParkedAttachmentRetirements(parkedRetirements)
1771 frozen.unfreeze()1757 frozen.unfreeze()
1772- frozen = frozenAttachmentEffectGates{}1758+ frozen = attachmentTransitionGuard{}
1773 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)1759 d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason)
1774 1760
1775 var purgeErr error1761 var purgeErr error
M internal/usecase/daemon/session_back.go +6 -7
@@ -8,11 +8,11 @@ import (
8 // backSessionForAttachment delegates previous-route ownership to the client8 // backSessionForAttachment delegates previous-route ownership to the client
9 // once it has published a complete route snapshot. There is no daemon-local9 // once it has published a complete route snapshot. There is no daemon-local
10 // previous-session fallback after the global-history cutover.10 // previous-session fallback after the global-history cutover.
11-func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error {11+func (d *Daemon) backSessionForAttachment(effect *attachmentEffect) error {
12- if d == nil || token.sess == nil || token.ac == nil {12+ if d == nil || !effect.current() || effect.sess == nil || effect.ac == nil {
13 return nil13 return nil
14 }14 }
15- snapshot := token.ac.routeSnapshotCopy()15+ snapshot := effect.ac.routeSnapshotCopy()
16 if snapshot.Generation == 0 {16 if snapshot.Generation == 0 {
17 if d.log != nil {17 if d.log != nil {
18 d.log.Debug("back-session skipped: attachment has no published route snapshot")18 d.log.Debug("back-session skipped: attachment has no published route snapshot")
@@ -22,7 +22,7 @@ func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error
22 if snapshot.Previous.Key == 0 || snapshot.Previous.Generation == 0 {22 if snapshot.Previous.Key == 0 || snapshot.Previous.Generation == 0 {
23 return nil23 return nil
24 }24 }
25- return d.sendRecentRouteNavigationActionForAttachment(token, ports.RouteNavigationAction{25+ return d.sendRecentRouteNavigationActionForAttachment(effect, ports.RouteNavigationAction{
26 SnapshotGeneration: snapshot.Generation,26 SnapshotGeneration: snapshot.Generation,
27 Key: snapshot.Previous.Key,27 Key: snapshot.Previous.Key,
28 Generation: snapshot.Previous.Generation,28 Generation: snapshot.Previous.Generation,
@@ -33,15 +33,14 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
33 if d == nil || current == nil || ac == nil {33 if d == nil || current == nil || ac == nil {
34 return34 return
35 }35 }
36- token := attachmentToken(current, ac, ac.transport())36+ token := captureAttachmentCapability(current, ac, ac.transport())
37 effect, admitted := ac.beginAttachmentEffect(token)37 effect, admitted := ac.beginAttachmentEffect(token)
38 if !admitted {38 if !admitted {
39 d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition))39 d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition))
40 return40 return
41 }41 }
42- token.effect = effect
43 defer effect.End()42 defer effect.End()
44- if err := d.backSessionForAttachment(token); err != nil {43+ if err := d.backSessionForAttachment(effect); err != nil {
45 d.reportError(current, err)44 d.reportError(current, err)
46 }45 }
47 }46 }
M internal/usecase/daemon/session_back_route_test.go +3 -4
@@ -12,13 +12,12 @@ func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T
12 transport := &closeTrackingTransport{}12 transport := &closeTrackingTransport{}
13 ac.replaceTransport(transport)13 ac.replaceTransport(transport)
14 rc := d.attachCoordinator(source, nil, ac, true)14 rc := d.attachCoordinator(source, nil, ac, true)
15- token := source.attachmentToken(ac, transport)15+ token := source.captureAttachmentCapability(ac, transport)
16 token.lease = rc.attachmentLease(ac)16 token.lease = rc.attachmentLease(ac)
17- ac.publishAttachmentCapability(token)17+ ac.installTestAttachmentCapability(token)
18 effect, admitted := ac.beginAttachmentEffect(token)18 effect, admitted := ac.beginAttachmentEffect(token)
19 require.True(t, admitted)19 require.True(t, admitted)
20 defer effect.End()20 defer effect.End()
21- token.effect = effect
22 ac.setRouteSnapshot(ports.RecentRouteSnapshot{21 ac.setRouteSnapshot(ports.RecentRouteSnapshot{
23 Generation: 5,22 Generation: 5,
24 Active: ports.RouteRef{Key: 9, Generation: 5},23 Active: ports.RouteRef{Key: 9, Generation: 5},
@@ -26,7 +25,7 @@ func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T
26 Entries: []ports.RecentRouteEntry{testRouteEntry(7, 3, "previous", 7, ports.RouteKindLocal)},25 Entries: []ports.RecentRouteEntry{testRouteEntry(7, 3, "previous", 7, ports.RouteKindLocal)},
27 })26 })
28 27
29- require.NoError(t, d.backSessionForAttachment(token))28+ require.NoError(t, d.backSessionForAttachment(effect))
30 frames := transport.Sends()29 frames := transport.Sends()
31 require.NotEmpty(t, frames)30 require.NotEmpty(t, frames)
32 var action ports.RouteNavigationAction31 var action ports.RouteNavigationAction
M internal/usecase/daemon/session_rename_purge_test.go +4 -4
@@ -17,8 +17,8 @@ func TestRenamePublishesCommittedRouteIdentityToAttachedClients(t *testing.T) {
17 sess.ephemeral = true17 sess.ephemeral = true
18 sess.incarnation = domain.IncarnationID{1}18 sess.incarnation = domain.IncarnationID{1}
19 sess.mu.Unlock()19 sess.mu.Unlock()
20- token := sess.attachmentToken(ac, ac.transport())20+ token := sess.captureAttachmentCapability(ac, ac.transport())
21- ac.publishAttachmentCapability(token)21+ ac.installTestAttachmentCapability(token)
22 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})22 ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1})
23 23
24 require.NoError(t, d.renameSession(sess, "vps-infra"))24 require.NoError(t, d.renameSession(sess, "vps-infra"))
@@ -40,8 +40,8 @@ func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T)
40 sess.ephemeral = true40 sess.ephemeral = true
41 sess.incarnation = domain.IncarnationID{1}41 sess.incarnation = domain.IncarnationID{1}
42 sess.mu.Unlock()42 sess.mu.Unlock()
43- token := sess.attachmentToken(ac, ac.transport())43+ token := sess.captureAttachmentCapability(ac, ac.transport())
44- ac.publishAttachmentCapability(token)44+ ac.installTestAttachmentCapability(token)
45 45
46 require.NoError(t, d.renameSession(sess, "vps-infra"))46 require.NoError(t, d.renameSession(sess, "vps-infra"))
47 require.Empty(t, sends, "the route ledger must be published before its identity can be updated")47 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 *
703 return false703 return false
704 }704 }
705 }705 }
706- connectionGeneration := ac.connectionGeneration.Load()706+ effect, admitted := beginAttachmentLeaseEffect(sess, ac, lease)
707- connection := ac.transportSnapshot()707+ if !admitted {
708+ return false
709+ }
710+ defer effect.End()
708 current := func() bool {711 current := func() bool {
709- return ac.connectionGeneration.Load() == connectionGeneration &&712+ return effect.current() && sess.geometryClaimTokenCurrent(ac, geometryClaim) &&
710- ac.transportSnapshotCurrent(connection) &&
711- sess.geometryClaimTokenCurrent(ac, geometryClaim) &&
712 rc.resizeCurrentForLease(epoch, ac, lease, false)713 rc.resizeCurrentForLease(epoch, ac, lease, false)
713 }714 }
714 if !current() {715 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