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 Unverified
B5690EEEBB952194The 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 | 3 | import "github.com/bnema/vev/internal/ports" | |
| 4 | 4 | ||
| 5 | - | // attachmentConnectionToken is the exact capability for one registered | |
| 6 | - | // attachment connection incarnation. It is invalid as soon as any of the | |
| 7 | - | // session, attachment membership, connection generation, transport link, or | |
| 8 | - | // coordinator lease changes. | |
| 9 | - | type attachmentConnectionToken struct { | |
| 5 | + | // attachmentCapability is the exact capability for one registered | |
| 6 | + | // Attachment Connection incarnation. It is invalid as soon as its Session | |
| 7 | + | // membership, generation, Transport incarnation, or render lease changes. | |
| 8 | + | type attachmentCapability struct { | |
| 10 | 9 | sess *session | |
| 11 | 10 | ac *attachedClient | |
| 12 | 11 | generation uint64 | |
| 13 | 12 | transport transportSnapshot | |
| 14 | 13 | lease *attachmentLease | |
| 15 | - | effect *attachmentEffectTicket | |
| 16 | 14 | // rebase marks a cross-session move whose first paint starts a dependency- | |
| 17 | 15 | // free output chain after publication releases architecture locks. | |
| 18 | 16 | 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 | 20 | if entry == nil || entry.core() == nil || ac == nil || tr == nil { | |
| 23 | - | return attachmentConnectionToken{} | |
| 21 | + | return attachmentCapability{} | |
| 24 | 22 | } | |
| 25 | 23 | // Capture one concrete link incarnation before taking the session lock. The | |
| 26 | 24 | // same snapshot is revalidated below; do not take a second snapshot and | |
| 27 | 25 | // accidentally bind a replacement link. | |
| 28 | 26 | transport := ac.transportSnapshot() | |
| 29 | 27 | if transport.transport != tr { | |
| 30 | - | return attachmentConnectionToken{} | |
| 28 | + | return attachmentCapability{} | |
| 31 | 29 | } | |
| 32 | - | if ac.beforeAttachmentTokenValidation != nil { | |
| 33 | - | ac.beforeAttachmentTokenValidation() | |
| 30 | + | if ac.beforeAttachmentCapabilityValidation != nil { | |
| 31 | + | ac.beforeAttachmentCapabilityValidation() | |
| 34 | 32 | } | |
| 35 | 33 | core := entry.core() | |
| 36 | 34 | core.mu.Lock() | |
| 37 | 35 | if !attachmentRegisteredLocked(entry, ac) || !ac.transportSnapshotCurrent(transport) { | |
| 38 | 36 | core.mu.Unlock() | |
| 39 | - | return attachmentConnectionToken{} | |
| 37 | + | return attachmentCapability{} | |
| 40 | 38 | } | |
| 41 | - | generation := ac.connectionGeneration.Load() | |
| 42 | - | token := attachmentConnectionToken{ | |
| 39 | + | generation := ac.lifecycle.generation.Load() | |
| 40 | + | token := attachmentCapability{ | |
| 43 | 41 | sess: entry, | |
| 44 | 42 | ac: ac, | |
| 45 | 43 | generation: generation, | |
| @@ -50,101 +48,93 @@ func attachmentToken(entry *session, ac *attachedClient, tr ports.Transport) att | |||
| 50 | 48 | } | |
| 51 | 49 | core.mu.Unlock() | |
| 52 | 50 | if !token.current() { | |
| 53 | - | return attachmentConnectionToken{} | |
| 51 | + | return attachmentCapability{} | |
| 54 | 52 | } | |
| 55 | - | ac.bootstrapAttachmentCapability(token) | |
| 53 | + | ac.lifecycle.installInitialCapability(token) | |
| 56 | 54 | return token | |
| 57 | 55 | } | |
| 58 | 56 | ||
| 59 | - | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentConnectionToken { | |
| 60 | - | return attachmentToken(s, ac, tr) | |
| 57 | + | func (s *session) captureAttachmentCapability(ac *attachedClient, tr ports.Transport) attachmentCapability { | |
| 58 | + | return captureAttachmentCapability(s, ac, tr) | |
| 61 | 59 | } | |
| 62 | 60 | ||
| 63 | - | // current validates every immutable identity captured by the token. Membership | |
| 64 | - | // is the sole authority; there is no owner, replacement, or compatibility role. | |
| 65 | - | func (t attachmentConnectionToken) current() bool { | |
| 66 | - | return t.sess != nil && t.ac != nil && | |
| 67 | - | t.ac.connectionGeneration.Load() == t.generation && | |
| 68 | - | t.ac.currentAttachmentSession() == t.sess && | |
| 69 | - | t.ac.transportSnapshotCurrent(t.transport) && | |
| 70 | - | attachmentRegistered(t.sess, t.ac) | |
| 61 | + | func (t attachmentCapability) sameIdentity(other attachmentCapability) bool { | |
| 62 | + | return t.sess != nil && t.ac != nil && t.sess == other.sess && t.ac == other.ac && | |
| 63 | + | t.generation == other.generation && t.transport.transport == other.transport.transport && | |
| 64 | + | t.transport.incarnation == other.transport.incarnation && t.lease == other.lease | |
| 71 | 65 | } | |
| 72 | 66 | ||
| 73 | - | // attachmentCurrent additionally validates the optional coordinator lease used | |
| 74 | - | // by render and resize effects. Attachments without that optional lease still | |
| 75 | - | // own their independent connection lifecycle. | |
| 76 | - | func (t attachmentConnectionToken) attachmentCurrent() bool { | |
| 77 | - | if !t.current() || t.ac.currentAttachmentSession() != t.sess { | |
| 67 | + | func (t attachmentCapability) matchesConnectionSnapshot(sess *session, ac *attachedClient, transport transportSnapshot) bool { | |
| 68 | + | return t.sess == sess && t.ac == ac && ac != nil && t.transport == transport && | |
| 69 | + | ac.lifecycle.identityCurrent(t) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (l *attachmentLifecycle) identityCurrent(capability attachmentCapability) bool { | |
| 73 | + | return l != nil && capability.sess != nil && capability.ac != nil && | |
| 74 | + | capability.ac.lifecycle.generation.Load() == capability.generation && | |
| 75 | + | capability.ac.currentAttachmentSession() == capability.sess && | |
| 76 | + | capability.ac.transportSnapshotCurrent(capability.transport) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // current validates every immutable identity captured by the capability. | |
| 80 | + | // Membership is the sole authority; there is no owner, replacement, or | |
| 81 | + | // compatibility role. | |
| 82 | + | func (l *attachmentLifecycle) current(capability attachmentCapability) bool { | |
| 83 | + | if !l.identityCurrent(capability) || !attachmentRegistered(capability.sess, capability.ac) { | |
| 78 | 84 | return false | |
| 79 | 85 | } | |
| 80 | - | if t.lease == nil { | |
| 86 | + | if capability.lease == nil { | |
| 81 | 87 | return true | |
| 82 | 88 | } | |
| 83 | - | rc := t.sess.core().coordinator.Load() | |
| 84 | - | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 89 | + | rc := capability.sess.core().coordinator.Load() | |
| 90 | + | return rc != nil && capability.lease.attachment == capability.ac && rc.leaseCurrent(capability.lease, false) | |
| 85 | 91 | } | |
| 86 | 92 | ||
| 87 | - | func (t attachmentConnectionToken) attachmentEffectCurrent() bool { | |
| 88 | - | if t.effect != nil && !t.effect.ended.Load() { | |
| 89 | - | return true | |
| 90 | - | } | |
| 91 | - | return t.attachmentCurrent() | |
| 93 | + | func (t attachmentCapability) current() bool { | |
| 94 | + | return t.ac != nil && t.ac.lifecycle.current(t) | |
| 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 | 98 | if sess == nil || ac == nil || lease == nil { | |
| 96 | 99 | return nil, false | |
| 97 | 100 | } | |
| 98 | - | token := attachmentToken(sess, ac, ac.transport()) | |
| 101 | + | token := captureAttachmentCapability(sess, ac, ac.transport()) | |
| 99 | 102 | token.lease = lease | |
| 100 | 103 | return ac.beginAttachmentEffect(token) | |
| 101 | 104 | } | |
| 102 | 105 | ||
| 103 | - | func (t *attachmentConnectionToken) endAttachmentEffect() { | |
| 104 | - | if t == nil || t.effect == nil { | |
| 105 | - | return | |
| 106 | - | } | |
| 107 | - | t.effect.End() | |
| 108 | - | t.effect = nil | |
| 106 | + | func (t attachmentCapability) currentInSessionLocked(sess *session) bool { | |
| 107 | + | return t.sess == sess && t.ac != nil && t.ac.lifecycle.currentSessionLocked(t) | |
| 109 | 108 | } | |
| 110 | 109 | ||
| 111 | - | // attachmentEffectCurrentSessionLocked validates authority at a session-owned | |
| 112 | - | // mutation boundary where sess.core().mu is already held. A live effect ticket | |
| 113 | - | // preserves the exact capability it captured for the effect because attachment | |
| 114 | - | // transitions drain the gate before changing identity. This differs from | |
| 115 | - | // attachmentCurrent, which revalidates the attachment's currently published identity. | |
| 116 | - | func (t attachmentConnectionToken) attachmentEffectCurrentSessionLocked() bool { | |
| 117 | - | if t.effect != nil && !t.effect.ended.Load() { | |
| 118 | - | return true | |
| 119 | - | } | |
| 120 | - | if t.sess == nil || t.ac == nil || | |
| 121 | - | t.ac.connectionGeneration.Load() != t.generation || | |
| 122 | - | !t.ac.transportSnapshotCurrent(t.transport) || | |
| 123 | - | t.ac.currentAttachmentSession() != t.sess || | |
| 124 | - | !attachmentRegisteredLocked(t.sess, t.ac) { | |
| 110 | + | func (t attachmentCapability) currentInSessionAndLeaseLocked(sess *session, ac *attachedClient, coordinator *renderCoordinator) bool { | |
| 111 | + | return t.sess == sess && t.ac == ac && ac != nil && | |
| 112 | + | ac.lifecycle.currentSessionAndLeaseLocked(t, coordinator) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // currentSessionLocked validates capability authority where sess.core().mu is | |
| 116 | + | // already held. An admitted effect does not use this comparison: a transition | |
| 117 | + | // cannot change identity until that effect ends. | |
| 118 | + | func (l *attachmentLifecycle) currentSessionLocked(capability attachmentCapability) bool { | |
| 119 | + | if !l.identityCurrent(capability) || !attachmentRegisteredLocked(capability.sess, capability.ac) { | |
| 125 | 120 | return false | |
| 126 | 121 | } | |
| 127 | - | if t.lease == nil { | |
| 122 | + | if capability.lease == nil { | |
| 128 | 123 | return true | |
| 129 | 124 | } | |
| 130 | - | rc := t.sess.core().coordinator.Load() | |
| 131 | - | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 125 | + | rc := capability.sess.core().coordinator.Load() | |
| 126 | + | return rc != nil && capability.lease.attachment == capability.ac && rc.leaseCurrent(capability.lease, false) | |
| 132 | 127 | } | |
| 133 | 128 | ||
| 134 | - | func (t attachmentConnectionToken) sendControl(frame ports.Frame) error { | |
| 135 | - | if t.ac == nil || t.transport.transport == nil { | |
| 136 | - | return errAttachmentTransition | |
| 137 | - | } | |
| 138 | - | t.ac.sendMu.Lock() | |
| 139 | - | defer t.ac.sendMu.Unlock() | |
| 140 | - | if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 141 | - | !t.effect.beginTransportSend(t.transport) { | |
| 142 | - | return errAttachmentTransition | |
| 129 | + | // currentSessionAndLeaseLocked validates the same capability tuple when the | |
| 130 | + | // caller already holds both the Session and coordinator locks. | |
| 131 | + | func (l *attachmentLifecycle) currentSessionAndLeaseLocked(capability attachmentCapability, coordinator *renderCoordinator) bool { | |
| 132 | + | if !l.identityCurrent(capability) || !attachmentRegisteredLocked(capability.sess, capability.ac) { | |
| 133 | + | return false | |
| 143 | 134 | } | |
| 144 | - | err := t.transport.transport.Send(frame) | |
| 145 | - | if err != nil { | |
| 146 | - | t.effect.reportTransportFailure(t.transport) | |
| 135 | + | if capability.lease == nil { | |
| 136 | + | return true | |
| 147 | 137 | } | |
| 148 | - | t.effect.endTransportSend() | |
| 149 | - | return err | |
| 138 | + | return coordinator != nil && capability.lease.attachment == capability.ac && | |
| 139 | + | 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 | 60 | return d.done | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | - | type frozenAttachmentEffectGates struct { | |
| 63 | + | type attachmentTransitionGuard struct { | |
| 64 | 64 | clients []*attachedClient | |
| 65 | 65 | interruptions map[*attachedClient]transportSnapshot | |
| 66 | 66 | acquired bool | |
| @@ -84,7 +84,7 @@ type attachmentEffectFreezeOptions struct { | |||
| 84 | 84 | ||
| 85 | 85 | // freezeAttachmentEffectGates establishes transition priority in immutable attachment | |
| 86 | 86 | // order, then drains earlier admissions. | |
| 87 | - | func freezeAttachmentEffectGates(clients ...*attachedClient) frozenAttachmentEffectGates { | |
| 87 | + | func freezeAttachmentEffectGates(clients ...*attachedClient) attachmentTransitionGuard { | |
| 88 | 88 | return freezeAttachmentEffectGatesWith(attachmentEffectFreezeOptions{}, clients...) | |
| 89 | 89 | } | |
| 90 | 90 | ||
| @@ -93,11 +93,11 @@ func freezeAttachmentEffectGates(clients ...*attachedClient) frozenAttachmentEff | |||
| 93 | 93 | // behind another owner, only gates acquired by this call are rolled back, in | |
| 94 | 94 | // reverse order. nonblocking is used by move reservations to avoid inverting | |
| 95 | 95 | // the move/teardown protocols. | |
| 96 | - | func freezeAttachmentEffectGatesWith(options attachmentEffectFreezeOptions, clients ...*attachedClient) frozenAttachmentEffectGates { | |
| 96 | + | func freezeAttachmentEffectGatesWith(options attachmentEffectFreezeOptions, clients ...*attachedClient) attachmentTransitionGuard { | |
| 97 | 97 | ordered := orderedAttachmentEffectClients(clients) | |
| 98 | 98 | frozen, acquired := acquireAttachmentEffectGates(ordered, options.done, options.afterFrozen, options.nonblocking) | |
| 99 | 99 | if !acquired { | |
| 100 | - | return frozenAttachmentEffectGates{} | |
| 100 | + | return attachmentTransitionGuard{} | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | 103 | interruptByClient := attachmentTransportInterruptsByClient(options.interrupts) | |
| @@ -121,18 +121,18 @@ func orderedAttachmentEffectClients(clients []*attachedClient) []*attachedClient | |||
| 121 | 121 | continue | |
| 122 | 122 | } | |
| 123 | 123 | unique[ac] = struct{}{} | |
| 124 | - | ac.attachmentEffects.immutableOrder() | |
| 124 | + | ac.lifecycle.immutableOrder() | |
| 125 | 125 | ordered = append(ordered, ac) | |
| 126 | 126 | } | |
| 127 | 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 | 130 | return ordered | |
| 131 | 131 | } | |
| 132 | 132 | ||
| 133 | 133 | // waitForAttachmentEffectChangeLocked requires g.mu and returns with g.mu held. A | |
| 134 | 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 | 136 | if done == nil { | |
| 137 | 137 | g.cond.Wait() | |
| 138 | 138 | return false | |
| @@ -156,22 +156,22 @@ func waitForAttachmentEffectChangeLocked(g *attachmentEffectGate, done func() <- | |||
| 156 | 156 | // acquireAttachmentEffectGates establishes exclusive transition ownership in order. | |
| 157 | 157 | // It must run without architecture locks. Deadline rollback releases only this | |
| 158 | 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) { | |
| 160 | - | frozen := frozenAttachmentEffectGates{clients: make([]*attachedClient, 0, len(ordered))} | |
| 159 | + | func acquireAttachmentEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient), nonblocking bool) (attachmentTransitionGuard, bool) { | |
| 160 | + | frozen := attachmentTransitionGuard{clients: make([]*attachedClient, 0, len(ordered))} | |
| 161 | 161 | for _, ac := range ordered { | |
| 162 | - | g := &ac.attachmentEffects | |
| 162 | + | g := &ac.lifecycle | |
| 163 | 163 | g.mu.Lock() | |
| 164 | 164 | g.initLocked() | |
| 165 | 165 | for g.phase == attachmentEffectsFrozen { | |
| 166 | 166 | if nonblocking { | |
| 167 | 167 | g.mu.Unlock() | |
| 168 | 168 | frozen.unfreeze() | |
| 169 | - | return frozenAttachmentEffectGates{}, false | |
| 169 | + | return attachmentTransitionGuard{}, false | |
| 170 | 170 | } | |
| 171 | 171 | if waitForAttachmentEffectChangeLocked(g, done) { | |
| 172 | 172 | g.mu.Unlock() | |
| 173 | 173 | frozen.unfreeze() | |
| 174 | - | return frozenAttachmentEffectGates{}, false | |
| 174 | + | return attachmentTransitionGuard{}, false | |
| 175 | 175 | } | |
| 176 | 176 | } | |
| 177 | 177 | g.phase = attachmentEffectsFrozen | |
| @@ -204,7 +204,7 @@ func interruptFrozenAttachmentTransports(ordered []*attachedClient, interruptByC | |||
| 204 | 204 | if !mayInterrupt { | |
| 205 | 205 | continue | |
| 206 | 206 | } | |
| 207 | - | g := &ac.attachmentEffects | |
| 207 | + | g := &ac.lifecycle | |
| 208 | 208 | g.mu.Lock() | |
| 209 | 209 | if gateHasTransportEffectLocked(g, expected) { | |
| 210 | 210 | toClose = append(toClose, attachmentTransportInterrupt{ac: ac, transport: expected}) | |
| @@ -221,7 +221,7 @@ func interruptFrozenAttachmentTransports(ordered []*attachedClient, interruptByC | |||
| 221 | 221 | // late tickets can then retire without pinning the owner indefinitely. | |
| 222 | 222 | func drainFrozenAttachmentEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool { | |
| 223 | 223 | for _, ac := range ordered { | |
| 224 | - | g := &ac.attachmentEffects | |
| 224 | + | g := &ac.lifecycle | |
| 225 | 225 | g.mu.Lock() | |
| 226 | 226 | for g.inFlight != 0 { | |
| 227 | 227 | if waitForAttachmentEffectChangeLocked(g, done) { | |
| @@ -238,7 +238,7 @@ func drainFrozenAttachmentEffects(ordered []*attachedClient, interruptByClient, | |||
| 238 | 238 | return true | |
| 239 | 239 | } | |
| 240 | 240 | ||
| 241 | - | func gateHasTransportEffectLocked(g *attachmentEffectGate, expected transportSnapshot) bool { | |
| 241 | + | func gateHasTransportEffectLocked(g *attachmentLifecycle, expected transportSnapshot) bool { | |
| 242 | 242 | for _, active := range g.transportEffects { | |
| 243 | 243 | if active.transport == expected.transport && active.incarnation == expected.incarnation { | |
| 244 | 244 | return true | |
| @@ -247,9 +247,9 @@ func gateHasTransportEffectLocked(g *attachmentEffectGate, expected transportSna | |||
| 247 | 247 | return false | |
| 248 | 248 | } | |
| 249 | 249 | ||
| 250 | - | func (f frozenAttachmentEffectGates) unfreeze() { | |
| 250 | + | func (f attachmentTransitionGuard) unfreeze() { | |
| 251 | 251 | for i := len(f.clients) - 1; i >= 0; i-- { | |
| 252 | - | g := &f.clients[i].attachmentEffects | |
| 252 | + | g := &f.clients[i].lifecycle | |
| 253 | 253 | g.mu.Lock() | |
| 254 | 254 | g.initLocked() | |
| 255 | 255 | g.phase = attachmentEffectsStable | |
M internal/usecase/daemon/attachment_effect_gate.go +108 -101
| @@ -15,46 +15,33 @@ const ( | |||
| 15 | 15 | attachmentEffectsFrozen | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | - | // attachmentCapability is the single attachment-local publication consumed by | |
| 19 | - | // effect admission. It deliberately contains no independently mutable state: | |
| 20 | - | // a ticket is admitted only when every field matches the frame's token. | |
| 21 | - | type attachmentCapability struct { | |
| 22 | - | sess *session | |
| 23 | - | generation uint64 | |
| 24 | - | transport transportSnapshot | |
| 25 | - | lease *attachmentLease | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | func capabilityFromToken(token attachmentConnectionToken) attachmentCapability { | |
| 29 | - | return attachmentCapability{ | |
| 30 | - | sess: token.sess, | |
| 31 | - | generation: token.generation, | |
| 32 | - | transport: token.transport, | |
| 33 | - | lease: token.lease, | |
| 34 | - | } | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | func (c attachmentCapability) matches(token attachmentConnectionToken) bool { | |
| 38 | - | return token.ac != nil && c.sess != nil && token.sess != nil && c.sess == token.sess && | |
| 39 | - | c.generation == token.generation && c.transport.transport == token.transport.transport && | |
| 40 | - | c.transport.incarnation == token.transport.incarnation && c.lease == token.lease | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | type attachmentEffectGate struct { | |
| 18 | + | // attachmentLifecycle owns the immutable committed capability and the | |
| 19 | + | // linearization gate for every attachment-bound observable effect. Transitions | |
| 20 | + | // freeze and drain it before changing membership, generation, Transport, or | |
| 21 | + | // render lease identity. | |
| 22 | + | type attachmentLifecycle struct { | |
| 23 | + | generation atomic.Uint64 | |
| 44 | 24 | mu sync.Mutex | |
| 45 | 25 | cond *sync.Cond | |
| 46 | 26 | changed chan struct{} | |
| 47 | 27 | phase attachmentEffectPhase | |
| 48 | 28 | capability attachmentCapability | |
| 49 | 29 | inFlight uint64 | |
| 50 | - | transportEffects map[*attachmentEffectTicket]transportSnapshot | |
| 30 | + | transportEffects map[*attachmentEffect]transportSnapshot | |
| 51 | 31 | failedTransport transportSnapshot | |
| 52 | 32 | order atomic.Uint64 | |
| 53 | 33 | } | |
| 54 | 34 | ||
| 55 | 35 | 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 | 45 | if g.cond == nil { | |
| 59 | 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 | 54 | g.initLocked() | |
| 68 | 55 | close(g.changed) | |
| 69 | 56 | g.changed = make(chan struct{}) | |
| 70 | 57 | g.cond.Broadcast() | |
| 71 | 58 | } | |
| 72 | 59 | ||
| 73 | - | func (g *attachmentEffectGate) immutableOrder() uint64 { | |
| 60 | + | func (g *attachmentLifecycle) immutableOrder() uint64 { | |
| 74 | 61 | if order := g.order.Load(); order != 0 { | |
| 75 | 62 | return order | |
| 76 | 63 | } | |
| @@ -81,7 +68,7 @@ func (g *attachmentEffectGate) immutableOrder() uint64 { | |||
| 81 | 68 | return g.order.Load() | |
| 82 | 69 | } | |
| 83 | 70 | ||
| 84 | - | func (g *attachmentEffectGate) inFlightCount() uint64 { | |
| 71 | + | func (g *attachmentLifecycle) inFlightCount() uint64 { | |
| 85 | 72 | if g == nil { | |
| 86 | 73 | return 0 | |
| 87 | 74 | } | |
| @@ -90,17 +77,18 @@ func (g *attachmentEffectGate) inFlightCount() uint64 { | |||
| 90 | 77 | return g.inFlight | |
| 91 | 78 | } | |
| 92 | 79 | ||
| 93 | - | // attachmentEffectTicket reserves the exact published capability until End. End is | |
| 94 | - | // idempotent so error paths can safely defer it before doing any observable I/O. | |
| 95 | - | type attachmentEffectTicket struct { | |
| 96 | - | gate *attachmentEffectGate | |
| 97 | - | token attachmentConnectionToken | |
| 80 | + | // attachmentEffect is an explicit admission of one immutable capability. It | |
| 81 | + | // remains valid until End because transitions freeze admission and drain all | |
| 82 | + | // existing effects before changing lifecycle identity. End is idempotent. | |
| 83 | + | type attachmentEffect struct { | |
| 84 | + | attachmentCapability | |
| 85 | + | lifecycle *attachmentLifecycle | |
| 98 | 86 | actionDaemon *Daemon | |
| 99 | 87 | action string | |
| 100 | 88 | 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 | 92 | if t == nil || t.ended.Load() { | |
| 105 | 93 | return | |
| 106 | 94 | } | |
| @@ -108,25 +96,31 @@ func (t *attachmentEffectTicket) bindActionEnd(d *Daemon, action string) { | |||
| 108 | 96 | t.action = action | |
| 109 | 97 | } | |
| 110 | 98 | ||
| 111 | - | func (t *attachmentEffectTicket) connectionToken() attachmentConnectionToken { | |
| 99 | + | func (t *attachmentEffect) capability() attachmentCapability { | |
| 112 | 100 | if t == nil { | |
| 113 | - | return attachmentConnectionToken{} | |
| 101 | + | return attachmentCapability{} | |
| 114 | 102 | } | |
| 115 | - | token := t.token | |
| 116 | - | token.effect = t | |
| 117 | - | return token | |
| 103 | + | return t.attachmentCapability | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (t *attachmentEffect) current() bool { | |
| 107 | + | return t != nil && !t.ended.Load() | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (t *attachmentEffect) currentSessionLocked() bool { | |
| 111 | + | return t.current() | |
| 118 | 112 | } | |
| 119 | 113 | ||
| 120 | - | func (t *attachmentEffectTicket) End() { | |
| 121 | - | if t == nil || t.gate == nil || !t.ended.CompareAndSwap(false, true) { | |
| 114 | + | func (t *attachmentEffect) End() { | |
| 115 | + | if t == nil || t.lifecycle == nil || !t.ended.CompareAndSwap(false, true) { | |
| 122 | 116 | return | |
| 123 | 117 | } | |
| 124 | - | g := t.gate | |
| 118 | + | g := t.lifecycle | |
| 125 | 119 | g.mu.Lock() | |
| 126 | 120 | g.initLocked() | |
| 127 | 121 | if g.inFlight == 0 { | |
| 128 | 122 | g.mu.Unlock() | |
| 129 | - | panic("daemon: attachment effect ticket ended without admission") | |
| 123 | + | panic("daemon: attachment effect ended without admission") | |
| 130 | 124 | } | |
| 131 | 125 | delete(g.transportEffects, t) | |
| 132 | 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 | 135 | if effect != nil { | |
| 142 | 136 | effect.End() | |
| 143 | 137 | } | |
| @@ -146,11 +140,11 @@ func (d *Daemon) endActionAttachmentEffect(effect *attachmentEffectTicket, _ str | |||
| 146 | 140 | // beginTransportSend marks only the interval spent in an external transport | |
| 147 | 141 | // send as interruptible. A replacement may close this exact published link to | |
| 148 | 142 | // release the send without retiring an idle healthy attachment. | |
| 149 | - | func (t *attachmentEffectTicket) beginTransportSend(expected transportSnapshot) bool { | |
| 150 | - | if t == nil || t.gate == nil || t.ended.Load() || expected.transport == nil { | |
| 143 | + | func (t *attachmentEffect) beginTransportSend(expected transportSnapshot) bool { | |
| 144 | + | if t == nil || t.lifecycle == nil || t.ended.Load() || expected.transport == nil { | |
| 151 | 145 | return false | |
| 152 | 146 | } | |
| 153 | - | g := t.gate | |
| 147 | + | g := t.lifecycle | |
| 154 | 148 | g.mu.Lock() | |
| 155 | 149 | g.initLocked() | |
| 156 | 150 | if t.ended.Load() || g.capability.transport.transport != expected.transport || | |
| @@ -159,7 +153,7 @@ func (t *attachmentEffectTicket) beginTransportSend(expected transportSnapshot) | |||
| 159 | 153 | return false | |
| 160 | 154 | } | |
| 161 | 155 | if g.transportEffects == nil { | |
| 162 | - | g.transportEffects = make(map[*attachmentEffectTicket]transportSnapshot) | |
| 156 | + | g.transportEffects = make(map[*attachmentEffect]transportSnapshot) | |
| 163 | 157 | } | |
| 164 | 158 | g.transportEffects[t] = expected | |
| 165 | 159 | g.signalLocked() | |
| @@ -167,11 +161,11 @@ func (t *attachmentEffectTicket) beginTransportSend(expected transportSnapshot) | |||
| 167 | 161 | return true | |
| 168 | 162 | } | |
| 169 | 163 | ||
| 170 | - | func (t *attachmentEffectTicket) reportTransportFailure(expected transportSnapshot) { | |
| 171 | - | if t == nil || t.gate == nil || expected.transport == nil { | |
| 164 | + | func (t *attachmentEffect) reportTransportFailure(expected transportSnapshot) { | |
| 165 | + | if t == nil || t.lifecycle == nil || expected.transport == nil { | |
| 172 | 166 | return | |
| 173 | 167 | } | |
| 174 | - | g := t.gate | |
| 168 | + | g := t.lifecycle | |
| 175 | 169 | g.mu.Lock() | |
| 176 | 170 | if !t.ended.Load() && g.capability.transport.transport == expected.transport && | |
| 177 | 171 | g.capability.transport.incarnation == expected.incarnation { | |
| @@ -181,11 +175,11 @@ func (t *attachmentEffectTicket) reportTransportFailure(expected transportSnapsh | |||
| 181 | 175 | g.mu.Unlock() | |
| 182 | 176 | } | |
| 183 | 177 | ||
| 184 | - | func (t *attachmentEffectTicket) endTransportSend() { | |
| 185 | - | if t == nil || t.gate == nil { | |
| 178 | + | func (t *attachmentEffect) endTransportSend() { | |
| 179 | + | if t == nil || t.lifecycle == nil { | |
| 186 | 180 | return | |
| 187 | 181 | } | |
| 188 | - | g := t.gate | |
| 182 | + | g := t.lifecycle | |
| 189 | 183 | g.mu.Lock() | |
| 190 | 184 | if _, exists := g.transportEffects[t]; exists { | |
| 191 | 185 | delete(g.transportEffects, t) | |
| @@ -194,45 +188,57 @@ func (t *attachmentEffectTicket) endTransportSend() { | |||
| 194 | 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 | 208 | // beginAttachmentEffect is the sole attachment-bound effect admission point. The gate is | |
| 198 | 209 | // held only long enough to reserve the capability; the effect itself must run | |
| 199 | 210 | // without the gate mutex. | |
| 200 | - | func (ac *attachedClient) beginAttachmentEffect(token attachmentConnectionToken) (*attachmentEffectTicket, bool) { | |
| 201 | - | if ac == nil || token.ac != ac { | |
| 211 | + | func (ac *attachedClient) beginAttachmentEffect(token attachmentCapability) (*attachmentEffect, bool) { | |
| 212 | + | if ac == nil || token.ac != ac || !token.current() { | |
| 202 | 213 | return nil, false | |
| 203 | 214 | } | |
| 204 | - | g := &ac.attachmentEffects | |
| 215 | + | g := &ac.lifecycle | |
| 205 | 216 | g.mu.Lock() | |
| 206 | 217 | g.initLocked() | |
| 207 | - | if g.phase != attachmentEffectsStable || !g.capability.matches(token) { | |
| 218 | + | if g.phase != attachmentEffectsStable || !g.capability.sameIdentity(token) { | |
| 208 | 219 | g.mu.Unlock() | |
| 209 | 220 | return nil, false | |
| 210 | 221 | } | |
| 211 | 222 | g.inFlight++ | |
| 212 | 223 | g.mu.Unlock() | |
| 213 | - | captured := token | |
| 214 | - | captured.effect = nil | |
| 215 | - | return &attachmentEffectTicket{gate: g, token: captured}, true | |
| 224 | + | return &attachmentEffect{attachmentCapability: token, lifecycle: g}, true | |
| 216 | 225 | } | |
| 217 | 226 | ||
| 218 | 227 | // beginCurrentAttachmentEffect waits out a transition that already froze ac, then | |
| 219 | 228 | // admits the capability derived from the session registry. Handshakes use it | |
| 220 | 229 | // after Welcome has completed so a replacement blocked behind that send can | |
| 221 | 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 | 232 | //nolint:contextcheck // This compatibility wrapper deliberately delegates with a fresh context; cancellable callers use the context variant below. | |
| 224 | 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) { | |
| 228 | - | if ctx == nil { | |
| 229 | - | ctx = context.Background() | |
| 230 | - | } | |
| 231 | - | if ac == nil || sess == nil || tr == nil { | |
| 232 | - | return attachmentConnectionToken{}, nil, false | |
| 236 | + | func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Context, sess *session, tr ports.Transport) (attachmentCapability, *attachmentEffect, bool) { | |
| 237 | + | if ctx == nil || ac == nil || sess == nil || tr == nil { | |
| 238 | + | return attachmentCapability{}, nil, false | |
| 233 | 239 | } | |
| 234 | 240 | for { | |
| 235 | - | token := sess.attachmentToken(ac, tr) | |
| 241 | + | token := sess.captureAttachmentCapability(ac, tr) | |
| 236 | 242 | if token.ac == nil { | |
| 237 | 243 | return token, nil, false | |
| 238 | 244 | } | |
| @@ -240,7 +246,7 @@ func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Contex | |||
| 240 | 246 | return token, ticket, true | |
| 241 | 247 | } | |
| 242 | 248 | ||
| 243 | - | g := &ac.attachmentEffects | |
| 249 | + | g := &ac.lifecycle | |
| 244 | 250 | g.mu.Lock() | |
| 245 | 251 | g.initLocked() | |
| 246 | 252 | if g.phase == attachmentEffectsFrozen { | |
| @@ -254,8 +260,8 @@ func (ac *attachedClient) beginCurrentAttachmentEffectContext(ctx context.Contex | |||
| 254 | 260 | } | |
| 255 | 261 | } | |
| 256 | 262 | g.mu.Unlock() | |
| 257 | - | // A stable mismatch is only recoverable when a publication raced token | |
| 258 | - | // capture; an already-stale token proves that happened. A current token | |
| 263 | + | // A stable mismatch is only recoverable when a publication raced capability | |
| 264 | + | // capture; an already-stale capability proves that happened. A current capability | |
| 259 | 265 | // with a stable mismatch violates the publication invariant, so fail | |
| 260 | 266 | // closed instead of spinning forever. | |
| 261 | 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 transition | |
| 270 | - | // publication while the gate is frozen. Production attachment changes publish only | |
| 271 | - | // through publishFrozenAttachmentCapability. | |
| 272 | - | func (ac *attachedClient) publishAttachmentCapability(token attachmentConnectionToken) { | |
| 273 | - | if ac == nil { | |
| 274 | - | return | |
| 275 | - | } | |
| 276 | - | g := &ac.attachmentEffects | |
| 275 | + | // installInitialCapability publishes only an empty lifecycle owner. It supports | |
| 276 | + | // direct/headless construction without creating a second mutable publication. | |
| 277 | + | func (g *attachmentLifecycle) installInitialCapability(capability attachmentCapability) { | |
| 277 | 278 | g.mu.Lock() | |
| 278 | 279 | g.initLocked() | |
| 279 | - | g.capability = capabilityFromToken(token) | |
| 280 | - | g.failedTransport = transportSnapshot{} | |
| 280 | + | if g.phase == attachmentEffectsStable && g.capability.sess == nil { | |
| 281 | + | g.capability = capability | |
| 282 | + | g.failedTransport = transportSnapshot{} | |
| 283 | + | } | |
| 281 | 284 | g.mu.Unlock() | |
| 282 | 285 | } | |
| 283 | 286 | ||
| 284 | - | // bootstrapAttachmentCapability supports direct/headless session construction. It | |
| 285 | - | // never changes an existing or frozen production publication. | |
| 286 | - | func (ac *attachedClient) bootstrapAttachmentCapability(token attachmentConnectionToken) { | |
| 287 | - | if ac == nil || token.sess == nil || token.ac != ac { | |
| 288 | - | return | |
| 289 | - | } | |
| 290 | - | g := &ac.attachmentEffects | |
| 287 | + | func (ac *attachedClient) publishFrozenAttachmentCapability(capability attachmentCapability) attachmentCapability { | |
| 288 | + | g := &ac.lifecycle | |
| 291 | 289 | g.mu.Lock() | |
| 292 | 290 | g.initLocked() | |
| 293 | - | if g.phase == attachmentEffectsStable && g.capability.sess == nil { | |
| 294 | - | g.capability = capabilityFromToken(token) | |
| 295 | - | g.failedTransport = transportSnapshot{} | |
| 291 | + | if g.phase != attachmentEffectsFrozen { | |
| 292 | + | g.mu.Unlock() | |
| 293 | + | panic("daemon: publishing attachment capability without transition freeze") | |
| 296 | 294 | } | |
| 295 | + | capability.generation = g.generation.Add(1) | |
| 296 | + | g.capability = capability | |
| 297 | + | g.failedTransport = transportSnapshot{} | |
| 297 | 298 | g.mu.Unlock() | |
| 299 | + | return capability | |
| 298 | 300 | } | |
| 299 | 301 | ||
| 300 | - | func (ac *attachedClient) publishFrozenAttachmentCapability(token attachmentConnectionToken) { | |
| 301 | - | g := &ac.attachmentEffects | |
| 302 | + | // invalidateRetiredAttachmentCapability retires parked identity either while | |
| 303 | + | // stable with no admitted effects or under a terminal teardown's existing | |
| 304 | + | // freeze. Live membership transitions use invalidateFrozenAttachmentCapability. | |
| 305 | + | func (ac *attachedClient) invalidateRetiredAttachmentCapability() { | |
| 306 | + | g := &ac.lifecycle | |
| 302 | 307 | g.mu.Lock() | |
| 303 | 308 | g.initLocked() | |
| 304 | - | if g.phase != attachmentEffectsFrozen { | |
| 309 | + | if g.phase != attachmentEffectsFrozen && (g.phase != attachmentEffectsStable || g.inFlight != 0) { | |
| 305 | 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 | 315 | g.failedTransport = transportSnapshot{} | |
| 310 | 316 | g.mu.Unlock() | |
| 311 | 317 | } | |
| 312 | 318 | ||
| 313 | 319 | func (ac *attachedClient) invalidateFrozenAttachmentCapability() { | |
| 314 | - | g := &ac.attachmentEffects | |
| 320 | + | g := &ac.lifecycle | |
| 315 | 321 | g.mu.Lock() | |
| 316 | 322 | g.initLocked() | |
| 317 | 323 | if g.phase != attachmentEffectsFrozen { | |
| 318 | 324 | g.mu.Unlock() | |
| 319 | 325 | panic("daemon: invalidating attachment capability without transition freeze") | |
| 320 | 326 | } | |
| 327 | + | g.generation.Add(1) | |
| 321 | 328 | g.capability = attachmentCapability{} | |
| 322 | 329 | g.failedTransport = transportSnapshot{} | |
| 323 | 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 | 122 | t.Helper() | |
| 123 | 123 | ticket, admitted := token.ac.beginAttachmentEffect(token) | |
| 124 | 124 | require.True(t, admitted) | |
| @@ -133,19 +133,19 @@ func startBlockedAttachmentSend(t *testing.T, token attachmentConnectionToken) < | |||
| 133 | 133 | ||
| 134 | 134 | func requireAttachmentEffectGateRetired(t *testing.T, ac *attachedClient) { | |
| 135 | 135 | t.Helper() | |
| 136 | - | ac.attachmentEffects.mu.Lock() | |
| 137 | - | defer ac.attachmentEffects.mu.Unlock() | |
| 138 | - | require.Equal(t, attachmentEffectsStable, ac.attachmentEffects.phase) | |
| 139 | - | require.Zero(t, ac.attachmentEffects.inFlight) | |
| 140 | - | require.Empty(t, ac.attachmentEffects.transportEffects) | |
| 136 | + | ac.lifecycle.mu.Lock() | |
| 137 | + | defer ac.lifecycle.mu.Unlock() | |
| 138 | + | require.Equal(t, attachmentEffectsStable, ac.lifecycle.phase) | |
| 139 | + | require.Zero(t, ac.lifecycle.inFlight) | |
| 140 | + | require.Empty(t, ac.lifecycle.transportEffects) | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | 143 | func TestBeginCurrentAttachmentEffectFailsClosedOnStableCapabilityMismatch(t *testing.T) { | |
| 144 | 144 | _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 145 | - | current := sess.attachmentToken(ac, ac.transport()) | |
| 145 | + | current := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 146 | 146 | stale := current | |
| 147 | 147 | stale.generation++ | |
| 148 | - | ac.publishAttachmentCapability(stale) | |
| 148 | + | ac.installTestAttachmentCapability(stale) | |
| 149 | 149 | ||
| 150 | 150 | result := make(chan bool, 1) | |
| 151 | 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 | 176 | func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) { | |
| 167 | 177 | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 168 | 178 | rc := d.attachCoordinator(sess, nil, ac, true) | |
| @@ -173,9 +183,9 @@ func TestBeginAttachmentLeaseEffectRejectsForeignLease(t *testing.T) { | |||
| 173 | 183 | } | |
| 174 | 184 | ||
| 175 | 185 | func attachmentEffectGatePublicationSnapshot(ac *attachedClient) (attachmentEffectPhase, attachmentCapability) { | |
| 176 | - | ac.attachmentEffects.mu.Lock() | |
| 177 | - | defer ac.attachmentEffects.mu.Unlock() | |
| 178 | - | return ac.attachmentEffects.phase, ac.attachmentEffects.capability | |
| 186 | + | ac.lifecycle.mu.Lock() | |
| 187 | + | defer ac.lifecycle.mu.Unlock() | |
| 188 | + | return ac.lifecycle.phase, ac.lifecycle.capability | |
| 179 | 189 | } | |
| 180 | 190 | ||
| 181 | 191 | func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testing.T) { | |
| @@ -197,21 +207,21 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||
| 197 | 207 | sess.mu.Unlock() | |
| 198 | 208 | ||
| 199 | 209 | rc := d.attachCoordinator(sess, nil, first, true) | |
| 200 | - | firstToken := sess.attachmentToken(first, firstTransport) | |
| 210 | + | firstToken := sess.captureAttachmentCapability(first, firstTransport) | |
| 201 | 211 | firstToken.lease = rc.attachmentLease(first) | |
| 202 | - | first.publishAttachmentCapability(firstToken) | |
| 203 | - | blockedToken := sess.attachmentToken(blocked, blockedTransport) | |
| 204 | - | blocked.publishAttachmentCapability(blockedToken) | |
| 205 | - | laterToken := sess.attachmentToken(later, laterTransport) | |
| 206 | - | later.publishAttachmentCapability(laterToken) | |
| 212 | + | first.installTestAttachmentCapability(firstToken) | |
| 213 | + | blockedToken := sess.captureAttachmentCapability(blocked, blockedTransport) | |
| 214 | + | blocked.installTestAttachmentCapability(blockedToken) | |
| 215 | + | laterToken := sess.captureAttachmentCapability(later, laterTransport) | |
| 216 | + | later.installTestAttachmentCapability(laterToken) | |
| 207 | 217 | ||
| 208 | 218 | // Fix the canonical order so teardown acquires first, waits behind the gate | |
| 209 | 219 | // owned by this test, and never reaches the still-stable later participant. | |
| 210 | - | first.attachmentEffects.immutableOrder() | |
| 211 | - | blocked.attachmentEffects.immutableOrder() | |
| 212 | - | later.attachmentEffects.immutableOrder() | |
| 213 | - | require.Less(t, first.attachmentEffects.order.Load(), blocked.attachmentEffects.order.Load()) | |
| 214 | - | require.Less(t, blocked.attachmentEffects.order.Load(), later.attachmentEffects.order.Load()) | |
| 220 | + | first.lifecycle.immutableOrder() | |
| 221 | + | blocked.lifecycle.immutableOrder() | |
| 222 | + | later.lifecycle.immutableOrder() | |
| 223 | + | require.Less(t, first.lifecycle.order.Load(), blocked.lifecycle.order.Load()) | |
| 224 | + | require.Less(t, blocked.lifecycle.order.Load(), later.lifecycle.order.Load()) | |
| 215 | 225 | ||
| 216 | 226 | blockedOwner := freezeAttachmentEffectGates(blocked) | |
| 217 | 227 | blockedOwnerHeld := true | |
| @@ -227,9 +237,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||
| 227 | 237 | require.Equal(t, attachmentEffectsStable, firstPhase) | |
| 228 | 238 | require.Equal(t, attachmentEffectsFrozen, blockedPhase) | |
| 229 | 239 | require.Equal(t, attachmentEffectsStable, laterPhase) | |
| 230 | - | firstGeneration := first.connectionGeneration.Load() | |
| 231 | - | blockedGeneration := blocked.connectionGeneration.Load() | |
| 232 | - | laterGeneration := later.connectionGeneration.Load() | |
| 240 | + | firstGeneration := first.lifecycle.generationValue() | |
| 241 | + | blockedGeneration := blocked.lifecycle.generationValue() | |
| 242 | + | laterGeneration := later.lifecycle.generationValue() | |
| 233 | 243 | firstSnapshot := first.transportSnapshot() | |
| 234 | 244 | blockedSnapshot := blocked.transportSnapshot() | |
| 235 | 245 | laterSnapshot := later.transportSnapshot() | |
| @@ -264,9 +274,9 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||
| 264 | 274 | for _, ac := range []*attachedClient{first, blocked, later} { | |
| 265 | 275 | require.Same(t, sess, ac.currentSession(), "partial gate acquisition cleared attachment ownership") | |
| 266 | 276 | } | |
| 267 | - | require.Equal(t, firstGeneration, first.connectionGeneration.Load()) | |
| 268 | - | require.Equal(t, blockedGeneration, blocked.connectionGeneration.Load()) | |
| 269 | - | require.Equal(t, laterGeneration, later.connectionGeneration.Load()) | |
| 277 | + | require.Equal(t, firstGeneration, first.lifecycle.generationValue()) | |
| 278 | + | require.Equal(t, blockedGeneration, blocked.lifecycle.generationValue()) | |
| 279 | + | require.Equal(t, laterGeneration, later.lifecycle.generationValue()) | |
| 270 | 280 | require.True(t, first.transportSnapshotCurrent(firstSnapshot)) | |
| 271 | 281 | require.True(t, blocked.transportSnapshotCurrent(blockedSnapshot)) | |
| 272 | 282 | require.True(t, later.transportSnapshotCurrent(laterSnapshot)) | |
| @@ -290,7 +300,7 @@ func TestKillSessionAcquisitionTimeoutDoesNotPublishPartialInvalidation(t *testi | |||
| 290 | 300 | require.True(t, complete.acquired, "rolled-back gate set could not be acquired again") | |
| 291 | 301 | require.True(t, complete.drained) | |
| 292 | 302 | complete.unfreeze() | |
| 293 | - | for _, token := range []attachmentConnectionToken{firstToken, blockedToken, laterToken} { | |
| 303 | + | for _, token := range []attachmentCapability{firstToken, blockedToken, laterToken} { | |
| 294 | 304 | ticket, admitted := token.ac.beginAttachmentEffect(token) | |
| 295 | 305 | require.True(t, admitted, "pre-publication abort changed an attachment capability") | |
| 296 | 306 | ticket.End() | |
| @@ -303,7 +313,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||
| 303 | 313 | snatchedTransport := newTeardownBlockingTransport() | |
| 304 | 314 | snatched.replaceTransport(snatchedTransport) | |
| 305 | 315 | d.attachCoordinator(sess, nil, snatched, true) | |
| 306 | - | snatched.publishAttachmentCapability(sess.attachmentToken(snatched, snatchedTransport)) | |
| 316 | + | snatched.installTestAttachmentCapability(sess.captureAttachmentCapability(snatched, snatchedTransport)) | |
| 307 | 317 | ||
| 308 | 318 | activeTransport := newTeardownBlockingTransport() | |
| 309 | 319 | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: snatched.size} | |
| @@ -324,8 +334,8 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||
| 324 | 334 | d.mu.Unlock() | |
| 325 | 335 | d.attachCoordinator(unrelated, nil, unrelatedClient, true) | |
| 326 | 336 | ||
| 327 | - | snatchedToken := sess.attachmentToken(snatched, snatchedTransport) | |
| 328 | - | activeToken := sess.attachmentToken(active, activeTransport) | |
| 337 | + | snatchedToken := sess.captureAttachmentCapability(snatched, snatchedTransport) | |
| 338 | + | activeToken := sess.captureAttachmentCapability(active, activeTransport) | |
| 329 | 339 | snatchedDone := startBlockedAttachmentSend(t, snatchedToken) | |
| 330 | 340 | activeDone := startBlockedAttachmentSend(t, activeToken) | |
| 331 | 341 | awaitTestCompletion(t, snatchedTransport.sendEntered, "snatched send did not block") | |
| @@ -359,9 +369,9 @@ func TestKillSessionInterruptsCapturedSendWithoutClosingNewerIncarnation(t *test | |||
| 359 | 369 | stale := newTeardownBlockingTransport() | |
| 360 | 370 | ac.replaceTransport(stale) | |
| 361 | 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 | 375 | sendDone := startBlockedAttachmentSend(t, token) | |
| 366 | 376 | awaitTestCompletion(t, stale.sendEntered, "captured incarnation send did not block") | |
| 367 | 377 | fresh := &closeTrackingTransport{} | |
| @@ -388,7 +398,7 @@ func TestShutdownSignalsServeWhenParticipantGateAcquisitionTimesOut(t *testing.T | |||
| 388 | 398 | transport := &closeTrackingTransport{} | |
| 389 | 399 | active.replaceTransport(transport) | |
| 390 | 400 | d.attachCoordinator(sess, nil, active, true) | |
| 391 | - | active.publishAttachmentCapability(sess.attachmentToken(active, transport)) | |
| 401 | + | active.installTestAttachmentCapability(sess.captureAttachmentCapability(active, transport)) | |
| 392 | 402 | ||
| 393 | 403 | owner := freezeAttachmentEffectGates(active) | |
| 394 | 404 | require.True(t, owner.acquired) | |
| @@ -419,9 +429,9 @@ func TestShutdownInterruptsBlockedParticipantSend(t *testing.T) { | |||
| 419 | 429 | blocked := newTeardownBlockingTransport() | |
| 420 | 430 | active.replaceTransport(blocked) | |
| 421 | 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 | 435 | sendDone := startBlockedAttachmentSend(t, token) | |
| 426 | 436 | awaitTestCompletion(t, blocked.sendEntered, "active send did not block") | |
| 427 | 437 | ||
| @@ -445,9 +455,9 @@ func TestRenderSendFailureCleanupIsDeadlineBoundedWhileAttachmentEffectGateIsBus | |||
| 445 | 455 | transport := &closeTrackingTransport{} | |
| 446 | 456 | ac.replaceTransport(transport) | |
| 447 | 457 | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 448 | - | token := sess.attachmentToken(ac, transport) | |
| 458 | + | token := sess.captureAttachmentCapability(ac, transport) | |
| 449 | 459 | token.lease = rc.attachmentLease(ac) | |
| 450 | - | ac.publishAttachmentCapability(token) | |
| 460 | + | ac.installTestAttachmentCapability(token) | |
| 451 | 461 | ||
| 452 | 462 | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 453 | 463 | d.clock = clock | |
| @@ -476,9 +486,9 @@ func TestAttachmentEffectGateReplacementInterruptsBlockedOldRenderBeforePublicat | |||
| 476 | 486 | old.replaceTransport(oldTransport) | |
| 477 | 487 | rc := d.attachCoordinator(sess, nil, old, true) | |
| 478 | 488 | lease := rc.attachmentLease(old) | |
| 479 | - | token := sess.attachmentToken(old, oldTransport) | |
| 489 | + | token := sess.captureAttachmentCapability(old, oldTransport) | |
| 480 | 490 | token.lease = lease | |
| 481 | - | old.publishAttachmentCapability(token) | |
| 491 | + | old.installTestAttachmentCapability(token) | |
| 482 | 492 | ||
| 483 | 493 | paintDone := make(chan struct{}) | |
| 484 | 494 | go func() { | |
| @@ -530,9 +540,9 @@ func TestAttachmentEffectGateAdmittedEffectCompletesBeforeConflictingTransition( | |||
| 530 | 540 | oldTransport := newDatagramTestTransport() | |
| 531 | 541 | old.replaceTransport(oldTransport) | |
| 532 | 542 | rc := d.attachCoordinator(sess, nil, old, true) | |
| 533 | - | token := sess.attachmentToken(old, oldTransport) | |
| 543 | + | token := sess.captureAttachmentCapability(old, oldTransport) | |
| 534 | 544 | token.lease = rc.attachmentLease(old) | |
| 535 | - | old.publishAttachmentCapability(token) | |
| 545 | + | old.installTestAttachmentCapability(token) | |
| 536 | 546 | ||
| 537 | 547 | ticket, ok := old.beginAttachmentEffect(token) | |
| 538 | 548 | require.True(t, ok) | |
| @@ -631,15 +641,16 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes | |||
| 631 | 641 | old.replaceTransport(oldTransport) | |
| 632 | 642 | old.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: old}, &d.bindings) | |
| 633 | 643 | rc := d.attachCoordinator(sess, nil, old, true) | |
| 634 | - | token := sess.attachmentToken(old, oldTransport) | |
| 644 | + | token := sess.captureAttachmentCapability(old, oldTransport) | |
| 635 | 645 | token.lease = rc.attachmentLease(old) | |
| 646 | + | old.installTestAttachmentCapability(token) | |
| 636 | 647 | if tt.setup != nil { | |
| 637 | 648 | tt.setup(d, sess, old, pty) | |
| 638 | 649 | } | |
| 639 | 650 | ||
| 640 | 651 | admitted := make(chan struct{}) | |
| 641 | 652 | release := make(chan struct{}) | |
| 642 | - | d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) { | |
| 653 | + | d.afterAttachmentEffectAdmitted = func(attachmentCapability) { | |
| 643 | 654 | close(admitted) | |
| 644 | 655 | <-release | |
| 645 | 656 | } | |
| @@ -672,9 +683,9 @@ func TestAttachedRouteSnapshotIsAcceptedAsAnAttachmentValue(t *testing.T) { | |||
| 672 | 683 | transport := &closeTrackingTransport{} | |
| 673 | 684 | ac.replaceTransport(transport) | |
| 674 | 685 | rc := d.attachCoordinator(source, nil, ac, true) | |
| 675 | - | token := source.attachmentToken(ac, transport) | |
| 686 | + | token := source.captureAttachmentCapability(ac, transport) | |
| 676 | 687 | token.lease = rc.attachmentLease(ac) | |
| 677 | - | ac.publishAttachmentCapability(token) | |
| 688 | + | ac.installTestAttachmentCapability(token) | |
| 678 | 689 | ||
| 679 | 690 | activeRef := ports.RouteRef{Key: 8, Generation: 4} | |
| 680 | 691 | activeTarget := testRouteTarget(source.name, 8) | |
| @@ -711,9 +722,9 @@ func TestAttachedNavigationCommandSendsResultAfterLocalTransition(t *testing.T) | |||
| 711 | 722 | transport := &closeTrackingTransport{} | |
| 712 | 723 | ac.replaceTransport(transport) | |
| 713 | 724 | rc := d.attachCoordinator(source, nil, ac, true) | |
| 714 | - | token := source.attachmentToken(ac, transport) | |
| 725 | + | token := source.captureAttachmentCapability(ac, transport) | |
| 715 | 726 | token.lease = rc.attachmentLease(ac) | |
| 716 | - | ac.publishAttachmentCapability(token) | |
| 727 | + | ac.installTestAttachmentCapability(token) | |
| 717 | 728 | payload, err := ports.MarshalCommandRequest(ports.CommandRequest{ | |
| 718 | 729 | Version: ports.ProtocolVersion, RequestID: 1, Slug: "back-session", Attached: true, | |
| 719 | 730 | }) | |
| @@ -762,9 +773,9 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |||
| 762 | 773 | d.sessions[target.id] = target | |
| 763 | 774 | ||
| 764 | 775 | rc := d.attachCoordinator(source, nil, ac, true) | |
| 765 | - | token := source.attachmentToken(ac, ac.transport()) | |
| 776 | + | token := source.captureAttachmentCapability(ac, ac.transport()) | |
| 766 | 777 | token.lease = rc.attachmentLease(ac) | |
| 767 | - | ac.publishAttachmentCapability(token) | |
| 778 | + | ac.installTestAttachmentCapability(token) | |
| 768 | 779 | ||
| 769 | 780 | d.handleAttachmentClientFrame(token, frameInput([]byte("\x1ba"))) | |
| 770 | 781 | ||
| @@ -780,9 +791,9 @@ func TestPickerDeleteDoesNotDeleteSourceAfterInitiatorReplacement(t *testing.T) | |||
| 780 | 791 | ||
| 781 | 792 | oldTransport := old.transport() | |
| 782 | 793 | rc := d.attachCoordinator(sess, nil, old, true) | |
| 783 | - | token := sess.attachmentToken(old, oldTransport) | |
| 794 | + | token := sess.captureAttachmentCapability(old, oldTransport) | |
| 784 | 795 | token.lease = rc.attachmentLease(old) | |
| 785 | - | old.publishAttachmentCapability(token) | |
| 796 | + | old.installTestAttachmentCapability(token) | |
| 786 | 797 | ||
| 787 | 798 | admissionEnded := make(chan struct{}) | |
| 788 | 799 | releaseAction := make(chan struct{}) | |
| @@ -824,9 +835,9 @@ func TestPickerDeleteSourceForCurrentInitiatorDoesNotDeadlock(t *testing.T) { | |||
| 824 | 835 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 825 | 836 | d.enterPicker(sess, ac) | |
| 826 | 837 | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 827 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 838 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 828 | 839 | token.lease = rc.attachmentLease(ac) | |
| 829 | - | ac.publishAttachmentCapability(token) | |
| 840 | + | ac.installTestAttachmentCapability(token) | |
| 830 | 841 | ||
| 831 | 842 | d.handleAttachmentClientFrame(token, frameInput([]byte("x"))) | |
| 832 | 843 | ||
| @@ -841,12 +852,13 @@ func TestAttachmentEffectGateAdmittedFirstPaintFinishesBeforeReplacement(t *test | |||
| 841 | 852 | oldTransport := newDatagramTestTransport() | |
| 842 | 853 | old.replaceTransport(oldTransport) | |
| 843 | 854 | rc := d.attachCoordinator(sess, nil, old, true) | |
| 844 | - | token := sess.attachmentToken(old, oldTransport) | |
| 855 | + | token := sess.captureAttachmentCapability(old, oldTransport) | |
| 845 | 856 | token.lease = rc.attachmentLease(old) | |
| 857 | + | old.installTestAttachmentCapability(token) | |
| 846 | 858 | ||
| 847 | 859 | admitted := make(chan struct{}) | |
| 848 | 860 | release := make(chan struct{}) | |
| 849 | - | d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) { | |
| 861 | + | d.afterAttachmentEffectAdmitted = func(attachmentCapability) { | |
| 850 | 862 | close(admitted) | |
| 851 | 863 | <-release | |
| 852 | 864 | } | |
| @@ -875,7 +887,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi | |||
| 875 | 887 | aTransport := newDatagramTestTransport() | |
| 876 | 888 | a.replaceTransport(aTransport) | |
| 877 | 889 | d.attachCoordinator(first, nil, a, true) | |
| 878 | - | first.attachmentToken(a, aTransport) | |
| 890 | + | first.captureAttachmentCapability(a, aTransport) | |
| 879 | 891 | ||
| 880 | 892 | bTransport := newDatagramTestTransport() | |
| 881 | 893 | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | |
| @@ -886,7 +898,7 @@ func TestAttachmentEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testi | |||
| 886 | 898 | d.sessions[second.id] = second | |
| 887 | 899 | d.mu.Unlock() | |
| 888 | 900 | d.attachCoordinator(second, nil, b, true) | |
| 889 | - | second.attachmentToken(b, bTransport) | |
| 901 | + | second.captureAttachmentCapability(b, bTransport) | |
| 890 | 902 | ||
| 891 | 903 | start := make(chan struct{}) | |
| 892 | 904 | results := make(chan error, 2) | |
| @@ -933,9 +945,9 @@ func TestAttachmentEffectGateFrozenConnectionRejectsLateEffect(t *testing.T) { | |||
| 933 | 945 | transport := newDatagramTestTransport() | |
| 934 | 946 | ac.replaceTransport(transport) | |
| 935 | 947 | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 936 | - | token := sess.attachmentToken(ac, transport) | |
| 948 | + | token := sess.captureAttachmentCapability(ac, transport) | |
| 937 | 949 | token.lease = rc.attachmentLease(ac) | |
| 938 | - | ac.publishAttachmentCapability(token) | |
| 950 | + | ac.installTestAttachmentCapability(token) | |
| 939 | 951 | ||
| 940 | 952 | frozen := freezeAttachmentEffectGates(ac) | |
| 941 | 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 | 358 | generation, revision := uint64(0), uint64(0) | |
| 359 | 359 | if s.attachment != nil { | |
| 360 | - | generation = s.attachment.connectionGeneration.Load() | |
| 360 | + | generation = s.attachment.lifecycle.generationValue() | |
| 361 | 361 | revision = s.attachment.view.revision | |
| 362 | 362 | } | |
| 363 | 363 | return generation, s.currentEpochLocked(), revision | |
| @@ -375,7 +375,7 @@ func (s *attachmentOutput) preparedCurrentLocked(generation, epoch, viewRevision | |||
| 375 | 375 | ||
| 376 | 376 | func (p *preparedOutput) currentLocked() bool { | |
| 377 | 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 | 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 | 188 | name string | |
| 189 | 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 | 192 | {name: "view revision", change: func(ac *attachedClient) { | |
| 193 | 193 | ac.viewMu.Lock() | |
| 194 | 194 | ac.view.revision++ | |
M internal/usecase/daemon/attachment_parking_test.go +2 -2
| @@ -103,7 +103,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) { | |||
| 103 | 103 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTransport) | |
| 104 | 104 | require.NoError(t, err) | |
| 105 | 105 | oldToken := ac.resumeToken | |
| 106 | - | oldGeneration := ac.connectionGeneration.Load() | |
| 106 | + | oldGeneration := ac.lifecycle.generationValue() | |
| 107 | 107 | d.clientGone(sess, ac, oldTransport, false) | |
| 108 | 108 | ||
| 109 | 109 | newTransport := &closeTrackingTransport{} | |
| @@ -113,7 +113,7 @@ func TestResumeRotatesCredentialAndRejectsStaleTransport(t *testing.T) { | |||
| 113 | 113 | require.Same(t, sess, resumed) | |
| 114 | 114 | require.Same(t, ac, same) | |
| 115 | 115 | require.NotEqual(t, oldToken, ac.resumeToken) | |
| 116 | - | require.Greater(t, ac.connectionGeneration.Load(), oldGeneration) | |
| 116 | + | require.Greater(t, ac.lifecycle.generationValue(), oldGeneration) | |
| 117 | 117 | require.True(t, d.commitResumeClaim(ac)) | |
| 118 | 118 | d.mu.Lock() | |
| 119 | 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 | 123 | rc := sess.renderCoordinator() | |
| 124 | 124 | lease := rc.attachmentLease(second) | |
| 125 | 125 | require.True(t, rc.markAttachmentReady(lease)) | |
| 126 | - | token := sess.attachmentToken(second, secondTransport) | |
| 126 | + | token := sess.captureAttachmentCapability(second, secondTransport) | |
| 127 | 127 | token.lease = lease | |
| 128 | - | ticket, admitted := second.beginAttachmentEffect(token) | |
| 128 | + | second.installTestAttachmentCapability(token) | |
| 129 | + | effect, admitted := second.beginAttachmentEffect(token) | |
| 129 | 130 | require.True(t, admitted) | |
| 130 | - | token.effect = ticket | |
| 131 | - | require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 120, Rows: 50})) | |
| 132 | - | ticket.End() | |
| 131 | + | require.True(t, d.resizeAttachmentForLease(effect, domain.Size{Cols: 120, Rows: 50})) | |
| 132 | + | effect.End() | |
| 133 | 133 | ||
| 134 | 134 | tb.mu.Lock() | |
| 135 | 135 | require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size) | |
| @@ -166,7 +166,7 @@ func TestAttachmentFirstPaintDoesNotWaitForBlockedPeer(t *testing.T) { | |||
| 166 | 166 | awaitTestCompletion(t, oldTransport.entered, "slow attachment did not begin its blocked output") | |
| 167 | 167 | ||
| 168 | 168 | require.True(t, rc.markAttachmentReady(freshLease)) | |
| 169 | - | token := sess.attachmentToken(fresh, newTransport) | |
| 169 | + | token := sess.captureAttachmentCapability(fresh, newTransport) | |
| 170 | 170 | token.lease = freshLease | |
| 171 | 171 | painted := make(chan bool, 1) | |
| 172 | 172 | go func() { painted <- d.firstPaintForTransition(token) }() | |
| @@ -282,7 +282,7 @@ func TestAttachmentResizeUsesLatestClaimedSessionGeometry(t *testing.T) { | |||
| 282 | 282 | require.True(t, rc.markAttachmentReady(firstLease)) | |
| 283 | 283 | require.True(t, rc.markAttachmentReady(secondLease)) | |
| 284 | 284 | resize := func(ac *attachedClient, tr ports.Transport, size domain.Size) { | |
| 285 | - | token := sess.attachmentToken(ac, tr) | |
| 285 | + | token := sess.captureAttachmentCapability(ac, tr) | |
| 286 | 286 | d.handleAttachmentClientFrame(token, ports.Frame{ | |
| 287 | 287 | Type: ports.MsgResize, | |
| 288 | 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 | 25 | transport := &closeTrackingTransport{} | |
| 26 | 26 | ac.replaceTransport(transport) | |
| 27 | 27 | rc := d.attachCoordinator(source, nil, ac, true) | |
| 28 | - | token := source.attachmentToken(ac, transport) | |
| 28 | + | token := source.captureAttachmentCapability(ac, transport) | |
| 29 | 29 | token.lease = rc.attachmentLease(ac) | |
| 30 | - | ac.publishAttachmentCapability(token) | |
| 30 | + | ac.installTestAttachmentCapability(token) | |
| 31 | 31 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |
| 32 | 32 | ||
| 33 | 33 | target := &session{ | |
| @@ -75,9 +75,9 @@ func TestAttachmentTransitionTearsDownAfterCommittedIdentitySendFailure(t *testi | |||
| 75 | 75 | transport := &committedIdentityErrorTransport{} | |
| 76 | 76 | ac.replaceTransport(transport) | |
| 77 | 77 | rc := d.attachCoordinator(source, nil, ac, true) | |
| 78 | - | token := source.attachmentToken(ac, transport) | |
| 78 | + | token := source.captureAttachmentCapability(ac, transport) | |
| 79 | 79 | token.lease = rc.attachmentLease(ac) | |
| 80 | - | ac.publishAttachmentCapability(token) | |
| 80 | + | ac.installTestAttachmentCapability(token) | |
| 81 | 81 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |
| 82 | 82 | ||
| 83 | 83 | target := &session{ | |
M internal/usecase/daemon/attachment_transition.go +12 -36
| @@ -24,14 +24,15 @@ type attachmentTransitionRequest struct { | |||
| 24 | 24 | target *session | |
| 25 | 25 | next *attachedClient | |
| 26 | 26 | expectedTransport transportSnapshot | |
| 27 | - | sourceToken *attachmentConnectionToken | |
| 27 | + | sourceCapability *attachmentCapability | |
| 28 | + | sourceEffect *attachmentEffect | |
| 28 | 29 | action string | |
| 29 | 30 | activateTargetTab bool | |
| 30 | 31 | targetTabIndex int | |
| 31 | 32 | copySourceEnvironment bool | |
| 32 | - | // preserveAttachment commits attachment-local navigation state without | |
| 33 | - | // changing session membership. The initiating connection token remains the | |
| 34 | - | // exact authority for this mutation. | |
| 33 | + | // preserveAttachment commits Attachment-local navigation state without | |
| 34 | + | // changing Session membership. The initiating capability remains the exact | |
| 35 | + | // authority for this mutation. | |
| 35 | 36 | preserveAttachment bool | |
| 36 | 37 | expectedSourceTab *tab | |
| 37 | 38 | // transferExpectedSourceTab permits an installed visible floating pane only | |
| @@ -44,33 +45,8 @@ type attachmentTransitionRequest struct { | |||
| 44 | 45 | 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 | 48 | type attachmentTransitionResult struct { | |
| 73 | - | published attachmentConnectionToken | |
| 49 | + | published attachmentCapability | |
| 74 | 50 | cleanups []renderLifecycleCleanup | |
| 75 | 51 | sourceGeometrySession *session | |
| 76 | 52 | } | |
| @@ -103,7 +79,7 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) ( | |||
| 103 | 79 | d.sessions[sourceCore.id] != source || req.target != nil && d.sessions[targetCore.id] != req.target { | |
| 104 | 80 | 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 | 83 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |
| 108 | 84 | } | |
| 109 | 85 | req.preflighted = true | |
| @@ -112,7 +88,7 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) ( | |||
| 112 | 88 | ||
| 113 | 89 | // freezeAttachmentTransition drains the snapshotted connection without any | |
| 114 | 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 | 92 | if d.afterAttachmentEffectParticipantsSnapshotted != nil { | |
| 117 | 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 | 109 | // 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 | 111 | func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 136 | 112 | d.mu.Lock() | |
| 137 | 113 | defer d.mu.Unlock() | |
| @@ -162,8 +138,8 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||
| 162 | 138 | if err != nil { | |
| 163 | 139 | return attachmentTransitionResult{}, err | |
| 164 | 140 | } | |
| 165 | - | if req.sourceToken != nil { | |
| 166 | - | d.endActionAttachmentEffect(req.sourceToken.effect, req.action) | |
| 141 | + | if req.sourceEffect != nil { | |
| 142 | + | d.endActionAttachmentEffect(req.sourceEffect, req.action) | |
| 167 | 143 | } | |
| 168 | 144 | ||
| 169 | 145 | frozen, err := d.freezeAttachmentTransition(req, participants) | |
| @@ -183,7 +159,7 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||
| 183 | 159 | if req.ready && result.published.ac != nil && result.published.ac.routeSnapshotCopy().Generation != 0 { | |
| 184 | 160 | identityErr := errAttachmentTransition | |
| 185 | 161 | if effect, admitted := result.published.ac.beginAttachmentEffect(result.published); admitted { | |
| 186 | - | identityErr = d.sendCommittedRouteIdentityForAttachment(effect.connectionToken()) | |
| 162 | + | identityErr = d.sendCommittedRouteIdentityForAttachment(effect) | |
| 187 | 163 | effect.End() | |
| 188 | 164 | } | |
| 189 | 165 | if identityErr != nil { | |
M internal/usecase/daemon/attachment_transition_prelocked_test.go +2 -2
| @@ -55,7 +55,7 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure | |||
| 55 | 55 | require.Equal(t, false, target.attachmentRegistered(next)) | |
| 56 | 56 | require.Equal(t, true, target.attachmentRegistered(old)) | |
| 57 | 57 | require.Same(t, source, next.currentSession()) | |
| 58 | - | require.Equal(t, uint64(0), next.connectionGeneration.Load()) | |
| 59 | - | require.Equal(t, uint64(0), old.connectionGeneration.Load()) | |
| 58 | + | require.Equal(t, uint64(0), next.lifecycle.generationValue()) | |
| 59 | + | require.Equal(t, uint64(0), old.lifecycle.generationValue()) | |
| 60 | 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 | 10 | // effect gate is frozen and d.mu is held. No target is created before this | |
| 11 | 11 | // succeeds. | |
| 12 | 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 | 14 | return false | |
| 15 | 15 | } | |
| 16 | 16 | d.notices.routingMu.Lock() | |
| @@ -22,13 +22,13 @@ func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest | |||
| 22 | 22 | coordinator.mu.Lock() | |
| 23 | 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 | 26 | return false | |
| 27 | 27 | } | |
| 28 | 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 | 32 | if token.sess == nil || token.ac == nil { | |
| 33 | 33 | return false | |
| 34 | 34 | } | |
| @@ -46,8 +46,7 @@ func (d *Daemon) sourceAttachmentTokenCurrentFrozen(token attachmentConnectionTo | |||
| 46 | 46 | coordinator.mu.Lock() | |
| 47 | 47 | defer coordinator.mu.Unlock() | |
| 48 | 48 | } | |
| 49 | - | req := attachmentTransitionRequest{source: token.sess, next: token.ac, expectedTransport: token.transport} | |
| 50 | - | return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 49 | + | return token.currentInSessionAndLeaseLocked(token.sess, token.ac, coordinator) | |
| 51 | 50 | } | |
| 52 | 51 | ||
| 53 | 52 | func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | |
| @@ -84,7 +83,6 @@ type attachmentPublication struct { | |||
| 84 | 83 | targetCoordinator *renderCoordinator | |
| 85 | 84 | sourceCoordinator *renderCoordinator | |
| 86 | 85 | releaseCoordinators func() | |
| 87 | - | nextGeneration uint64 | |
| 88 | 86 | } | |
| 89 | 87 | ||
| 90 | 88 | func (p *attachmentPublication) unlockCoordinators() { | |
| @@ -124,20 +122,20 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 124 | 122 | if sourceCore == nil || targetCore == nil || !req.preflighted || !req.attachmentEffectsFrozen || | |
| 125 | 123 | req.next == nil || d.closing || d.sessions[sourceCore.id] != source || d.sessions[targetCore.id] != req.target || | |
| 126 | 124 | req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) || | |
| 127 | - | req.preserveAttachment && req.sourceToken == nil { | |
| 125 | + | req.preserveAttachment && req.sourceCapability == nil { | |
| 128 | 126 | return nil, errAttachmentTransition | |
| 129 | 127 | } | |
| 130 | 128 | ||
| 131 | 129 | sourceCoordinator := sourceCore.coordinator.Load() | |
| 132 | 130 | targetCoordinator := d.ensureAttachmentRenderCoordinatorPrelocked(req.target) | |
| 133 | - | // transitionSourceTokenCurrentLocked also reads the source lease. Hold the | |
| 131 | + | // Capability validation also reads the source lease. Hold the | |
| 134 | 132 | // source coordinator lock before calling it, following session -> coordinator | |
| 135 | 133 | // ordering and preventing a lease rebind from racing validation. | |
| 136 | 134 | releaseCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator) | |
| 137 | 135 | if d.afterAttachmentTransitionCoordinatorsLocked != nil { | |
| 138 | 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 | 139 | releaseCoordinators() | |
| 142 | 140 | return nil, errAttachmentTransition | |
| 143 | 141 | } | |
| @@ -155,7 +153,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 155 | 153 | } | |
| 156 | 154 | if !attachmentLifecycleCurrentLocked(req.target, req.expectedTargetLifecycle) || | |
| 157 | 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 | 157 | releaseCoordinators() | |
| 160 | 158 | return nil, errAttachmentTransition | |
| 161 | 159 | } | |
| @@ -196,7 +194,6 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |||
| 196 | 194 | // attachment claim with the replacement transport's current size. | |
| 197 | 195 | req.target.claimGeometryOwnerLocked(req.next, req.next.geometrySnapshot()) | |
| 198 | 196 | } | |
| 199 | - | publication.nextGeneration = req.next.connectionGeneration.Add(1) | |
| 200 | 197 | req.next.setSession(req.target) | |
| 201 | 198 | } | |
| 202 | 199 | ||
| @@ -214,14 +211,13 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att | |||
| 214 | 211 | lease = publication.targetCoordinator.attachWithReadinessLocked(req.next, req.ready) | |
| 215 | 212 | } | |
| 216 | 213 | } | |
| 217 | - | result.published = attachmentConnectionToken{ | |
| 214 | + | result.published = attachmentCapability{ | |
| 218 | 215 | sess: req.target, ac: req.next, | |
| 219 | - | generation: publication.nextGeneration, | |
| 220 | - | transport: req.expectedTransport, lease: lease, | |
| 216 | + | transport: req.expectedTransport, lease: lease, | |
| 221 | 217 | rebase: publication.source != req.target, | |
| 222 | 218 | } | |
| 223 | 219 | if req.attachmentEffectsFrozen { | |
| 224 | - | req.next.publishFrozenAttachmentCapability(result.published) | |
| 220 | + | result.published = req.next.publishFrozenAttachmentCapability(result.published) | |
| 225 | 221 | } | |
| 226 | 222 | return result | |
| 227 | 223 | } | |
| @@ -229,7 +225,7 @@ func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) att | |||
| 229 | 225 | func (d *Daemon) publishAttachmentTransitionPrelocked(publication *attachmentPublication) attachmentTransitionResult { | |
| 230 | 226 | if publication.req.preserveAttachment { | |
| 231 | 227 | d.applyTargetStateLocked(publication) | |
| 232 | - | return attachmentTransitionResult{published: *publication.req.sourceToken} | |
| 228 | + | return attachmentTransitionResult{published: *publication.req.sourceCapability} | |
| 233 | 229 | } | |
| 234 | 230 | publishAttachmentOwnershipLocked(publication) | |
| 235 | 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 | 50 | // happened — it also covers "no target exists", which is routine and not an | |
| 51 | 51 | // error. Only a failure to reach a target that does exist is a genuine error. | |
| 52 | 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 | 57 | if sess == nil || ac == nil { | |
| 58 | 58 | return nil | |
| 59 | 59 | } | |
| 60 | 60 | if idx, ok := oldestAttentionTab(sess); ok { | |
| 61 | 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 | 67 | d.invalidateRender(sess, ac, true, "attention.go") | |
| 64 | 68 | } | |
| 65 | 69 | return nil | |
| @@ -70,10 +74,10 @@ func (d *Daemon) jumpAttentionForAttachment(sess *session, ac *attachedClient, t | |||
| 70 | 74 | return nil | |
| 71 | 75 | } | |
| 72 | 76 | pickerTarget := picker.Target{Session: target.sessionID, TabIndex: target.tabIndex} | |
| 73 | - | if token.ac == nil { | |
| 77 | + | if effect == nil { | |
| 74 | 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 | 83 | func oldestAttentionTab(sess *session) (int, bool) { | |
M internal/usecase/daemon/client.go +58 -57
| @@ -42,14 +42,11 @@ type attachedClient struct { | |||
| 42 | 42 | terminalCapabilities ports.TerminalCapabilities | |
| 43 | 43 | navigationCapabilities ports.NavigationCapabilities | |
| 44 | 44 | startupOverlay ports.StartupOverlay | |
| 45 | - | // connectionGeneration is the wire-facing capability generation. attachmentEffects is | |
| 46 | - | // the linearization gate for every attachment-bound observable operation. A role | |
| 47 | - | // transition freezes and drains this gate before changing either generation | |
| 48 | - | // or the session/coordinator registries. | |
| 49 | - | connectionGeneration atomic.Uint64 | |
| 50 | - | attachmentEffects attachmentEffectGate | |
| 51 | - | resumeCapable bool | |
| 52 | - | resumeToken uint64 | |
| 45 | + | // lifecycle is the sole authority for attachment capability publication, | |
| 46 | + | // effect admission, transition freeze/drain, and connection generation. | |
| 47 | + | lifecycle attachmentLifecycle | |
| 48 | + | resumeCapable bool | |
| 49 | + | resumeToken uint64 | |
| 53 | 50 | // resumeClaimToken is non-zero only between a parked resume claim and its | |
| 54 | 51 | // successful Welcome. It lets a failed pre-claim handshake restore the old | |
| 55 | 52 | // credential instead of consuming it. | |
| @@ -111,9 +108,9 @@ type attachedClient struct { | |||
| 111 | 108 | // routeSessionPurge distinguishes a brand-new session from a restored | |
| 112 | 109 | // stopped session when failed-handshake cleanup removes the live instance. | |
| 113 | 110 | routeSessionPurge bool | |
| 114 | - | // beforeAttachmentTokenValidation is a deterministic lifecycle-race seam | |
| 111 | + | // beforeAttachmentCapabilityValidation is a deterministic lifecycle-race Seam | |
| 115 | 112 | // used only by package tests. | |
| 116 | - | beforeAttachmentTokenValidation func() | |
| 113 | + | beforeAttachmentCapabilityValidation func() | |
| 117 | 114 | } | |
| 118 | 115 | ||
| 119 | 116 | type cursorOut struct { | |
| @@ -474,7 +471,7 @@ func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f po | |||
| 474 | 471 | // attachment's current transport incarnation and admits ticket's interruptible | |
| 475 | 472 | // transport send. The caller must already hold ac.sendMu and, on success, owns | |
| 476 | 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 | 475 | if expected.transport == nil || !ac.transportSnapshotCurrent(expected) { | |
| 479 | 476 | return errTransportReplaced | |
| 480 | 477 | } | |
| @@ -484,7 +481,7 @@ func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSna | |||
| 484 | 481 | 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 | 485 | ac.sendMu.Lock() | |
| 489 | 486 | defer ac.sendMu.Unlock() | |
| 490 | 487 | // A attachment-bound send requires a live ticket, and reports every rejection, | |
| @@ -862,37 +859,36 @@ func (d *Daemon) resetScreenDefaultColors(sess *session) { | |||
| 862 | 859 | // firstPaintForTransition is the only active post-transition paint entry | |
| 863 | 860 | // point. It rejects a stale attachment capability before rebasing output and carries | |
| 864 | 861 | // the exact coordinator lease through every resize and render effect. | |
| 865 | - | func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool { | |
| 866 | - | if !token.attachmentCurrent() { | |
| 862 | + | func (d *Daemon) firstPaintForTransition(capability attachmentCapability) bool { | |
| 863 | + | if !capability.current() { | |
| 867 | 864 | return false | |
| 868 | 865 | } | |
| 869 | - | ticket, admitted := token.ac.beginAttachmentEffect(token) | |
| 866 | + | effect, admitted := capability.ac.beginAttachmentEffect(capability) | |
| 870 | 867 | if !admitted { | |
| 871 | 868 | return false | |
| 872 | 869 | } | |
| 873 | - | token.effect = ticket | |
| 874 | - | defer token.endAttachmentEffect() | |
| 870 | + | defer effect.End() | |
| 875 | 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 | 875 | if d.beforeFirstPaintSendWait != nil { | |
| 880 | - | d.beforeFirstPaintSendWait(token) | |
| 876 | + | d.beforeFirstPaintSendWait(effect.capability()) | |
| 881 | 877 | } | |
| 882 | - | token.ac.sendMu.Lock() | |
| 883 | - | if token.ac.renderStages.handoffRebase != nil { | |
| 884 | - | token.ac.renderStages.handoffRebase() | |
| 878 | + | effect.ac.sendMu.Lock() | |
| 879 | + | if effect.ac.renderStages.handoffRebase != nil { | |
| 880 | + | effect.ac.renderStages.handoffRebase() | |
| 885 | 881 | } | |
| 886 | - | token.ac.rebaseOutput() | |
| 882 | + | effect.ac.rebaseOutput() | |
| 887 | 883 | // Captures belong to the old session even when pane-local IDs happen | |
| 888 | 884 | // to be reused by the destination. | |
| 889 | - | token.ac.captureFrames = nil | |
| 890 | - | token.ac.sendMu.Unlock() | |
| 885 | + | effect.ac.captureFrames = nil | |
| 886 | + | effect.ac.sendMu.Unlock() | |
| 891 | 887 | } | |
| 892 | - | if token.sess == nil { | |
| 888 | + | if effect.sess == nil { | |
| 893 | 889 | 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 | 892 | return true | |
| 897 | 893 | } | |
| 898 | 894 | ||
| @@ -958,25 +954,25 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||
| 958 | 954 | d.invalidateRender(sess, ac, true, "client.go") | |
| 959 | 955 | } | |
| 960 | 956 | ||
| 961 | - | func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) { | |
| 962 | - | if !token.attachmentEffectCurrent() { | |
| 957 | + | func (d *Daemon) applyThemeForAttachment(effect *attachmentEffect, msg ports.Theme) { | |
| 958 | + | if !effect.current() { | |
| 963 | 959 | return | |
| 964 | 960 | } | |
| 965 | 961 | clientTheme := themeFromMessage(msg) | |
| 966 | - | token.ac.setClientTheme(clientTheme) | |
| 967 | - | sess := token.sess | |
| 968 | - | if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) { | |
| 962 | + | effect.ac.setClientTheme(clientTheme) | |
| 963 | + | sess := effect.sess | |
| 964 | + | if sess == nil || !effect.current() || !d.applyHostTheme(sess, effect.ac, clientTheme, false) { | |
| 969 | 965 | return | |
| 970 | 966 | } | |
| 971 | - | if !token.attachmentEffectCurrent() { | |
| 967 | + | if !effect.current() { | |
| 972 | 968 | return | |
| 973 | 969 | } | |
| 974 | 970 | rc := sess.renderCoordinator() | |
| 975 | - | if token.lease == nil || rc == nil { | |
| 976 | - | d.invalidateRender(sess, token.ac, true, "client.go") | |
| 971 | + | if effect.lease == nil || rc == nil { | |
| 972 | + | d.invalidateRender(sess, effect.ac, true, "client.go") | |
| 977 | 973 | 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 | 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 | 983 | // attachment the latest shared tab/PTY geometry claimant. Callers hold the | |
| 988 | 984 | // attachment effect admission; output state is rebased before any redraw so | |
| 989 | 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 | 987 | geometry := domain.Geometry{Size: size} | |
| 992 | - | if token.ac != nil { | |
| 988 | + | if effect != nil && effect.ac != nil { | |
| 993 | 989 | // Direct size-only callers do not have a fresh pixel pair. Preserve the | |
| 994 | 990 | // attachment's last complete pair instead of downgrading its claim to | |
| 995 | 991 | // cell-only geometry. | |
| 996 | - | geometry = token.ac.geometrySnapshot() | |
| 992 | + | geometry = effect.ac.geometrySnapshot() | |
| 997 | 993 | 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 | 999 | geometry = geometry.NormalizePixels() | |
| 1004 | - | if token.ac == nil || !geometry.Valid() || !token.attachmentEffectCurrent() { | |
| 1000 | + | if !effect.current() || effect.ac == nil || !geometry.Valid() { | |
| 1005 | 1001 | return false | |
| 1006 | 1002 | } | |
| 1007 | - | ac := token.ac | |
| 1003 | + | ac := effect.ac | |
| 1008 | 1004 | ac.sendMu.Lock() | |
| 1009 | - | if !token.attachmentEffectCurrent() || !ac.transportSnapshotCurrent(token.transport) { | |
| 1005 | + | if !effect.current() || !ac.transportSnapshotCurrent(effect.transport) { | |
| 1010 | 1006 | ac.sendMu.Unlock() | |
| 1011 | 1007 | return false | |
| 1012 | 1008 | } | |
| @@ -1036,7 +1032,7 @@ func (d *Daemon) resizeAttachmentGeometryForLease(token attachmentConnectionToke | |||
| 1036 | 1032 | d.registerPreviewForSelection(ac) | |
| 1037 | 1033 | } | |
| 1038 | 1034 | ||
| 1039 | - | sess := token.sess | |
| 1035 | + | sess := effect.sess | |
| 1040 | 1036 | if sess == nil { | |
| 1041 | 1037 | return false | |
| 1042 | 1038 | } | |
| @@ -1056,8 +1052,8 @@ func (d *Daemon) resizeAttachmentGeometryForLease(token attachmentConnectionToke | |||
| 1056 | 1052 | } | |
| 1057 | 1053 | // Rendering is attachment-scoped. Schedule it through the coordinator so | |
| 1058 | 1054 | // rapid resize frames coalesce under the attachment lease and deadline. | |
| 1059 | - | return rc.scheduleResizeForLease(geometry.Size, ac, token.lease, func(uint64) { | |
| 1060 | - | go d.paint(sess, ac, true, token.lease) | |
| 1055 | + | return rc.scheduleResizeForLease(geometry.Size, ac, effect.lease, func(uint64) { | |
| 1056 | + | go d.paint(sess, ac, true, effect.lease) | |
| 1061 | 1057 | }) != 0 | |
| 1062 | 1058 | } | |
| 1063 | 1059 | ||
| @@ -1071,37 +1067,42 @@ func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice po | |||
| 1071 | 1067 | return | |
| 1072 | 1068 | } | |
| 1073 | 1069 | tr := ac.transport() | |
| 1074 | - | token := sess.attachmentToken(ac, tr) | |
| 1070 | + | token := sess.captureAttachmentCapability(ac, tr) | |
| 1075 | 1071 | if token.lease == nil { | |
| 1076 | 1072 | // Direct test/headless callers retain pointer-based routing. Production | |
| 1077 | 1073 | // client frames always carry the exact lease through the attachment path below. | |
| 1078 | 1074 | d.handleClientNoticeDirect(sess, ac, notice) | |
| 1079 | 1075 | 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 | 1086 | d.notices.routingMu.Lock() | |
| 1086 | - | if !token.attachmentEffectCurrent() { | |
| 1087 | + | if !effect.current() { | |
| 1087 | 1088 | d.notices.routingMu.Unlock() | |
| 1088 | 1089 | return | |
| 1089 | 1090 | } | |
| 1090 | 1091 | if d.notices.beforeClientNoticeMutation != nil { | |
| 1091 | 1092 | d.notices.beforeClientNoticeMutation() | |
| 1092 | 1093 | } | |
| 1093 | - | if !token.attachmentEffectCurrent() { | |
| 1094 | + | if !effect.current() { | |
| 1094 | 1095 | d.notices.routingMu.Unlock() | |
| 1095 | 1096 | return | |
| 1096 | 1097 | } | |
| 1097 | - | if token.sess == nil { | |
| 1098 | + | if effect.sess == nil { | |
| 1098 | 1099 | d.notices.routingMu.Unlock() | |
| 1099 | 1100 | return | |
| 1100 | 1101 | } | |
| 1101 | - | repaint := d.mutateClientNotice(token.sess, token.ac, notice) | |
| 1102 | + | repaint := d.mutateClientNotice(effect.sess, effect.ac, notice) | |
| 1102 | 1103 | d.notices.routingMu.Unlock() | |
| 1103 | - | if repaint && token.attachmentEffectCurrent() { | |
| 1104 | - | d.repaintForNotice(token.ac) | |
| 1104 | + | if repaint && effect.current() { | |
| 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 | 14 | // currentAttachmentConnection retries the lock-free session-to-role snapshot when a | |
| 15 | 15 | // handoff lands between those reads. The attachment gate still performs the final | |
| 16 | 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 | 18 | for range connectionSnapshotAttempts { | |
| 19 | 19 | if !ac.currentTransportIs(tr) { | |
| 20 | 20 | break | |
| 21 | 21 | } | |
| 22 | 22 | sess := ac.currentAttachmentSession() | |
| 23 | 23 | if sess == nil { | |
| 24 | - | return nil, attachmentConnectionToken{}, false | |
| 24 | + | return nil, attachmentCapability{}, false | |
| 25 | 25 | } | |
| 26 | 26 | if d.afterConnectionSessionSnapshot != nil { | |
| 27 | 27 | d.afterConnectionSessionSnapshot(sess) | |
| 28 | 28 | } | |
| 29 | - | token := attachmentToken(sess, ac, tr) | |
| 29 | + | token := captureAttachmentCapability(sess, ac, tr) | |
| 30 | 30 | if token.ac == nil || ac.currentAttachmentSession() != sess { | |
| 31 | 31 | runtime.Gosched() | |
| 32 | 32 | continue | |
| 33 | 33 | } | |
| 34 | 34 | return sess, token, true | |
| 35 | 35 | } | |
| 36 | - | return nil, attachmentConnectionToken{}, false | |
| 36 | + | return nil, attachmentCapability{}, false | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | 39 | // 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 | 76 | // handleAttachmentClientFrame owns the attached-client protocol. | |
| 77 | - | func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f ports.Frame) bool { | |
| 78 | - | if !token.attachmentEffectCurrent() { | |
| 77 | + | func (d *Daemon) handleAttachmentClientFrame(capability attachmentCapability, f ports.Frame) bool { | |
| 78 | + | if !capability.current() { | |
| 79 | 79 | return false | |
| 80 | 80 | } | |
| 81 | 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 | 85 | if !admitted { | |
| 86 | 86 | return false | |
| 87 | 87 | } | |
| 88 | - | token.effect = ticket | |
| 89 | - | defer token.endAttachmentEffect() | |
| 88 | + | defer effect.End() | |
| 90 | 89 | if d.afterAttachmentEffectAdmitted != nil { | |
| 91 | - | d.afterAttachmentEffectAdmitted(token) | |
| 90 | + | d.afterAttachmentEffectAdmitted(effect.capability()) | |
| 92 | 91 | } | |
| 93 | 92 | switch f.Type { | |
| 94 | 93 | case ports.MsgInput: | |
| 95 | 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 | 97 | case ports.MsgResize: | |
| 99 | - | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() { | |
| 100 | - | d.resizeAttachmentGeometryForLease(token, rz.Geometry()) | |
| 98 | + | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && effect.current() { | |
| 99 | + | d.resizeAttachmentGeometryForLease(effect, rz.Geometry()) | |
| 101 | 100 | } | |
| 102 | 101 | case ports.MsgTheme: | |
| 103 | 102 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | |
| 104 | - | d.applyThemeForAttachment(token, th) | |
| 103 | + | d.applyThemeForAttachment(effect, th) | |
| 105 | 104 | } | |
| 106 | 105 | case ports.MsgImagePush: | |
| 107 | 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 | 109 | case ports.MsgClientNotice: | |
| 111 | 110 | if notice, derr := ports.UnmarshalClientNotice(f.Payload); derr == nil { | |
| 112 | - | d.handleClientNoticeForAttachment(token, notice) | |
| 111 | + | d.handleClientNoticeForAttachment(effect, notice) | |
| 113 | 112 | } else { | |
| 114 | 113 | d.log.Warn("malformed client notice", "err", derr) | |
| 115 | 114 | } | |
| 116 | 115 | case ports.MsgDetach: | |
| 117 | - | if token.attachmentEffectCurrent() { | |
| 118 | - | d.clientGoneForAttachment(token, true) | |
| 116 | + | if effect.current() { | |
| 117 | + | d.clientGoneForAttachment(effect, true) | |
| 119 | 118 | return true | |
| 120 | 119 | } | |
| 121 | 120 | case ports.MsgAck: | |
| 122 | 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 | 124 | case ports.MsgOutputResetRequest: | |
| 126 | 125 | if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil { | |
| 127 | - | d.resetOutput(token) | |
| 126 | + | d.resetOutput(effect) | |
| 128 | 127 | } | |
| 129 | 128 | case ports.MsgSamePeerSwitchRequest: | |
| 130 | 129 | request, derr := ports.UnmarshalSamePeerSwitchRequest(f.Payload) | |
| @@ -132,27 +131,27 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||
| 132 | 131 | d.log.Warn("malformed same-peer switch request", "err", derr) | |
| 133 | 132 | break | |
| 134 | 133 | } | |
| 135 | - | d.switchSamePeerForAttachment(token, request) | |
| 134 | + | d.switchSamePeerForAttachment(effect, request) | |
| 136 | 135 | case ports.MsgParkedRouteRequest: | |
| 137 | 136 | request, derr := ports.UnmarshalParkedRouteRequest(f.Payload) | |
| 138 | 137 | if derr != nil { | |
| 139 | 138 | d.log.Warn("malformed parked-route request", "err", derr) | |
| 140 | 139 | break | |
| 141 | 140 | } | |
| 142 | - | d.handleParkedRouteRequest(token, request) | |
| 141 | + | d.handleParkedRouteRequest(effect, request) | |
| 143 | 142 | case ports.MsgRecentRouteSnapshot: | |
| 144 | 143 | snapshot, derr := ports.UnmarshalRecentRouteSnapshot(f.Payload) | |
| 145 | 144 | if derr != nil { | |
| 146 | 145 | d.log.Warn("malformed recent route snapshot", "err", derr) | |
| 147 | 146 | break | |
| 148 | 147 | } | |
| 149 | - | replayIdentity := token.ac.setRouteSnapshot(snapshot) | |
| 150 | - | d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-snapshot") | |
| 148 | + | replayIdentity := effect.ac.setRouteSnapshot(snapshot) | |
| 149 | + | d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-snapshot") | |
| 151 | 150 | if !replayIdentity { | |
| 152 | 151 | break | |
| 153 | 152 | } | |
| 154 | - | if err := d.sendCommittedRouteIdentityForAttachment(token); err != nil { | |
| 155 | - | d.detachOnAttachmentSendError(token, token.transport.transport) | |
| 153 | + | if err := d.sendCommittedRouteIdentityForAttachment(effect); err != nil { | |
| 154 | + | d.detachOnAttachmentSendError(effect.capability(), effect.transport.transport) | |
| 156 | 155 | } | |
| 157 | 156 | case ports.MsgRouteAttentionSubscription: | |
| 158 | 157 | subscription, derr := ports.UnmarshalRouteAttentionSubscription(f.Payload) | |
| @@ -160,8 +159,8 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||
| 160 | 159 | d.log.Warn("malformed route attention subscription", "err", derr) | |
| 161 | 160 | break | |
| 162 | 161 | } | |
| 163 | - | token.ac.setRouteAttentionSubscription(subscription) | |
| 164 | - | d.invalidateRender(token.sess, token.ac, false, "client_frame_routing.go:route-attention") | |
| 162 | + | effect.ac.setRouteAttentionSubscription(subscription) | |
| 163 | + | d.invalidateRender(effect.sess, effect.ac, false, "client_frame_routing.go:route-attention") | |
| 165 | 164 | case ports.MsgRouteNavigationFailure: | |
| 166 | 165 | failure, derr := ports.UnmarshalRouteNavigationFailure(f.Payload) | |
| 167 | 166 | if derr != nil { | |
| @@ -181,29 +180,29 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||
| 181 | 180 | case ports.RouteFailureNoSuchRoute: | |
| 182 | 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 | 184 | case ports.MsgCommand: | |
| 186 | 185 | request, derr := ports.UnmarshalCommandRequest(f.Payload) | |
| 187 | 186 | if derr != nil { | |
| 188 | 187 | return false | |
| 189 | 188 | } | |
| 190 | - | result := d.executeAttachedCommand(token, request) | |
| 191 | - | resultToken, ok := d.commandResultToken(token) | |
| 189 | + | result := d.executeAttachedCommand(effect, request) | |
| 190 | + | resultEffect, ok := d.commandResultEffect(effect) | |
| 192 | 191 | if !ok { | |
| 193 | 192 | // A remote handoff or explicit detach deliberately retired the old | |
| 194 | 193 | // attachment; it must not be treated as a stale-result send error. | |
| 195 | 194 | return true | |
| 196 | 195 | } | |
| 197 | - | if err := resultToken.sendControl(frameCommandResult(result)); err != nil { | |
| 198 | - | resultToken.endAttachmentEffect() | |
| 199 | - | d.detachOnAttachmentSendError(resultToken, resultToken.transport.transport) | |
| 196 | + | if err := resultEffect.sendControl(frameCommandResult(result)); err != nil { | |
| 197 | + | resultEffect.End() | |
| 198 | + | d.detachOnAttachmentSendError(resultEffect.capability(), resultEffect.transport.transport) | |
| 200 | 199 | return true | |
| 201 | 200 | } | |
| 202 | - | resultToken.endAttachmentEffect() | |
| 201 | + | resultEffect.End() | |
| 203 | 202 | case ports.MsgPing: | |
| 204 | - | if err := token.sendControl(framePong()); err != nil { | |
| 205 | - | token.endAttachmentEffect() | |
| 206 | - | d.detachOnAttachmentSendError(token, token.transport.transport) | |
| 203 | + | if err := effect.sendControl(framePong()); err != nil { | |
| 204 | + | effect.End() | |
| 205 | + | d.detachOnAttachmentSendError(effect.capability(), effect.transport.transport) | |
| 207 | 206 | return true | |
| 208 | 207 | } | |
| 209 | 208 | default: | |
| @@ -213,34 +212,28 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||
| 213 | 212 | return false | |
| 214 | 213 | } | |
| 215 | 214 | ||
| 216 | - | // commandResultToken renews the attachment effect after a local navigation | |
| 217 | - | // transition ends the command's initiating effect. A retired transport/session | |
| 218 | - | // means the command deliberately handed off or detached and has no result to | |
| 219 | - | // send on the old carriage. | |
| 220 | - | func (d *Daemon) commandResultToken(token attachmentConnectionToken) (attachmentConnectionToken, bool) { | |
| 221 | - | if token.ac == nil || !token.ac.currentTransportIs(token.transport.transport) { | |
| 222 | - | return attachmentConnectionToken{}, false | |
| 215 | + | // commandResultEffect renews admission after a local navigation transition | |
| 216 | + | // ends the command's initiating effect. A retired Transport or Session means | |
| 217 | + | // the command deliberately handed off or detached and has no result carriage. | |
| 218 | + | func (d *Daemon) commandResultEffect(effect *attachmentEffect) (*attachmentEffect, bool) { | |
| 219 | + | if effect == nil || effect.ac == nil || !effect.ac.currentTransportIs(effect.transport.transport) { | |
| 220 | + | return nil, false | |
| 223 | 221 | } | |
| 224 | - | sess := token.ac.currentAttachmentSession() | |
| 225 | - | if sess == nil { | |
| 226 | - | return attachmentConnectionToken{}, false | |
| 227 | - | } | |
| 228 | - | resultToken := attachmentToken(sess, token.ac, token.transport.transport) | |
| 229 | - | if resultToken.ac == nil { | |
| 230 | - | return attachmentConnectionToken{}, false | |
| 222 | + | if effect.current() && effect.attachmentCapability.current() { | |
| 223 | + | return effect, true | |
| 231 | 224 | } | |
| 232 | - | if token.effect != nil && !token.effect.ended.Load() && token.current() { | |
| 233 | - | return token, true | |
| 225 | + | sess := effect.ac.currentAttachmentSession() | |
| 226 | + | if sess == nil { | |
| 227 | + | return nil, false | |
| 234 | 228 | } | |
| 235 | - | ticket, admitted := token.ac.beginAttachmentEffect(resultToken) | |
| 236 | - | if !admitted { | |
| 237 | - | return attachmentConnectionToken{}, false | |
| 229 | + | capability := captureAttachmentCapability(sess, effect.ac, effect.transport.transport) | |
| 230 | + | if capability.ac == nil { | |
| 231 | + | return nil, false | |
| 238 | 232 | } | |
| 239 | - | resultToken.effect = ticket | |
| 240 | - | return resultToken, true | |
| 233 | + | return effect.ac.beginAttachmentEffect(capability) | |
| 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 | 237 | result := ports.CommandResult{RequestID: request.RequestID} | |
| 245 | 238 | if request.Version != ports.ProtocolVersion { | |
| 246 | 239 | result.Code = ports.ErrInvalidCommandArgs | |
| @@ -267,8 +260,8 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request | |||
| 267 | 260 | result.Text = "attached commands cannot override their active session target" | |
| 268 | 261 | return result | |
| 269 | 262 | } | |
| 270 | - | sess := token.sess | |
| 271 | - | if sess == nil || !token.attachmentEffectCurrent() { | |
| 263 | + | sess := effect.sess | |
| 264 | + | if sess == nil || !effect.current() { | |
| 272 | 265 | result.Code = ports.ErrNoSuchTarget | |
| 273 | 266 | result.Text = "attached session is no longer active" | |
| 274 | 267 | return result | |
| @@ -292,7 +285,7 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request | |||
| 292 | 285 | // The frame's attachment token is the target capability. No registry/name lookup is | |
| 293 | 286 | // performed, so the command cannot escape to another remote session. | |
| 294 | 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 | 290 | if err == nil { | |
| 298 | 291 | result.OK = true | |
| @@ -311,13 +304,13 @@ func (d *Daemon) executeAttachedCommand(token attachmentConnectionToken, request | |||
| 311 | 304 | // resetOutput rebases only the exact attachment and transport admitted | |
| 312 | 305 | // by the frame router. sendMu serializes the rebase with every output send; | |
| 313 | 306 | // transport revalidation under that lock rejects a link replaced while waiting. | |
| 314 | - | func (d *Daemon) resetOutput(token attachmentConnectionToken) bool { | |
| 315 | - | ac := token.ac | |
| 316 | - | if ac == nil || token.effect == nil || token.effect.ended.Load() { | |
| 307 | + | func (d *Daemon) resetOutput(effect *attachmentEffect) bool { | |
| 308 | + | if !effect.current() || effect.ac == nil { | |
| 317 | 309 | return false | |
| 318 | 310 | } | |
| 311 | + | ac := effect.ac | |
| 319 | 312 | ac.sendMu.Lock() | |
| 320 | - | if token.effect.ended.Load() || !token.attachmentCurrent() || ac.output == nil { | |
| 313 | + | if !effect.current() || ac.output == nil { | |
| 321 | 314 | ac.sendMu.Unlock() | |
| 322 | 315 | return false | |
| 323 | 316 | } | |
| @@ -325,15 +318,15 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool { | |||
| 325 | 318 | ac.pipelineCache = composeCacheInput{} | |
| 326 | 319 | ac.pipelineScratch = composeCacheInput{} | |
| 327 | 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 | 322 | return true | |
| 330 | 323 | } | |
| 331 | 324 | ||
| 332 | - | func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64) bool { | |
| 333 | - | ac := token.ac | |
| 334 | - | if token.effect == nil || token.effect.ended.Load() { | |
| 325 | + | func (d *Daemon) ackOutput(effect *attachmentEffect, epoch, state uint64) bool { | |
| 326 | + | if !effect.current() || effect.ac == nil { | |
| 335 | 327 | return false | |
| 336 | 328 | } | |
| 329 | + | ac := effect.ac | |
| 337 | 330 | ac.sendMu.Lock() | |
| 338 | 331 | if ac.output == nil { | |
| 339 | 332 | ac.sendMu.Unlock() | |
| @@ -342,8 +335,8 @@ func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64) | |||
| 342 | 335 | acknowledged := ac.output.ack(epoch, state) | |
| 343 | 336 | ac.sendMu.Unlock() | |
| 344 | 337 | if acknowledged { | |
| 345 | - | if rc := token.sess.renderCoordinator(); rc != nil { | |
| 346 | - | rc.notifyAckForLease(token.lease) | |
| 338 | + | if rc := effect.sess.renderCoordinator(); rc != nil { | |
| 339 | + | rc.notifyAckForLease(effect.lease) | |
| 347 | 340 | } | |
| 348 | 341 | } | |
| 349 | 342 | 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 | 39 | d.finishClientGone(sess, ac, failed, explicit, notice) | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | - | func (d *Daemon) clientGoneForAttachment(token attachmentConnectionToken, explicit bool) bool { | |
| 43 | - | if token.effect == nil { | |
| 42 | + | func (d *Daemon) clientGoneForAttachment(effect *attachmentEffect, explicit bool) bool { | |
| 43 | + | if effect == nil { | |
| 44 | 44 | return false | |
| 45 | 45 | } | |
| 46 | - | token.effect.bindActionEnd(d, "detach") | |
| 47 | - | token.effect.End() | |
| 48 | - | sess := token.sess | |
| 46 | + | effect.bindActionEnd(d, "detach") | |
| 47 | + | effect.End() | |
| 48 | + | capability := effect.capability() | |
| 49 | + | sess := capability.sess | |
| 49 | 50 | if sess == nil { | |
| 50 | 51 | return false | |
| 51 | 52 | } | |
| 52 | 53 | var parkingToken uint64 | |
| 53 | 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 | - | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) | |
| 57 | + | if !d.detachIfAttachmentCurrent(capability) { | |
| 58 | + | d.clearParkingInFlightIfAbandoned(sess, capability.ac, parkingToken) | |
| 58 | 59 | 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 | 62 | return true | |
| 62 | 63 | } | |
| 63 | 64 | ||
| @@ -131,11 +132,11 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por | |||
| 131 | 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 | 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 | 140 | // A delayed sender may report after the client has rebound. Only the exact | |
| 140 | 141 | // transport captured by this attachment is allowed to detach either lifecycle. | |
| 141 | 142 | if failed == nil || failed != token.transport.transport { | |
| @@ -156,7 +157,7 @@ func (d *Daemon) detachOnAttachmentSendErrorUntil(token attachmentConnectionToke | |||
| 156 | 157 | // reserveAttachmentSendErrorCleanup accounts for cleanup before End releases the | |
| 157 | 158 | // attachment gate. This closes the WaitGroup Add/Wait race with terminal teardown; | |
| 158 | 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 | 161 | d.attachmentCleanupWg.Add(1) | |
| 161 | 162 | return func() { | |
| 162 | 163 | go func() { | |
M internal/usecase/daemon/clipboard.go +24 -24
| @@ -26,27 +26,27 @@ var ( | |||
| 26 | 26 | clipPasteCloseMarker = []byte("\x1b[201~") | |
| 27 | 27 | ) | |
| 28 | 28 | ||
| 29 | - | func (d *Daemon) handleSequencedImagePushForAttachment(token attachmentConnectionToken, _ uint64, ip ports.ImagePush) { | |
| 30 | - | if !token.attachmentEffectCurrent() { | |
| 29 | + | func (d *Daemon) handleSequencedImagePushForAttachment(effect *attachmentEffect, _ uint64, ip ports.ImagePush) { | |
| 30 | + | if !effect.current() { | |
| 31 | 31 | 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) { | |
| 37 | - | if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !token.attachmentEffectCurrent() { | |
| 36 | + | func (d *Daemon) handleImagePushForAttachment(effect *attachmentEffect, ip ports.ImagePush) { | |
| 37 | + | if len(ip.Data) == 0 || len(ip.Data) > maxImagePushSize || !effect.current() { | |
| 38 | 38 | return | |
| 39 | 39 | } | |
| 40 | - | path, err := d.writeClipboardImageForAttachment(token, ip) | |
| 40 | + | path, err := d.writeClipboardImageForAttachment(effect, ip) | |
| 41 | 41 | if err != nil { | |
| 42 | 42 | d.log.Error("writing clipboard image failed", "err", err) | |
| 43 | 43 | return | |
| 44 | 44 | } | |
| 45 | - | if !token.attachmentEffectCurrent() { | |
| 45 | + | if !effect.current() { | |
| 46 | 46 | _ = os.Remove(path) | |
| 47 | 47 | return | |
| 48 | 48 | } | |
| 49 | - | d.injectClipboardPathForAttachment(token, path) | |
| 49 | + | d.injectClipboardPathForAttachment(effect, path) | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | 52 | // 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 | 84 | 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 | 88 | path, err := d.createClipboardImage(ip) | |
| 89 | 89 | if err != nil { | |
| 90 | 90 | return "", err | |
| 91 | 91 | } | |
| 92 | - | sess := token.sess | |
| 92 | + | sess := effect.sess | |
| 93 | 93 | if sess == nil { | |
| 94 | 94 | _ = os.Remove(path) | |
| 95 | 95 | return "", errAttachmentTransition | |
| 96 | 96 | } | |
| 97 | 97 | sess.mu.Lock() | |
| 98 | - | if !token.attachmentEffectCurrentSessionLocked() { | |
| 98 | + | if !effect.currentSessionLocked() { | |
| 99 | 99 | sess.mu.Unlock() | |
| 100 | 100 | _ = os.Remove(path) | |
| 101 | 101 | return "", errAttachmentTransition | |
| @@ -134,18 +134,18 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) { | |||
| 134 | 134 | d.injectClipboardPathToTarget(sess, path, nil) | |
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | - | func (d *Daemon) injectClipboardPathForAttachment(token attachmentConnectionToken, path string) { | |
| 138 | - | sess := token.sess | |
| 139 | - | if sess == nil || !token.attachmentEffectCurrent() { | |
| 137 | + | func (d *Daemon) injectClipboardPathForAttachment(effect *attachmentEffect, path string) { | |
| 138 | + | sess := effect.sess | |
| 139 | + | if sess == nil || !effect.current() { | |
| 140 | 140 | 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 | 146 | var ac *attachedClient | |
| 147 | - | if token != nil { | |
| 148 | - | ac = token.ac | |
| 147 | + | if effect != nil { | |
| 148 | + | ac = effect.ac | |
| 149 | 149 | } | |
| 150 | 150 | var tb *tab | |
| 151 | 151 | if ac != nil { | |
| @@ -175,7 +175,7 @@ func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token * | |||
| 175 | 175 | wrapped = append(wrapped, clipPasteCloseMarker...) | |
| 176 | 176 | data = wrapped | |
| 177 | 177 | } | |
| 178 | - | if token != nil && !token.attachmentEffectCurrent() { | |
| 178 | + | if effect != nil && !effect.current() { | |
| 179 | 179 | return | |
| 180 | 180 | } | |
| 181 | 181 | d.writeToPane(sess, p, data) | |
| @@ -202,7 +202,7 @@ func clipboardExt(mime string) string { | |||
| 202 | 202 | ||
| 203 | 203 | type clipboardForward struct { | |
| 204 | 204 | owner paneEffectLease | |
| 205 | - | token attachmentConnectionToken | |
| 205 | + | token attachmentCapability | |
| 206 | 206 | seq []byte | |
| 207 | 207 | } | |
| 208 | 208 | ||
| @@ -237,8 +237,8 @@ func (d *Daemon) forwardClipboardAsync(owner paneEffectLease, b64 string) { | |||
| 237 | 237 | items := make([]clipboardForward, 0, len(attachments)) | |
| 238 | 238 | for _, ac := range attachments { | |
| 239 | 239 | transport := ac.transport() | |
| 240 | - | token := sess.attachmentToken(ac, transport) | |
| 241 | - | if token.attachmentCurrent() { | |
| 240 | + | token := sess.captureAttachmentCapability(ac, transport) | |
| 241 | + | if token.current() { | |
| 242 | 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 | 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 | 258 | token := item.token | |
| 259 | 259 | if token.ac == nil || ticket == nil || ticket.ended.Load() || token.transport.transport == nil { | |
| 260 | 260 | return token.transport.transport, errAttachmentTransition | |
| @@ -300,7 +300,7 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta | |||
| 300 | 300 | // admission and marks the transport interval while ownership publication is | |
| 301 | 301 | // excluded. Publication therefore orders either before this check (the send is | |
| 302 | 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 | 304 | if lease.pane == nil || lease.owner == nil || lease.owner.session == nil || lease.owner.tab == nil { | |
| 305 | 305 | 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 | 205 | func publishActiveClipboardCapability(d *Daemon, sess *session, ac *attachedClient, tr ports.Transport) { | |
| 206 | 206 | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 207 | - | token := sess.attachmentToken(ac, tr) | |
| 207 | + | token := sess.captureAttachmentCapability(ac, tr) | |
| 208 | 208 | token.lease = rc.attachmentLease(ac) | |
| 209 | - | ac.publishAttachmentCapability(token) | |
| 209 | + | ac.installTestAttachmentCapability(token) | |
| 210 | 210 | } | |
| 211 | 211 | ||
| 212 | 212 | func clipboardOwnerLease(t *testing.T, sess *session) paneEffectLease { | |
| @@ -419,7 +419,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing | |||
| 419 | 419 | close(workerDone) | |
| 420 | 420 | }() | |
| 421 | 421 | require.Eventually(t, func() bool { | |
| 422 | - | return ac.attachmentEffects.inFlightCount() == 1 | |
| 422 | + | return ac.lifecycle.inFlightCount() == 1 | |
| 423 | 423 | }, time.Second, time.Millisecond, "clipboard send was not admitted before waiting on sendMu") | |
| 424 | 424 | ||
| 425 | 425 | sourceTab := testAttachmentTab(sess) | |
M internal/usecase/daemon/control.go +17 -17
| @@ -300,7 +300,7 @@ type daemonActionTarget struct { | |||
| 300 | 300 | type daemonActionRequest struct { | |
| 301 | 301 | kind daemonActionKind | |
| 302 | 302 | target daemonActionTarget | |
| 303 | - | effect *attachmentEffectTicket | |
| 303 | + | effect *attachmentEffect | |
| 304 | 304 | direction layout.Direction | |
| 305 | 305 | axis layout.Axis | |
| 306 | 306 | delta int | |
| @@ -513,21 +513,21 @@ func (e controlExec) MovePane(destinationSession, destinationTabID string) error | |||
| 513 | 513 | return errMovePaneInvalid | |
| 514 | 514 | } | |
| 515 | 515 | attachment := e.target.attachment | |
| 516 | - | token := attachmentConnectionToken{} | |
| 516 | + | token := attachmentCapability{} | |
| 517 | 517 | if attachment != nil { | |
| 518 | - | token = e.sess.attachmentToken(attachment, attachment.transport()) | |
| 518 | + | token = e.sess.captureAttachmentCapability(attachment, attachment.transport()) | |
| 519 | 519 | if token.ac == nil { | |
| 520 | 520 | return errMoveStaleTarget | |
| 521 | 521 | } | |
| 522 | 522 | } | |
| 523 | 523 | return e.d.movePane(movePaneRequest{ | |
| 524 | - | Attachment: attachment, | |
| 525 | - | AttachmentToken: token, | |
| 526 | - | Source: sessionMoveLocator(e.sess), | |
| 527 | - | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 528 | - | SourcePaneID: domain.PaneStableID(e.target.pane.stableID), | |
| 529 | - | Destination: sessionMoveLocator(destination), | |
| 530 | - | DestinationTabID: domain.TabStableID(destinationTabID), | |
| 524 | + | Attachment: attachment, | |
| 525 | + | AttachmentCapability: token, | |
| 526 | + | Source: sessionMoveLocator(e.sess), | |
| 527 | + | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 528 | + | SourcePaneID: domain.PaneStableID(e.target.pane.stableID), | |
| 529 | + | Destination: sessionMoveLocator(destination), | |
| 530 | + | DestinationTabID: domain.TabStableID(destinationTabID), | |
| 531 | 531 | }) | |
| 532 | 532 | } | |
| 533 | 533 | ||
| @@ -540,19 +540,19 @@ func (e controlExec) MoveTab(destinationSession string) error { | |||
| 540 | 540 | return errMovePaneInvalid | |
| 541 | 541 | } | |
| 542 | 542 | attachment := e.target.attachment | |
| 543 | - | token := attachmentConnectionToken{} | |
| 543 | + | token := attachmentCapability{} | |
| 544 | 544 | if attachment != nil { | |
| 545 | - | token = e.sess.attachmentToken(attachment, attachment.transport()) | |
| 545 | + | token = e.sess.captureAttachmentCapability(attachment, attachment.transport()) | |
| 546 | 546 | if token.ac == nil { | |
| 547 | 547 | return errMoveStaleTarget | |
| 548 | 548 | } | |
| 549 | 549 | } | |
| 550 | 550 | return e.d.moveTab(moveTabRequest{ | |
| 551 | - | Attachment: attachment, | |
| 552 | - | AttachmentToken: token, | |
| 553 | - | Source: sessionMoveLocator(e.sess), | |
| 554 | - | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 555 | - | Destination: sessionMoveLocator(destination), | |
| 551 | + | Attachment: attachment, | |
| 552 | + | AttachmentCapability: token, | |
| 553 | + | Source: sessionMoveLocator(e.sess), | |
| 554 | + | SourceTabID: domain.TabStableID(e.tab.stableID), | |
| 555 | + | Destination: sessionMoveLocator(destination), | |
| 556 | 556 | }) | |
| 557 | 557 | } | |
| 558 | 558 | ||
M internal/usecase/daemon/daemon.go +6 -6
| @@ -161,7 +161,7 @@ type Daemon struct { | |||
| 161 | 161 | afterConnectionSessionSnapshot func(*session) | |
| 162 | 162 | // afterAttachmentFrameDispatch is a deterministic test seam after the | |
| 163 | 163 | // connection loop snapshots a token and before a decoded frame takes effect. | |
| 164 | - | afterAttachmentFrameDispatch func(attachmentConnectionToken) | |
| 164 | + | afterAttachmentFrameDispatch func(attachmentCapability) | |
| 165 | 165 | // afterAttachmentEffectParticipantsSnapshotted observes immutable role-gate | |
| 166 | 166 | // participants after architecture preflight locks are released and before | |
| 167 | 167 | // the globally ordered freeze begins. | |
| @@ -214,10 +214,10 @@ type Daemon struct { | |||
| 214 | 214 | beforeResumeParkedSendMu func() | |
| 215 | 215 | // afterAttachmentEffectAdmitted is a deterministic test seam after a frame/paint | |
| 216 | 216 | // reserves its exact capability and before its first observable mutation. | |
| 217 | - | afterAttachmentEffectAdmitted func(attachmentConnectionToken) | |
| 217 | + | afterAttachmentEffectAdmitted func(attachmentCapability) | |
| 218 | 218 | // beforeFirstPaintSendWait is a test-only seam immediately before a | |
| 219 | 219 | // transition first paint waits for the attachment send lock. | |
| 220 | - | beforeFirstPaintSendWait func(attachmentConnectionToken) | |
| 220 | + | beforeFirstPaintSendWait func(attachmentCapability) | |
| 221 | 221 | // afterDelayedKeyEffectAttempt observes whether a timer callback acquired a | |
| 222 | 222 | // fresh exact capability before producing PTY, action, or overlay effects. | |
| 223 | 223 | afterDelayedKeyEffectAttempt func(bool) | |
| @@ -226,7 +226,7 @@ type Daemon struct { | |||
| 226 | 226 | afterActionAttachmentEffectEnded func(string) | |
| 227 | 227 | // beforeAttachmentSendErrorCleanup pauses asynchronous render-failure retirement | |
| 228 | 228 | // after the render ticket ends and before exact lifecycle validation. | |
| 229 | - | beforeAttachmentSendErrorCleanup func(attachmentConnectionToken) | |
| 229 | + | beforeAttachmentSendErrorCleanup func(attachmentCapability) | |
| 230 | 230 | afterAttachmentSendErrorCleanup func() | |
| 231 | 231 | ptys ports.PTYFactory | |
| 232 | 232 | clock ports.Clock | |
| @@ -1217,7 +1217,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut < | |||
| 1217 | 1217 | return | |
| 1218 | 1218 | } | |
| 1219 | 1219 | expected := ac.transportSnapshot() | |
| 1220 | - | welcomeToken := sess.attachmentToken(ac, tr) | |
| 1220 | + | welcomeToken := sess.captureAttachmentCapability(ac, tr) | |
| 1221 | 1221 | welcomeTicket, admitted := ac.beginAttachmentEffect(welcomeToken) | |
| 1222 | 1222 | if expected.transport != tr || !admitted || welcomeToken.ac == nil { | |
| 1223 | 1223 | if admitted { | |
| @@ -1267,7 +1267,7 @@ func (d *Daemon) handleHelloWithContext(handshakeCtx context.Context, timedOut < | |||
| 1267 | 1267 | failAttachment() | |
| 1268 | 1268 | return | |
| 1269 | 1269 | } | |
| 1270 | - | paintToken := sess.attachmentToken(ac, tr) | |
| 1270 | + | paintToken := sess.captureAttachmentCapability(ac, tr) | |
| 1271 | 1271 | painted := make(chan bool, 1) | |
| 1272 | 1272 | paintDone, paintErr := boundedHandshakeOperationTracked(handshakeCtx, tr, func() error { | |
| 1273 | 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 | 42 | d.ptys = newFactory(t, newQuietPTY()) | |
| 43 | 43 | rc := d.attachCoordinator(sess, nil, first, true) | |
| 44 | 44 | d.attachCoordinator(sess, nil, second, true) | |
| 45 | - | firstToken := sess.attachmentToken(first, first.transport()) | |
| 45 | + | firstToken := sess.captureAttachmentCapability(first, first.transport()) | |
| 46 | 46 | firstToken.lease = rc.attachmentLease(first) | |
| 47 | - | first.publishAttachmentCapability(firstToken) | |
| 48 | - | secondToken := sess.attachmentToken(second, second.transport()) | |
| 47 | + | first.installTestAttachmentCapability(firstToken) | |
| 48 | + | secondToken := sess.captureAttachmentCapability(second, second.transport()) | |
| 49 | 49 | secondToken.lease = rc.attachmentLease(second) | |
| 50 | - | second.publishAttachmentCapability(secondToken) | |
| 50 | + | second.installTestAttachmentCapability(secondToken) | |
| 51 | 51 | ||
| 52 | 52 | firstView := first.viewSnapshot() | |
| 53 | 53 | secondView := second.viewSnapshot() | |
| 54 | 54 | first.sendMu.Lock() | |
| 55 | 55 | firstEpoch := first.output.currentEpoch() | |
| 56 | 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 | 62 | d.enterPalette(sess, second) | |
| 60 | 63 | d.handlePaletteInput(second, []byte("CNT\r")) | |
| @@ -136,12 +139,12 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing | |||
| 136 | 139 | ||
| 137 | 140 | rc := d.attachCoordinator(sess, nil, first, true) | |
| 138 | 141 | d.attachCoordinator(sess, nil, second, true) | |
| 139 | - | firstToken := sess.attachmentToken(first, first.transport()) | |
| 142 | + | firstToken := sess.captureAttachmentCapability(first, first.transport()) | |
| 140 | 143 | firstToken.lease = rc.attachmentLease(first) | |
| 141 | - | first.publishAttachmentCapability(firstToken) | |
| 142 | - | secondToken := sess.attachmentToken(second, second.transport()) | |
| 144 | + | first.installTestAttachmentCapability(firstToken) | |
| 145 | + | secondToken := sess.captureAttachmentCapability(second, second.transport()) | |
| 143 | 146 | secondToken.lease = rc.attachmentLease(second) | |
| 144 | - | second.publishAttachmentCapability(secondToken) | |
| 147 | + | second.installTestAttachmentCapability(secondToken) | |
| 145 | 148 | firstEpoch := first.output.currentEpoch() | |
| 146 | 149 | secondEpoch := second.output.currentEpoch() | |
| 147 | 150 | ||
| @@ -152,7 +155,10 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing | |||
| 152 | 155 | require.Greater(t, second.output.currentEpoch(), secondEpoch) | |
| 153 | 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 | 162 | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size) | |
| 157 | 163 | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary") | |
| 158 | 164 | sess.mu.Lock() | |
| @@ -183,9 +189,9 @@ func TestAcceptanceDirectAttachmentSupportsVerticalSplit(t *testing.T) { | |||
| 183 | 189 | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 184 | 190 | d.ptys = newFactory(t, newQuietPTY()) | |
| 185 | 191 | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 186 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 192 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 187 | 193 | token.lease = rc.attachmentLease(ac) | |
| 188 | - | ac.publishAttachmentCapability(token) | |
| 194 | + | ac.installTestAttachmentCapability(token) | |
| 189 | 195 | ||
| 190 | 196 | d.enterPalette(sess, ac) | |
| 191 | 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 | 117 | paintStarted := make(chan struct{}) | |
| 118 | 118 | releasePaint := make(chan struct{}) | |
| 119 | 119 | paintDone := make(chan struct{}) | |
| 120 | - | d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) { | |
| 120 | + | d.afterAttachmentEffectAdmitted = func(attachmentCapability) { | |
| 121 | 121 | close(paintStarted) | |
| 122 | 122 | <-ctx.Done() | |
| 123 | 123 | <-releasePaint | |
M internal/usecase/daemon/input.go +24 -29
| @@ -20,11 +20,11 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6 | |||
| 20 | 20 | d.handleInput(sess, ac, data) | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | - | func (d *Daemon) handleSequencedInputForAttachment(token attachmentConnectionToken, _ uint64, data []byte) { | |
| 24 | - | if !token.attachmentEffectCurrent() { | |
| 23 | + | func (d *Daemon) handleSequencedInputForAttachment(effect *attachmentEffect, _ uint64, data []byte) { | |
| 24 | + | if !effect.current() { | |
| 25 | 25 | return | |
| 26 | 26 | } | |
| 27 | - | d.handleInputForAttachment(token, data) | |
| 27 | + | d.handleInputForAttachment(effect, data) | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 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) { | |
| 44 | - | ac := token.ac | |
| 43 | + | func (d *Daemon) handleInputForAttachment(effect *attachmentEffect, data []byte) { | |
| 44 | + | ac := effect.ac | |
| 45 | 45 | ac.initOverlays() | |
| 46 | 46 | ac.mouseScan.Scan(data, | |
| 47 | 47 | func(ev mouse.Event) { | |
| 48 | - | if token.attachmentEffectCurrent() { | |
| 48 | + | if effect.current() { | |
| 49 | 49 | d.handleMouse(ac, ev) | |
| 50 | 50 | } | |
| 51 | 51 | }, | |
| 52 | 52 | func(b []byte) { | |
| 53 | - | if !token.attachmentEffectCurrent() { | |
| 53 | + | if !effect.current() { | |
| 54 | 54 | return | |
| 55 | 55 | } | |
| 56 | - | if ac.overlays.HandleInput(d, b, token.effect) { | |
| 56 | + | if ac.overlays.HandleInput(d, b, effect) { | |
| 57 | 57 | return | |
| 58 | 58 | } | |
| 59 | - | if !token.attachmentEffectCurrent() { | |
| 59 | + | if !effect.current() { | |
| 60 | 60 | 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 | 373 | type daemonKeyHandler struct { | |
| 374 | - | d *Daemon | |
| 375 | - | ac *attachedClient | |
| 376 | - | actions daemonActionRunner | |
| 377 | - | connectionToken attachmentConnectionToken | |
| 374 | + | d *Daemon | |
| 375 | + | ac *attachedClient | |
| 376 | + | actions daemonActionRunner | |
| 377 | + | effect *attachmentEffect | |
| 378 | 378 | } | |
| 379 | 379 | ||
| 380 | 380 | // acquireAttachmentEffect preserves a synchronous frame's existing admission and | |
| 381 | 381 | // gives delayed router callbacks a fresh ticket for the exact captured attachment. | |
| 382 | 382 | // Direct/headless callers without an attachment token retain their existing behavior. | |
| 383 | - | func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffectTicket, bool) { | |
| 384 | - | if h.connectionToken.ac != nil { | |
| 385 | - | if effect := h.connectionToken.effect; effect != nil && !effect.ended.Load() { | |
| 386 | - | return h.connectionToken.sess, effect, false | |
| 383 | + | func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect, bool) { | |
| 384 | + | if h.effect != nil && h.effect.ac != nil { | |
| 385 | + | if h.effect.current() { | |
| 386 | + | return h.effect.sess, h.effect, false | |
| 387 | 387 | } | |
| 388 | - | effect, admitted := h.connectionToken.ac.beginAttachmentEffect(h.connectionToken) | |
| 388 | + | effect, admitted := h.effect.ac.beginAttachmentEffect(h.effect.capability()) | |
| 389 | 389 | if h.d.afterDelayedKeyEffectAttempt != nil { | |
| 390 | 390 | h.d.afterDelayedKeyEffectAttempt(admitted) | |
| 391 | 391 | } | |
| @@ -393,16 +393,13 @@ func (h daemonKeyHandler) acquireAttachmentEffect() (*session, *attachmentEffect | |||
| 393 | 393 | return nil, nil, false | |
| 394 | 394 | } | |
| 395 | 395 | if h.d.afterAttachmentEffectAdmitted != nil { | |
| 396 | - | token := h.connectionToken | |
| 397 | - | token.effect = effect | |
| 398 | - | h.d.afterAttachmentEffectAdmitted(token) | |
| 396 | + | h.d.afterAttachmentEffectAdmitted(effect.capability()) | |
| 399 | 397 | } | |
| 400 | - | // Keep this defensive check even though token admission normally requires a session. | |
| 401 | - | if h.connectionToken.sess == nil { | |
| 398 | + | if effect.sess == nil { | |
| 402 | 399 | effect.End() | |
| 403 | 400 | return nil, nil, false | |
| 404 | 401 | } | |
| 405 | - | return h.connectionToken.sess, effect, true | |
| 402 | + | return effect.sess, effect, true | |
| 406 | 403 | } | |
| 407 | 404 | sess := h.ac.currentSession() | |
| 408 | 405 | if sess == nil { | |
| @@ -475,9 +472,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||
| 475 | 472 | return | |
| 476 | 473 | } | |
| 477 | 474 | effect.bindActionEnd(h.d, "jump-attention") | |
| 478 | - | token := h.connectionToken | |
| 479 | - | token.effect = effect | |
| 480 | - | if err := h.d.jumpAttentionForAttachment(sess, h.ac, token); err != nil { | |
| 475 | + | if err := h.d.jumpAttentionForAttachment(sess, h.ac, effect); err != nil { | |
| 481 | 476 | h.d.reportError(sess, err) | |
| 482 | 477 | } | |
| 483 | 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 | 520 | err := h.d.focusDir(sess, h.ac, dir, effect) | |
| 526 | 521 | if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) { | |
| 527 | 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 | 783 | defer releaseAll(releases) | |
| 784 | 784 | transport := ac.transport() | |
| 785 | 785 | rc := d.attachCoordinator(current, nil, ac, true) | |
| 786 | - | token := current.attachmentToken(ac, transport) | |
| 786 | + | token := current.captureAttachmentCapability(ac, transport) | |
| 787 | 787 | token.lease = rc.attachmentLease(ac) | |
| 788 | - | ac.publishAttachmentCapability(token) | |
| 788 | + | ac.installTestAttachmentCapability(token) | |
| 789 | 789 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 790 | 790 | require.True(t, admitted) | |
| 791 | 791 | defer effect.End() | |
| 792 | - | token.effect = effect | |
| 793 | 792 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 794 | 793 | Generation: 3, | |
| 795 | 794 | Active: ports.RouteRef{Key: 3, Generation: 3}, | |
| @@ -797,7 +796,7 @@ func TestPaletteBackSessionSendsClientPreviousRouteAction(t *testing.T) { | |||
| 797 | 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 | 800 | frame := awaitFrame(t, sends, ports.MsgNavigateRecentRoute) | |
| 802 | 801 | action, err := ports.UnmarshalRouteNavigationAction(frame.Payload) | |
| 803 | 802 | require.NoError(t, err) | |
M internal/usecase/daemon/lifecycle_fence_test.go +6 -6
| @@ -21,7 +21,7 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation | |||
| 21 | 21 | sourceCoordinator := newRenderCoordinator(renderCoordinatorOptions{}) | |
| 22 | 22 | source.installRenderCoordinator(sourceCoordinator) | |
| 23 | 23 | sourceCoordinator.attach(ac) | |
| 24 | - | token := source.attachmentToken(ac, transport) | |
| 24 | + | token := source.captureAttachmentCapability(ac, transport) | |
| 25 | 25 | require.NotNil(t, token.lease) | |
| 26 | 26 | d.sessions[source.id] = source | |
| 27 | 27 | d.sessions[target.id] = target | |
| @@ -37,7 +37,7 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation | |||
| 37 | 37 | } | |
| 38 | 38 | req := attachmentTransitionRequest{ | |
| 39 | 39 | source: source, target: target, next: ac, | |
| 40 | - | expectedTransport: ac.transportSnapshot(), sourceToken: &token, | |
| 40 | + | expectedTransport: ac.transportSnapshot(), sourceCapability: &token, | |
| 41 | 41 | preflighted: true, attachmentEffectsFrozen: true, | |
| 42 | 42 | } | |
| 43 | 43 | d.mu.Lock() | |
| @@ -56,7 +56,7 @@ func TestValidateAttachmentTransitionLocksSourceCoordinatorBeforeLeaseValidation | |||
| 56 | 56 | require.NotNil(t, publication) | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | - | func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T) { | |
| 59 | + | func TestAttachmentCapabilityRevalidatesTheCapturedTransportIncarnation(t *testing.T) { | |
| 60 | 60 | first := &closeTrackingTransport{} | |
| 61 | 61 | second := &closeTrackingTransport{} | |
| 62 | 62 | ac := &attachedClient{tr: first} | |
| @@ -69,12 +69,12 @@ func TestAttachmentTokenRevalidatesTheCapturedTransportIncarnation(t *testing.T) | |||
| 69 | 69 | ||
| 70 | 70 | captured := make(chan struct{}) | |
| 71 | 71 | release := make(chan struct{}) | |
| 72 | - | ac.beforeAttachmentTokenValidation = func() { | |
| 72 | + | ac.beforeAttachmentCapabilityValidation = func() { | |
| 73 | 73 | close(captured) | |
| 74 | 74 | <-release | |
| 75 | 75 | } | |
| 76 | - | result := make(chan attachmentConnectionToken, 1) | |
| 77 | - | go func() { result <- sess.attachmentToken(ac, first) }() | |
| 76 | + | result := make(chan attachmentCapability, 1) | |
| 77 | + | go func() { result <- sess.captureAttachmentCapability(ac, first) }() | |
| 78 | 78 | <-captured | |
| 79 | 79 | ac.replaceTransport(second) | |
| 80 | 80 | close(release) | |
M internal/usecase/daemon/move.go +13 -13
| @@ -10,21 +10,21 @@ import ( | |||
| 10 | 10 | // immutable identity. Session names are advisory; the ID and incarnation are | |
| 11 | 11 | // the commit-time authority. | |
| 12 | 12 | type movePaneRequest struct { | |
| 13 | - | Attachment *attachedClient | |
| 14 | - | AttachmentToken attachmentConnectionToken | |
| 15 | - | Source moveSessionLocator | |
| 16 | - | SourceTabID domain.TabStableID | |
| 17 | - | SourcePaneID domain.PaneStableID | |
| 18 | - | Destination moveSessionLocator | |
| 19 | - | DestinationTabID domain.TabStableID | |
| 13 | + | Attachment *attachedClient | |
| 14 | + | AttachmentCapability attachmentCapability | |
| 15 | + | Source moveSessionLocator | |
| 16 | + | SourceTabID domain.TabStableID | |
| 17 | + | SourcePaneID domain.PaneStableID | |
| 18 | + | Destination moveSessionLocator | |
| 19 | + | DestinationTabID domain.TabStableID | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | type moveTabRequest struct { | |
| 23 | - | Attachment *attachedClient | |
| 24 | - | AttachmentToken attachmentConnectionToken | |
| 25 | - | Source moveSessionLocator | |
| 26 | - | SourceTabID domain.TabStableID | |
| 27 | - | Destination moveSessionLocator | |
| 23 | + | Attachment *attachedClient | |
| 24 | + | AttachmentCapability attachmentCapability | |
| 25 | + | Source moveSessionLocator | |
| 26 | + | SourceTabID domain.TabStableID | |
| 27 | + | Destination moveSessionLocator | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | // movePane resolves, reserves, validates, and commits one pane relocation. All | |
| @@ -115,7 +115,7 @@ func (d *Daemon) movePane(req movePaneRequest) (result error) { | |||
| 115 | 115 | // A final source tab is retired without transferring any attachment to the | |
| 116 | 116 | // destination. Freeze every affected source connection before publication so | |
| 117 | 117 | // teardown invalidates exact generations rather than racing input or render. | |
| 118 | - | var frozen frozenAttachmentEffectGates | |
| 118 | + | var frozen attachmentTransitionGuard | |
| 119 | 119 | var sourceEffectsFrozen bool | |
| 120 | 120 | if finalSourceTab && source != destination && len(admission.sourceAttachments) != 0 { | |
| 121 | 121 | interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments)) | |
M internal/usecase/daemon/move_attachments.go +0 -7
| @@ -1,11 +1,5 @@ | |||
| 1 | 1 | 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 | 3 | // sameMoveAttachmentsLocked reports whether the source membership is unchanged | |
| 10 | 4 | // since admission. Caller holds the source session lock. | |
| 11 | 5 | func sameMoveAttachmentsLocked(sess *session, admitted []*attachedClient) bool { | |
| @@ -33,7 +27,6 @@ func detachMoveAttachmentsLocked(sess *session, transports map[*attachedClient]t | |||
| 33 | 27 | retired := make([]detachedAttachmentSnapshot, 0, len(attachments)) | |
| 34 | 28 | for _, ac := range attachments { | |
| 35 | 29 | retired = append(retired, detachedAttachmentSnapshot{ac: ac, transport: transports[ac]}) | |
| 36 | - | ac.connectionGeneration.Add(1) | |
| 37 | 30 | ac.setSession(nil) | |
| 38 | 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 | 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 | 107 | movedPTY, releaseMoved := newBlockingPTY(t) | |
| 108 | 108 | remainingPTY, releaseRemaining := newBlockingPTY(t) | |
| 109 | 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 | 194 | sess.mu.Unlock() | |
| 195 | 195 | d.attachCoordinator(sess, nil, other, true) | |
| 196 | 196 | ||
| 197 | - | currentToken := sess.attachmentToken(current, current.transport()) | |
| 197 | + | currentToken := sess.captureAttachmentCapability(current, current.transport()) | |
| 198 | 198 | currentEffect, admitted := current.beginAttachmentEffect(currentToken) | |
| 199 | 199 | require.True(t, admitted) | |
| 200 | 200 | t.Cleanup(currentEffect.End) | |
| 201 | - | otherToken := sess.attachmentToken(other, otherTransport) | |
| 201 | + | otherToken := sess.captureAttachmentCapability(other, otherTransport) | |
| 202 | 202 | otherEffect, admitted := other.beginAttachmentEffect(otherToken) | |
| 203 | 203 | require.True(t, admitted) | |
| 204 | 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 | 32 | if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) { | |
| 33 | 33 | 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 | 36 | 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 | 17 | sourceTransports map[*attachedClient]transportSnapshot | |
| 18 | 18 | sourceGeneration uint64 | |
| 19 | 19 | destinationGeneration uint64 | |
| 20 | - | frozenEffects frozenAttachmentEffectGates | |
| 20 | + | frozenEffects attachmentTransitionGuard | |
| 21 | 21 | err error | |
| 22 | 22 | ||
| 23 | 23 | syncCleanup syncTimerCleanup | |
| @@ -51,7 +51,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 51 | 51 | if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) { | |
| 52 | 52 | 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 | 55 | return false | |
| 56 | 56 | } | |
| 57 | 57 | ||
M internal/usecase/daemon/move_pane_postcommit.go +1 -1
| @@ -29,7 +29,7 @@ type movePanePostcommitPlan struct { | |||
| 29 | 29 | operation string | |
| 30 | 30 | sourceAttachments []*attachedClient | |
| 31 | 31 | syncCleanup syncTimerCleanup | |
| 32 | - | frozenEffects frozenAttachmentEffectGates | |
| 32 | + | frozenEffects attachmentTransitionGuard | |
| 33 | 33 | effectsFrozen bool | |
| 34 | 34 | unlockDispatch func() | |
| 35 | 35 | 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 | 17 | sess.mu.Lock() | |
| 18 | 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 | 20 | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source attachment is no longer active.", errMovePaneInvalid) | |
| 21 | 21 | } | |
| 22 | 22 | if source.Attachment != nil && !attachmentRegisteredLocked(sess, source.Attachment) { | |
| @@ -52,11 +52,11 @@ type moveSessionLocator struct { | |||
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | 54 | type moveSourceLocator struct { | |
| 55 | - | Session moveSessionLocator | |
| 56 | - | TabID domain.TabStableID | |
| 57 | - | PaneID domain.PaneStableID | |
| 58 | - | Attachment *attachedClient | |
| 59 | - | AttachmentToken attachmentConnectionToken | |
| 55 | + | Session moveSessionLocator | |
| 56 | + | TabID domain.TabStableID | |
| 57 | + | PaneID domain.PaneStableID | |
| 58 | + | Attachment *attachedClient | |
| 59 | + | AttachmentCapability attachmentCapability | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | // enterPickerForIntent returns errNoMoveDestination only for move intents. | |
| @@ -64,8 +64,8 @@ func (d *Daemon) enterPickerForIntent(sess *session, ac *attachedClient, intent | |||
| 64 | 64 | if intent != pickerNavigate && !sess.capabilities().yieldsMoves() { | |
| 65 | 65 | return errSessionCannotYieldMoves | |
| 66 | 66 | } | |
| 67 | - | if source.Attachment != nil && source.AttachmentToken.ac == nil { | |
| 68 | - | source.AttachmentToken = sess.attachmentToken(source.Attachment, source.Attachment.transport()) | |
| 67 | + | if source.Attachment != nil && source.AttachmentCapability.ac == nil { | |
| 68 | + | source.AttachmentCapability = sess.captureAttachmentCapability(source.Attachment, source.Attachment.transport()) | |
| 69 | 69 | } | |
| 70 | 70 | model := d.newPickerModel(sess, ac, intent, source, picker.SourceFilter{}) | |
| 71 | 71 | if intent != pickerNavigate { | |
| @@ -97,21 +97,21 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc | |||
| 97 | 97 | return errMovePaneInvalid | |
| 98 | 98 | } | |
| 99 | 99 | return d.movePane(movePaneRequest{ | |
| 100 | - | Attachment: source.Attachment, | |
| 101 | - | AttachmentToken: source.AttachmentToken, | |
| 102 | - | Source: source.Session, | |
| 103 | - | SourceTabID: source.TabID, | |
| 104 | - | SourcePaneID: source.PaneID, | |
| 105 | - | Destination: destination, | |
| 106 | - | DestinationTabID: target.TabID, | |
| 100 | + | Attachment: source.Attachment, | |
| 101 | + | AttachmentCapability: source.AttachmentCapability, | |
| 102 | + | Source: source.Session, | |
| 103 | + | SourceTabID: source.TabID, | |
| 104 | + | SourcePaneID: source.PaneID, | |
| 105 | + | Destination: destination, | |
| 106 | + | DestinationTabID: target.TabID, | |
| 107 | 107 | }) | |
| 108 | 108 | case pickerMoveTab: | |
| 109 | 109 | return d.moveTab(moveTabRequest{ | |
| 110 | - | Attachment: source.Attachment, | |
| 111 | - | AttachmentToken: source.AttachmentToken, | |
| 112 | - | Source: source.Session, | |
| 113 | - | SourceTabID: source.TabID, | |
| 114 | - | Destination: destination, | |
| 110 | + | Attachment: source.Attachment, | |
| 111 | + | AttachmentCapability: source.AttachmentCapability, | |
| 112 | + | Source: source.Session, | |
| 113 | + | SourceTabID: source.TabID, | |
| 114 | + | Destination: destination, | |
| 115 | 115 | }) | |
| 116 | 116 | default: | |
| 117 | 117 | return errMovePaneInvalid | |
M internal/usecase/daemon/move_tab.go +4 -4
| @@ -28,7 +28,7 @@ type moveTabCommit struct { | |||
| 28 | 28 | destination *session | |
| 29 | 29 | admission moveTabAdmission | |
| 30 | 30 | sourceEffectsFrozen bool | |
| 31 | - | frozenEffects frozenAttachmentEffectGates | |
| 31 | + | frozenEffects attachmentTransitionGuard | |
| 32 | 32 | syncCleanup syncTimerCleanup | |
| 33 | 33 | oldTabCancel context.CancelFunc | |
| 34 | 34 | sourceEmpty bool | |
| @@ -110,7 +110,7 @@ func (d *Daemon) moveTab(req moveTabRequest) (result error) { | |||
| 110 | 110 | d.afterMoveTabSourceSnapshot() | |
| 111 | 111 | } | |
| 112 | 112 | commit := moveTabCommit{req: req, source: source, destination: destination, admission: *admission} | |
| 113 | - | var frozen frozenAttachmentEffectGates | |
| 113 | + | var frozen attachmentTransitionGuard | |
| 114 | 114 | if admission.finalSource && len(admission.sourceAttachments) != 0 { | |
| 115 | 115 | interrupts := make([]attachmentTransportInterrupt, 0, len(admission.sourceAttachments)) | |
| 116 | 116 | for _, ac := range admission.sourceAttachments { | |
| @@ -190,7 +190,7 @@ func (d *Daemon) snapshotMoveTabAdmission(req moveTabRequest, source, destinatio | |||
| 190 | 190 | if req.Attachment != nil && !attachmentRegisteredLocked(source, req.Attachment) { | |
| 191 | 191 | 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 | 194 | return nil, errMoveStaleTarget | |
| 195 | 195 | } | |
| 196 | 196 | moved := findMoveTabLocked(source, req.SourceTabID) | |
| @@ -251,7 +251,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 251 | 251 | if c.req.Attachment != nil && !attachmentRegisteredLocked(c.source, c.req.Attachment) { | |
| 252 | 252 | 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 | 255 | return false | |
| 256 | 256 | } | |
| 257 | 257 | 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 | 408 | sourceFenceUnlocked := r.source.layoutApplyMu.TryLock() | |
| 409 | 409 | destinationFenceUnlocked := r.destination.layoutApplyMu.TryLock() | |
| 410 | 410 | tabFenceUnlocked := r.moved.layoutApplyMu.TryLock() | |
| 411 | - | roleUnlocked := r.client.attachmentEffects.mu.TryLock() | |
| 412 | - | roleStable := roleUnlocked && r.client.attachmentEffects.phase == attachmentEffectsStable | |
| 411 | + | roleUnlocked := r.client.lifecycle.mu.TryLock() | |
| 412 | + | roleStable := roleUnlocked && r.client.lifecycle.phase == attachmentEffectsStable | |
| 413 | 413 | r.outside = daemonUnlocked && routingUnlocked && sourceUnlocked && destinationUnlocked && tabUnlocked && paneUnlocked && | |
| 414 | 414 | sourceFenceUnlocked && destinationFenceUnlocked && tabFenceUnlocked && roleStable | |
| 415 | 415 | if roleUnlocked { | |
| 416 | - | r.client.attachmentEffects.mu.Unlock() | |
| 416 | + | r.client.lifecycle.mu.Unlock() | |
| 417 | 417 | } | |
| 418 | 418 | if tabFenceUnlocked { | |
| 419 | 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 | 17 | name string | |
| 18 | 18 | releasedAction string | |
| 19 | 19 | wantErr bool | |
| 20 | - | run func(*Daemon, *session, *session, *attachedClient, *attachmentEffectTicket) error | |
| 20 | + | run func(*Daemon, *session, *session, *attachedClient, *attachmentEffect) error | |
| 21 | 21 | check func(*testing.T, *Daemon, *session, *session) | |
| 22 | 22 | }{ | |
| 23 | 23 | { | |
| 24 | 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 | 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 | 31 | name: "palette session", releasedAction: "detach", | |
| 32 | - | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffectTicket) error { | |
| 33 | - | return d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session") | |
| 32 | + | run: func(d *Daemon, _ *session, target *session, _ *attachedClient, effect *attachmentEffect) error { | |
| 33 | + | return d.switchToTargetForAttachment(effect, picker.Target{Session: target.id, TabIndex: 1}, sessionHandoffGuard{}, "palette-session") | |
| 34 | 34 | }, | |
| 35 | 35 | }, | |
| 36 | 36 | { | |
| 37 | 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 | 39 | source.mu.Lock() | |
| 40 | 40 | source.name = "alpha" | |
| 41 | 41 | source.mu.Unlock() | |
| @@ -48,13 +48,13 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 48 | 48 | }, | |
| 49 | 49 | { | |
| 50 | 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 | 52 | d.mu.Lock() | |
| 53 | 53 | delete(d.sessions, target.id) | |
| 54 | 54 | d.inactive["stopped"] = inactiveSession{name: "stopped", cwd: "/tmp", createdAt: 9, incarnation: domain.IncarnationID{2}, state: ports.SessionDown} | |
| 55 | 55 | d.mu.Unlock() | |
| 56 | 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 | 59 | check: func(t *testing.T, d *Daemon, _, _ *session) { | |
| 60 | 60 | d.mu.Lock() | |
| @@ -67,8 +67,8 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 67 | 67 | }, | |
| 68 | 68 | { | |
| 69 | 69 | name: "transition prompt creation", releasedAction: "create-session", wantErr: true, | |
| 70 | - | run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffectTicket) error { | |
| 71 | - | return d.createSessionAndSwitchForAttachment(effect.connectionToken(), "created") | |
| 70 | + | run: func(d *Daemon, _ *session, _ *session, _ *attachedClient, effect *attachmentEffect) error { | |
| 71 | + | return d.createSessionAndSwitchForAttachment(effect, "created") | |
| 72 | 72 | }, | |
| 73 | 73 | check: func(t *testing.T, d *Daemon, _, _ *session) { | |
| 74 | 74 | d.mu.Lock() | |
| @@ -93,9 +93,9 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 93 | 93 | d.mu.Unlock() | |
| 94 | 94 | ||
| 95 | 95 | rc := d.attachCoordinator(source, nil, old, true) | |
| 96 | - | token := source.attachmentToken(old, old.transport()) | |
| 96 | + | token := source.captureAttachmentCapability(old, old.transport()) | |
| 97 | 97 | token.lease = rc.attachmentLease(old) | |
| 98 | - | old.publishAttachmentCapability(token) | |
| 98 | + | old.installTestAttachmentCapability(token) | |
| 99 | 99 | effect, admitted := old.beginAttachmentEffect(token) | |
| 100 | 100 | require.True(t, admitted) | |
| 101 | 101 | ||
| @@ -153,9 +153,9 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing. | |||
| 153 | 153 | d.mu.Unlock() | |
| 154 | 154 | ||
| 155 | 155 | rc := d.attachCoordinator(source, nil, old, true) | |
| 156 | - | token := source.attachmentToken(old, old.transport()) | |
| 156 | + | token := source.captureAttachmentCapability(old, old.transport()) | |
| 157 | 157 | token.lease = rc.attachmentLease(old) | |
| 158 | - | old.publishAttachmentCapability(token) | |
| 158 | + | old.installTestAttachmentCapability(token) | |
| 159 | 159 | effect, admitted := old.beginAttachmentEffect(token) | |
| 160 | 160 | require.True(t, admitted) | |
| 161 | 161 | ||
| @@ -171,7 +171,7 @@ func TestStoppedSessionHandoffDoesNotResumeAfterInitiatorReplacement(t *testing. | |||
| 171 | 171 | expectedCreatedAt := int64(7) | |
| 172 | 172 | done := make(chan error, 1) | |
| 173 | 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 | 176 | select { | |
| 177 | 177 | case <-ended: | |
M internal/usecase/daemon/navigation_attachment_incarnation_test.go +8 -8
| @@ -19,9 +19,9 @@ func TestNavigationHandoffRejectsReplacedTargetLifecycleAtPublication(t *testing | |||
| 19 | 19 | d.mu.Unlock() | |
| 20 | 20 | ||
| 21 | 21 | rc := d.attachCoordinator(source, nil, ac, true) | |
| 22 | - | token := source.attachmentToken(ac, ac.transport()) | |
| 22 | + | token := source.captureAttachmentCapability(ac, ac.transport()) | |
| 23 | 23 | token.lease = rc.attachmentLease(ac) | |
| 24 | - | ac.publishAttachmentCapability(token) | |
| 24 | + | ac.installTestAttachmentCapability(token) | |
| 25 | 25 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 26 | 26 | require.True(t, admitted) | |
| 27 | 27 | ||
| @@ -30,7 +30,7 @@ func TestNavigationHandoffRejectsReplacedTargetLifecycleAtPublication(t *testing | |||
| 30 | 30 | // transition's lifecycle fence is validated. | |
| 31 | 31 | target.incarnation = domain.SessionLifecycleID{2} | |
| 32 | 32 | } | |
| 33 | - | err := d.switchToTargetForAttachment(effect.connectionToken(), picker.Target{ | |
| 33 | + | err := d.switchToTargetForAttachment(effect, picker.Target{ | |
| 34 | 34 | Session: target.id, | |
| 35 | 35 | Incarnation: domain.SessionLifecycleID{1}, | |
| 36 | 36 | TabID: domain.TabStableID(target.tabs[1].stableID), | |
| @@ -69,9 +69,9 @@ func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testi | |||
| 69 | 69 | name: "transport incarnation rebound on same attachment", | |
| 70 | 70 | rebind: func(t *testing.T, _ *Daemon, source *session, ac *attachedClient) { | |
| 71 | 71 | ac.replaceTransport(&closeTrackingTransport{}) | |
| 72 | - | current := source.attachmentToken(ac, ac.transport()) | |
| 72 | + | current := source.captureAttachmentCapability(ac, ac.transport()) | |
| 73 | 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 | 88 | d.mu.Unlock() | |
| 89 | 89 | ||
| 90 | 90 | rc := d.attachCoordinator(source, nil, ac, true) | |
| 91 | - | token := source.attachmentToken(ac, ac.transport()) | |
| 91 | + | token := source.captureAttachmentCapability(ac, ac.transport()) | |
| 92 | 92 | token.lease = rc.attachmentLease(ac) | |
| 93 | - | ac.publishAttachmentCapability(token) | |
| 93 | + | ac.installTestAttachmentCapability(token) | |
| 94 | 94 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 95 | 95 | require.True(t, admitted) | |
| 96 | 96 | ||
| @@ -104,7 +104,7 @@ func TestNavigationHandoffDoesNotMutateAfterInitiatorIncarnationChanges(t *testi | |||
| 104 | 104 | } | |
| 105 | 105 | done := make(chan error, 1) | |
| 106 | 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 | 109 | awaitTestCompletion(t, ended, "test handoff did not release its role ticket") | |
| 110 | 110 | tt.rebind(t, d, source, ac) | |
M internal/usecase/daemon/overlay_runtime.go +3 -3
| @@ -53,7 +53,7 @@ type overlayRuntime struct { | |||
| 53 | 53 | promptMu sync.Mutex | |
| 54 | 54 | prompt *promptui.Model | |
| 55 | 55 | promptSubmit func(string) error | |
| 56 | - | promptTransitionSubmit func(string, attachmentConnectionToken) error | |
| 56 | + | promptTransitionSubmit func(string, *attachmentEffect) error | |
| 57 | 57 | promptPending []byte | |
| 58 | 58 | ||
| 59 | 59 | resizeMu sync.Mutex | |
| @@ -247,12 +247,12 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||
| 247 | 247 | 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 | 251 | if rt == nil || rt.ac == nil { | |
| 252 | 252 | return false | |
| 253 | 253 | } | |
| 254 | 254 | ac := rt.ac | |
| 255 | - | var effect *attachmentEffectTicket | |
| 255 | + | var effect *attachmentEffect | |
| 256 | 256 | if len(effects) != 0 { | |
| 257 | 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 | 329 | 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 | 333 | entry := ac.currentAttachmentSession() | |
| 334 | 334 | if entry == nil { | |
| 335 | 335 | return | |
| @@ -350,7 +350,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 350 | 350 | var rawQuery string | |
| 351 | 351 | changed, cancel, execute := false, false, false | |
| 352 | 352 | var closedDiscovery remoteDiscoveryInstance | |
| 353 | - | var effect *attachmentEffectTicket | |
| 353 | + | var effect *attachmentEffect | |
| 354 | 354 | if len(effects) != 0 { | |
| 355 | 355 | effect = effects[0] | |
| 356 | 356 | } | |
| @@ -490,7 +490,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 490 | 490 | RemoteKey: &remoteKey, RemoteTarget: &remoteTarget, RemoteHost: remoteKey.Host, | |
| 491 | 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 | 494 | if err == nil { | |
| 495 | 495 | d.closeExecutedPalette(ac, generation, rawQuery) | |
| 496 | 496 | return | |
| @@ -529,7 +529,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 529 | 529 | } | |
| 530 | 530 | var err error | |
| 531 | 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 | 533 | } else { | |
| 534 | 534 | err = d.switchToTarget(sess, ac, target) | |
| 535 | 535 | } | |
| @@ -582,7 +582,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 582 | 582 | sess.dispatchMu.Unlock() | |
| 583 | 583 | if attachmentHandoff { | |
| 584 | 584 | if current := ac.currentSession(); current != nil { | |
| 585 | - | currentToken := current.attachmentToken(ac, ac.transport()) | |
| 585 | + | currentToken := current.captureAttachmentCapability(ac, ac.transport()) | |
| 586 | 586 | fresh, admitted := ac.beginAttachmentEffect(currentToken) | |
| 587 | 587 | if d.closeExecutedPalette(ac, generation, rawQuery) { | |
| 588 | 588 | d.invalidateRender(current, ac, true, "palette.go") | |
| @@ -681,7 +681,7 @@ type paletteExec struct { | |||
| 681 | 681 | ac *attachedClient | |
| 682 | 682 | routeSnapshot ports.RecentRouteSnapshot | |
| 683 | 683 | actions daemonActionRunner | |
| 684 | - | effect *attachmentEffectTicket | |
| 684 | + | effect *attachmentEffect | |
| 685 | 685 | redrawClosedPalette bool | |
| 686 | 686 | } | |
| 687 | 687 | ||
| @@ -719,14 +719,14 @@ func (e paletteExec) CreateSession() error { | |||
| 719 | 719 | if entry == nil { | |
| 720 | 720 | entry = e.sess | |
| 721 | 721 | } | |
| 722 | - | e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, token attachmentConnectionToken) error { | |
| 723 | - | if token.ac == nil { | |
| 722 | + | e.d.enterTransitionPrompt(entry, e.ac, " Create session ", "", func(name string, effect *attachmentEffect) error { | |
| 723 | + | if effect == nil || effect.ac == nil { | |
| 724 | 724 | if e.sess == nil { | |
| 725 | 725 | return errAttachmentTransition | |
| 726 | 726 | } | |
| 727 | 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 | 731 | return nil | |
| 732 | 732 | } | |
| @@ -735,14 +735,14 @@ func (e paletteExec) CreateSessionNamed(name string) error { | |||
| 735 | 735 | if e.effect == nil { | |
| 736 | 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 | 741 | func (e paletteExec) CreateEphemeralSession() error { | |
| 742 | 742 | if e.effect == nil { | |
| 743 | 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 | 748 | func (e paletteExec) CloseTab() error { | |
| @@ -839,7 +839,7 @@ func (e paletteExec) ToggleFloatingPane() error { | |||
| 839 | 839 | ||
| 840 | 840 | func (e paletteExec) BackSession() error { | |
| 841 | 841 | if e.effect != nil { | |
| 842 | - | return e.d.backSessionForAttachment(e.effect.connectionToken()) | |
| 842 | + | return e.d.backSessionForAttachment(e.effect) | |
| 843 | 843 | } | |
| 844 | 844 | e.d.backSession(e.sess, e.ac) | |
| 845 | 845 | return nil | |
| @@ -847,7 +847,7 @@ func (e paletteExec) BackSession() error { | |||
| 847 | 847 | ||
| 848 | 848 | func (e paletteExec) Detach() error { | |
| 849 | 849 | if e.effect != nil { | |
| 850 | - | if !e.d.clientGoneForAttachment(e.effect.connectionToken(), true) { | |
| 850 | + | if !e.d.clientGoneForAttachment(e.effect, true) { | |
| 851 | 851 | return errAttachmentTransition | |
| 852 | 852 | } | |
| 853 | 853 | return nil | |
| @@ -912,7 +912,7 @@ func (e paletteExec) RenameTabTo(name string) error { | |||
| 912 | 912 | ||
| 913 | 913 | func (e paletteExec) OpenSessionPicker() error { | |
| 914 | 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 | 917 | e.d.enterPicker(e.sess, e.ac) | |
| 918 | 918 | return nil | |
| @@ -957,12 +957,18 @@ func (e paletteExec) NavigateRecentRoute(action ports.RouteNavigationAction) err | |||
| 957 | 957 | e.d.beforeRecentSessionHandoff() | |
| 958 | 958 | } | |
| 959 | 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 | 962 | if e.ac == nil || e.attachment == nil { | |
| 963 | 963 | 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 | 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 | 36 | t.Helper() | |
| 37 | 37 | transport := ac.transport() | |
| 38 | 38 | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 39 | - | token := sess.attachmentToken(ac, transport) | |
| 39 | + | token := sess.captureAttachmentCapability(ac, transport) | |
| 40 | 40 | token.lease = rc.attachmentLease(ac) | |
| 41 | - | ac.publishAttachmentCapability(token) | |
| 41 | + | ac.installTestAttachmentCapability(token) | |
| 42 | 42 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 43 | 43 | require.True(t, admitted) | |
| 44 | 44 | t.Cleanup(effect.End) | |
| 45 | - | token.effect = effect | |
| 46 | - | return token | |
| 45 | + | return effect | |
| 47 | 46 | } | |
| 48 | 47 | ||
| 49 | 48 | func TestCaptureOverlayLayersPreservesPaletteDescriptionSurfaceAcrossFallbacks(t *testing.T) { | |
| @@ -355,14 +354,14 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) { | |||
| 355 | 354 | ||
| 356 | 355 | d.handleInput(current, ac, []byte("\x1b ")) | |
| 357 | 356 | awaitFrame(t, sends, ports.MsgOutput) | |
| 358 | - | generation := ac.connectionGeneration.Load() | |
| 357 | + | generation := ac.lifecycle.generationValue() | |
| 359 | 358 | d.handleInput(current, ac, []byte("stopped\r")) | |
| 360 | 359 | ||
| 361 | 360 | resumed := ac.currentSession() | |
| 362 | 361 | require.Equal(t, "stopped", resumed.name) | |
| 363 | 362 | require.Equal(t, int64(42), resumed.createdAt) | |
| 364 | 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 | 365 | require.False(t, ac.overlays.paletteActive()) | |
| 367 | 366 | firstPaint := awaitFrame(t, sends, ports.MsgOutput) | |
| 368 | 367 | firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload) | |
| @@ -1004,7 +1003,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||
| 1004 | 1003 | require.True(t, ac.overlays.promptActive()) | |
| 1005 | 1004 | require.Contains(t, string(promptOutput.Data), "Create session") | |
| 1006 | 1005 | ||
| 1007 | - | generation := ac.connectionGeneration.Load() | |
| 1006 | + | generation := ac.lifecycle.generationValue() | |
| 1008 | 1007 | d.handleInput(sess, ac, []byte("scratch\r")) | |
| 1009 | 1008 | // The submit first paints the newly attached session while the prompt is | |
| 1010 | 1009 | // still open, then handlePromptInput closes the prompt and repaints the | |
| @@ -1026,7 +1025,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||
| 1026 | 1025 | require.False(t, newSess.ephemeral) | |
| 1027 | 1026 | require.Contains(t, newSess.snapshotAttachments(), ac) | |
| 1028 | 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 | 1029 | require.Contains(t, string(finalOutput.Data), "scratch") | |
| 1031 | 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 | 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 | 352 | if tb == nil { | |
| 353 | 353 | return layout.ErrNotFound | |
| 354 | 354 | } | |
| @@ -406,7 +406,7 @@ func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.Pan | |||
| 406 | 406 | 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 | 410 | target := resolveDaemonActionTargetForAttachment(sess, ac) | |
| 411 | 411 | oldFocus := layout.PaneID("") | |
| 412 | 412 | if target.pane != nil { | |
| @@ -434,7 +434,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 434 | 434 | return errNoNeighbor | |
| 435 | 435 | } | |
| 436 | 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 | 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) { | |
| 54 | - | ac := token.ac | |
| 55 | - | if ac == nil || !token.attachmentCurrent() || ac.navigationCapabilities&ports.NavigationCapabilityHomePicker == 0 { | |
| 53 | + | func (d *Daemon) armParkedRoute(effect *attachmentEffect) (ports.ParkedRouteLeaseID, error) { | |
| 54 | + | if !effect.current() { | |
| 55 | + | return ports.ParkedRouteLeaseID{}, errAttachmentTransition | |
| 56 | + | } | |
| 57 | + | ac := effect.ac | |
| 58 | + | if ac == nil || ac.navigationCapabilities&ports.NavigationCapabilityHomePicker == 0 { | |
| 56 | 59 | return ports.ParkedRouteLeaseID{}, errAttachmentTransition | |
| 57 | 60 | } | |
| 58 | 61 | id, err := newParkedRouteLeaseID() | |
| @@ -60,7 +63,7 @@ func (d *Daemon) armParkedRoute(token attachmentConnectionToken) (ports.ParkedRo | |||
| 60 | 63 | return ports.ParkedRouteLeaseID{}, err | |
| 61 | 64 | } | |
| 62 | 65 | lease := &parkedRouteLease{ | |
| 63 | - | id: id, generation: token.generation, transport: token.transport, | |
| 66 | + | id: id, generation: effect.generation, transport: effect.transport, | |
| 64 | 67 | expiresAt: d.clock.Now().Add(parkedRouteLeaseTTL), | |
| 65 | 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 | 106 | ac.parkedRouteOutput.Store(false) | |
| 104 | 107 | ac.parkedRouteFullPending.Store(false) | |
| 105 | 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 | 110 | _ = ac.closeCapturedTransport(lease.transport.transport) | |
| 108 | 111 | } | |
| 109 | 112 | } | |
| @@ -120,8 +123,8 @@ func (ac *attachedClient) clearParkedRoute() { | |||
| 120 | 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 { | |
| 124 | - | if ac == nil || !token.attachmentCurrent() { | |
| 126 | + | func (ac *attachedClient) parkedRouteStatus(token attachmentCapability, id ports.ParkedRouteLeaseID, now time.Time, suspended bool) ports.ParkedRouteStatus { | |
| 127 | + | if ac == nil || !token.current() { | |
| 125 | 128 | return ports.ParkedRouteRejected | |
| 126 | 129 | } | |
| 127 | 130 | ac.parkedRouteMu.Lock() | |
| @@ -143,7 +146,7 @@ func (ac *attachedClient) parkedRouteStatus(token attachmentConnectionToken, id | |||
| 143 | 146 | 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 | 150 | if status := ac.parkedRouteStatus(token, id, now, false); status != 0 { | |
| 148 | 151 | return status | |
| 149 | 152 | } | |
| @@ -158,7 +161,7 @@ func (ac *attachedClient) prepareParkedRoute(token attachmentConnectionToken, id | |||
| 158 | 161 | 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 | 165 | if status := ac.parkedRouteStatus(token, id, now, true); status != 0 { | |
| 163 | 166 | return status | |
| 164 | 167 | } | |
| @@ -173,8 +176,8 @@ func (ac *attachedClient) consumeParkedRoute(token attachmentConnectionToken, id | |||
| 173 | 176 | return 0 | |
| 174 | 177 | } | |
| 175 | 178 | ||
| 176 | - | func (ac *attachedClient) beginParkedRouteSwitch(token attachmentConnectionToken, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus { | |
| 177 | - | if ac == nil || !token.attachmentCurrent() { | |
| 179 | + | func (ac *attachedClient) beginParkedRouteSwitch(token attachmentCapability, id ports.ParkedRouteLeaseID, now time.Time) ports.ParkedRouteStatus { | |
| 180 | + | if ac == nil || !token.current() { | |
| 178 | 181 | return ports.ParkedRouteRejected | |
| 179 | 182 | } | |
| 180 | 183 | ac.parkedRouteMu.Lock() | |
| @@ -194,7 +197,7 @@ func (ac *attachedClient) beginParkedRouteSwitch(token attachmentConnectionToken | |||
| 194 | 197 | 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 | 201 | if ac == nil { | |
| 199 | 202 | return ports.ParkedRouteRejected | |
| 200 | 203 | } | |
| @@ -215,8 +218,8 @@ func (ac *attachedClient) rejectParkedRouteSwitch(token attachmentConnectionToke | |||
| 215 | 218 | return 0 | |
| 216 | 219 | } | |
| 217 | 220 | ||
| 218 | - | func (ac *attachedClient) consumePublishedParkedRoute(previous, published attachmentConnectionToken, id ports.ParkedRouteLeaseID) ports.ParkedRouteStatus { | |
| 219 | - | if ac == nil || published.ac != ac || !published.attachmentCurrent() { | |
| 221 | + | func (ac *attachedClient) consumePublishedParkedRoute(previous, published attachmentCapability, id ports.ParkedRouteLeaseID) ports.ParkedRouteStatus { | |
| 222 | + | if ac == nil || published.ac != ac || !published.current() { | |
| 220 | 223 | return ports.ParkedRouteRejected | |
| 221 | 224 | } | |
| 222 | 225 | ac.parkedRouteMu.Lock() | |
| @@ -241,12 +244,12 @@ func parkedRouteResponseFrame(response ports.ParkedRouteResponse) (ports.Frame, | |||
| 241 | 244 | 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 | 248 | frame, err := parkedRouteResponseFrame(response) | |
| 246 | 249 | if err != nil { | |
| 247 | 250 | return err | |
| 248 | 251 | } | |
| 249 | - | return token.sendControl(frame) | |
| 252 | + | return effect.sendControl(frame) | |
| 250 | 253 | } | |
| 251 | 254 | ||
| 252 | 255 | // sendParkedRouteResponseLocked preserves response -> full-output ordering. | |
| @@ -270,77 +273,98 @@ func (ac *attachedClient) releaseParkedOutputLocked() { | |||
| 270 | 273 | ac.parkedRouteOutput.Store(false) | |
| 271 | 274 | } | |
| 272 | 275 | ||
| 273 | - | func (d *Daemon) parkedRouteResponseFailed(token attachmentConnectionToken) { | |
| 274 | - | if token.effect == nil || token.effect.ended.Load() { | |
| 276 | + | func (d *Daemon) parkedRouteResponseFailed(effect *attachmentEffect) { | |
| 277 | + | if effect == nil { | |
| 275 | 278 | return | |
| 276 | 279 | } | |
| 277 | - | launch := d.reserveAttachmentSendErrorCleanup(token, token.transport.transport) | |
| 278 | - | token.effect.End() | |
| 280 | + | capability := effect.capability() | |
| 281 | + | launch := d.reserveAttachmentSendErrorCleanup(capability, capability.transport.transport) | |
| 282 | + | effect.End() | |
| 279 | 283 | launch() | |
| 280 | 284 | } | |
| 281 | 285 | ||
| 282 | - | func (d *Daemon) respondParkedRoute(token attachmentConnectionToken, response ports.ParkedRouteResponse) bool { | |
| 283 | - | if err := d.sendParkedRouteResponse(token, response); err != nil { | |
| 284 | - | d.parkedRouteResponseFailed(token) | |
| 286 | + | func (d *Daemon) respondParkedRoute(effect *attachmentEffect, response ports.ParkedRouteResponse) bool { | |
| 287 | + | if effect == nil || effect.ac == nil { | |
| 288 | + | return false | |
| 289 | + | } | |
| 290 | + | sender := effect | |
| 291 | + | owned := false | |
| 292 | + | if !sender.current() { | |
| 293 | + | var admitted bool | |
| 294 | + | sender, admitted = effect.ac.beginAttachmentEffect(effect.capability()) | |
| 295 | + | if !admitted { | |
| 296 | + | return false | |
| 297 | + | } | |
| 298 | + | owned = true | |
| 299 | + | } | |
| 300 | + | if owned { | |
| 301 | + | defer sender.End() | |
| 302 | + | } | |
| 303 | + | if err := d.sendParkedRouteResponse(sender, response); err != nil { | |
| 304 | + | d.parkedRouteResponseFailed(sender) | |
| 285 | 305 | return false | |
| 286 | 306 | } | |
| 287 | 307 | return true | |
| 288 | 308 | } | |
| 289 | 309 | ||
| 290 | - | func (d *Daemon) handleParkedRouteRequest(token attachmentConnectionToken, request ports.ParkedRouteRequest) { | |
| 291 | - | if ports.ValidateParkedRouteRequest(request) != nil || token.ac == nil { | |
| 310 | + | func (d *Daemon) handleParkedRouteRequest(effect *attachmentEffect, request ports.ParkedRouteRequest) { | |
| 311 | + | if ports.ValidateParkedRouteRequest(request) != nil || !effect.current() || effect.ac == nil { | |
| 292 | 312 | return | |
| 293 | 313 | } | |
| 314 | + | capability := effect.capability() | |
| 294 | 315 | switch request.Action { | |
| 295 | 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 | 318 | if status == 0 { | |
| 298 | 319 | 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 | 322 | case ports.ParkedRouteResume: | |
| 302 | - | d.resumeParkedRoute(token, request) | |
| 323 | + | d.resumeParkedRoute(effect, request) | |
| 303 | 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) { | |
| 309 | - | status := token.ac.consumeParkedRoute(token, request.LeaseID, d.clock.Now()) | |
| 329 | + | func (d *Daemon) resumeParkedRoute(effect *attachmentEffect, request ports.ParkedRouteRequest) { | |
| 330 | + | capability := effect.capability() | |
| 331 | + | status := effect.ac.consumeParkedRoute(capability, request.LeaseID, d.clock.Now()) | |
| 310 | 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 | 334 | return | |
| 313 | 335 | } | |
| 314 | - | ac := token.ac | |
| 336 | + | ac := effect.ac | |
| 315 | 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 | 339 | if err == nil { | |
| 318 | 340 | ac.releaseParkedOutputLocked() | |
| 319 | 341 | } | |
| 320 | 342 | ac.sendMu.Unlock() | |
| 321 | 343 | if err != nil { | |
| 322 | - | d.parkedRouteResponseFailed(token) | |
| 344 | + | d.parkedRouteResponseFailed(effect) | |
| 323 | 345 | return | |
| 324 | 346 | } | |
| 325 | - | if token.sess != nil { | |
| 326 | - | d.firstPaintWithLease(token.sess, ac, token.lease, true) | |
| 347 | + | if effect.sess != nil { | |
| 348 | + | d.firstPaintWithLease(effect.sess, ac, effect.lease, true) | |
| 327 | 349 | } | |
| 328 | 350 | } | |
| 329 | 351 | ||
| 330 | - | func (d *Daemon) rejectParkedRouteSwitch(token attachmentConnectionToken, request ports.ParkedRouteRequest, fallback ports.ParkedRouteStatus) { | |
| 331 | - | status := token.ac.rejectParkedRouteSwitch(token, request.LeaseID, d.clock.Now()) | |
| 352 | + | func (d *Daemon) rejectParkedRouteSwitch(effect *attachmentEffect, request ports.ParkedRouteRequest, fallback ports.ParkedRouteStatus) { | |
| 353 | + | capability := effect.capability() | |
| 354 | + | status := effect.ac.rejectParkedRouteSwitch(capability, request.LeaseID, d.clock.Now()) | |
| 332 | 355 | if status == 0 { | |
| 333 | 356 | status = fallback | |
| 334 | 357 | } | |
| 335 | - | if d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired { | |
| 336 | - | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 358 | + | if d.respondParkedRoute(effect, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired { | |
| 359 | + | _ = effect.ac.closeCapturedTransport(effect.transport.transport) | |
| 337 | 360 | } | |
| 338 | 361 | } | |
| 339 | 362 | ||
| 340 | - | func (d *Daemon) switchParkedRoute(token attachmentConnectionToken, request ports.ParkedRouteRequest) { | |
| 341 | - | if status := token.ac.beginParkedRouteSwitch(token, request.LeaseID, d.clock.Now()); status != 0 { | |
| 342 | - | if d.respondParkedRoute(token, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired { | |
| 343 | - | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 363 | + | func (d *Daemon) switchParkedRoute(effect *attachmentEffect, request ports.ParkedRouteRequest) { | |
| 364 | + | capability := effect.capability() | |
| 365 | + | if status := effect.ac.beginParkedRouteSwitch(capability, request.LeaseID, d.clock.Now()); status != 0 { | |
| 366 | + | if d.respondParkedRoute(effect, ports.ParkedRouteResponse{RequestID: request.RequestID, Status: status}) && status == ports.ParkedRouteExpired { | |
| 367 | + | _ = effect.ac.closeCapturedTransport(effect.transport.transport) | |
| 344 | 368 | } | |
| 345 | 369 | return | |
| 346 | 370 | } | |
| @@ -350,34 +374,34 @@ func (d *Daemon) switchParkedRoute(token attachmentConnectionToken, request port | |||
| 350 | 374 | ctx = context.Background() | |
| 351 | 375 | } | |
| 352 | 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 | 378 | return | |
| 355 | 379 | } | |
| 356 | 380 | ||
| 357 | - | transition, targetSession, err := d.transitionParkedRouteTarget(token, target) | |
| 381 | + | transition, targetSession, err := d.transitionParkedRouteTarget(effect, target) | |
| 358 | 382 | if err != nil { | |
| 359 | - | d.rejectParkedRouteSwitch(token, request, ports.ParkedRouteStaleTarget) | |
| 383 | + | d.rejectParkedRouteSwitch(effect, request, ports.ParkedRouteStaleTarget) | |
| 360 | 384 | 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 | 387 | d.abortPublishedAttachmentTransition(transition) | |
| 364 | 388 | return | |
| 365 | 389 | } | |
| 366 | - | fresh, admitted := token.ac.beginAttachmentEffect(transition.published) | |
| 390 | + | fresh, admitted := effect.ac.beginAttachmentEffect(transition.published) | |
| 367 | 391 | if !admitted { | |
| 368 | 392 | d.abortPublishedAttachmentTransition(transition) | |
| 369 | 393 | return | |
| 370 | 394 | } | |
| 371 | - | freshToken := fresh.connectionToken() | |
| 372 | - | ac := token.ac | |
| 395 | + | freshCapability := fresh.capability() | |
| 396 | + | ac := effect.ac | |
| 373 | 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 | 399 | if responseErr == nil { | |
| 376 | 400 | ac.releaseParkedOutputLocked() | |
| 377 | 401 | } | |
| 378 | 402 | ac.sendMu.Unlock() | |
| 379 | 403 | if responseErr != nil { | |
| 380 | - | launch := d.reserveAttachmentSendErrorCleanup(freshToken, freshToken.transport.transport) | |
| 404 | + | launch := d.reserveAttachmentSendErrorCleanup(freshCapability, freshCapability.transport.transport) | |
| 381 | 405 | fresh.End() | |
| 382 | 406 | launch() | |
| 383 | 407 | d.deferAttachmentTransitionCleanups(transition) | |
| @@ -409,7 +433,8 @@ func (d *Daemon) waitForParkedTargetRestore(parent context.Context, name string) | |||
| 409 | 433 | 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 | 438 | d.mu.Lock() | |
| 414 | 439 | if d.closing { | |
| 415 | 440 | d.mu.Unlock() | |
| @@ -430,8 +455,8 @@ func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, ta | |||
| 430 | 455 | live.mu.Unlock() | |
| 431 | 456 | d.mu.Unlock() | |
| 432 | 457 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 433 | - | source: token.sess, target: live, next: token.ac, | |
| 434 | - | expectedTransport: token.transport, sourceToken: &token, action: "parked-route-switch", | |
| 458 | + | source: effect.sess, target: live, next: effect.ac, | |
| 459 | + | expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: "parked-route-switch", | |
| 435 | 460 | expectedTargetLifecycle: &attachmentLifecycleFence{ | |
| 436 | 461 | name: target.SessionName, incarnation: target.LifecycleID, checkIncarnation: true, | |
| 437 | 462 | tabID: tabID, tabIndex: tabIndex, checkTab: true, | |
| @@ -455,8 +480,8 @@ func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, ta | |||
| 455 | 480 | ||
| 456 | 481 | var created *session | |
| 457 | 482 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 458 | - | source: token.sess, next: token.ac, | |
| 459 | - | expectedTransport: token.transport, sourceToken: &token, action: "parked-route-restore", | |
| 483 | + | source: effect.sess, next: effect.ac, | |
| 484 | + | expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: "parked-route-restore", | |
| 460 | 485 | activateTargetTab: true, targetTabIndex: tabIndex, ready: true, | |
| 461 | 486 | createTargetLocked: func() (*session, error) { | |
| 462 | 487 | current, ok := d.inactive[target.SessionName] | |
| @@ -470,7 +495,7 @@ func (d *Daemon) transitionParkedRouteTarget(token attachmentConnectionToken, ta | |||
| 470 | 495 | env := copyEnvironment(d.baseEnv) | |
| 471 | 496 | cwd := d.dirOrHome(current.cwd) | |
| 472 | 497 | 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 | 499 | 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 | 64 | func armAndPrepareParkedRoute(t *testing.T, d *Daemon, source *session, ac *attachedClient, sends chan ports.Frame) ports.ParkedRouteLeaseID { | |
| 65 | 65 | t.Helper() | |
| 66 | 66 | ac.navigationCapabilities |= ports.NavigationCapabilityHomePicker | |
| 67 | - | token := source.attachmentToken(ac, ac.transport()) | |
| 67 | + | token := source.captureAttachmentCapability(ac, ac.transport()) | |
| 68 | 68 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 69 | 69 | require.True(t, admitted) | |
| 70 | - | token.effect = effect | |
| 71 | - | require.NoError(t, d.sendNavigationActionForAttachment(token, ports.NavigationOpenHomePicker)) | |
| 70 | + | require.NoError(t, d.sendNavigationActionForAttachment(effect, ports.NavigationOpenHomePicker)) | |
| 72 | 71 | effect.End() | |
| 73 | 72 | directiveFrame := awaitFrame(t, sends, ports.MsgNavigationAction) | |
| 74 | 73 | directive, err := ports.UnmarshalNavigationDirective(directiveFrame.Payload) | |
| 75 | 74 | require.NoError(t, err) | |
| 76 | 75 | ||
| 77 | 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 | 78 | readyFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse) | |
| 80 | 79 | ready, err := ports.UnmarshalParkedRouteResponse(readyFrame.Payload) | |
| 81 | 80 | require.NoError(t, err) | |
| @@ -108,7 +107,7 @@ func TestParkedRouteSwitchesExactLiveTargetOnRetainedTransport(t *testing.T) { | |||
| 108 | 107 | SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID), | |
| 109 | 108 | } | |
| 110 | 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 | 112 | require.Same(t, target, ac.currentAttachmentSession()) | |
| 114 | 113 | require.False(t, ac.parkedRouteOutput.Load()) | |
| @@ -147,7 +146,7 @@ func TestParkedRouteSwitchAcceptedBeforeLeaseExpiryCommitsAtomically(t *testing. | |||
| 147 | 146 | SessionName: "target", LiveTabID: domain.TabStableID(target.tabs[0].stableID), | |
| 148 | 147 | } | |
| 149 | 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 | 151 | require.Same(t, target, ac.currentAttachmentSession()) | |
| 153 | 152 | responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse) | |
| @@ -185,7 +184,7 @@ func TestParkedRouteRestoresExactStoppedTargetWithDaemonEnvironment(t *testing.T | |||
| 185 | 184 | SessionName: "stopped", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-b"), | |
| 186 | 185 | } | |
| 187 | 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 | 189 | restored := ac.currentAttachmentSession() | |
| 191 | 190 | require.NotNil(t, restored) | |
| @@ -210,7 +209,7 @@ func TestParkedRouteStaleTargetLeavesSourceResumable(t *testing.T) { | |||
| 210 | 209 | SessionName: "missing", LiveTabID: "tab-1", | |
| 211 | 210 | } | |
| 212 | 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 | 213 | staleFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse) | |
| 215 | 214 | stale, err := ports.UnmarshalParkedRouteResponse(staleFrame.Payload) | |
| 216 | 215 | require.NoError(t, err) | |
| @@ -219,7 +218,7 @@ func TestParkedRouteStaleTargetLeavesSourceResumable(t *testing.T) { | |||
| 219 | 218 | require.True(t, ac.parkedRouteOutput.Load()) | |
| 220 | 219 | ||
| 221 | 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 | 222 | resumedFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse) | |
| 224 | 223 | resumed, err := ports.UnmarshalParkedRouteResponse(resumedFrame.Payload) | |
| 225 | 224 | require.NoError(t, err) | |
| @@ -245,7 +244,7 @@ func TestParkedRouteDoesNotReplayOneShotEffectsAfterResume(t *testing.T) { | |||
| 245 | 244 | } | |
| 246 | 245 | ||
| 247 | 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 | 248 | _ = awaitFrame(t, sends, ports.MsgParkedRouteResponse) | |
| 250 | 249 | outputFrame := awaitFrame(t, sends, ports.MsgOutput) | |
| 251 | 250 | output, err := ports.UnmarshalOutput(outputFrame.Payload) | |
| @@ -270,7 +269,7 @@ func TestParkedRouteExpiredLeaseFailsClosed(t *testing.T) { | |||
| 270 | 269 | ac.parkedRoute.expiresAt = d.clock.Now() | |
| 271 | 270 | ac.parkedRouteMu.Unlock() | |
| 272 | 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 | 273 | responseFrame := awaitFrame(t, sends, ports.MsgParkedRouteResponse) | |
| 275 | 274 | response, err := ports.UnmarshalParkedRouteResponse(responseFrame.Payload) | |
| 276 | 275 | require.NoError(t, err) | |
| @@ -288,7 +287,7 @@ func TestParkedRouteResumeRejectsSwitchInFlight(t *testing.T) { | |||
| 288 | 287 | ac.parkedRouteMu.Lock() | |
| 289 | 288 | ac.parkedRoute.inFlight = true | |
| 290 | 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 | 292 | require.Equal(t, ports.ParkedRouteRejected, status) | |
| 294 | 293 | ac.parkedRouteMu.Lock() | |
| @@ -335,7 +334,11 @@ func TestParkedRouteMalformedSwitchRequestsFailClosed(t *testing.T) { | |||
| 335 | 334 | {RequestID: 2, Action: ports.ParkedRouteSwitch, Target: &domain.RemoteSessionTarget{}}, | |
| 336 | 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 | 343 | require.Same(t, source, ac.currentAttachmentSession()) | |
| 341 | 344 | select { | |
| @@ -349,14 +352,13 @@ func TestParkedRouteResponseFailureDoesNotDrainItsOwnFrameEffect(t *testing.T) { | |||
| 349 | 352 | d, source, ac, _, releases := newManualTabSession(t, 1) | |
| 350 | 353 | defer releaseAll(releases) | |
| 351 | 354 | ac.replaceTransport(parkedRouteFailTransport{}) | |
| 352 | - | token := source.attachmentToken(ac, ac.transport()) | |
| 355 | + | token := source.captureAttachmentCapability(ac, ac.transport()) | |
| 353 | 356 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 354 | 357 | require.True(t, admitted) | |
| 355 | - | token.effect = effect | |
| 356 | 358 | ||
| 357 | 359 | result := make(chan bool, 1) | |
| 358 | 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 | 363 | require.False(t, awaitTestValue(t, result, "parked-route send failure cleanup")) | |
| 362 | 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 | 295 | sess := ac.currentAttachmentSession() | |
| 296 | 296 | if sess == nil { | |
| 297 | 297 | return | |
| @@ -344,7 +344,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 344 | 344 | return | |
| 345 | 345 | } | |
| 346 | 346 | if result.action == 'x' { | |
| 347 | - | var effect *attachmentEffectTicket | |
| 347 | + | var effect *attachmentEffect | |
| 348 | 348 | if len(effects) != 0 { | |
| 349 | 349 | effect = effects[0] | |
| 350 | 350 | } | |
| @@ -352,7 +352,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 352 | 352 | var err error | |
| 353 | 353 | if effect != nil { | |
| 354 | 354 | effect.bindActionEnd(d, "picker-delete") | |
| 355 | - | err = d.killPickerTargetForAttachment(target, effect.connectionToken()) | |
| 355 | + | err = d.killPickerTargetForAttachment(target, effect) | |
| 356 | 356 | } else { | |
| 357 | 357 | err = d.killPickerTarget(target) | |
| 358 | 358 | } | |
| @@ -360,8 +360,8 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 360 | 360 | d.reportAttachmentError(sess, err) | |
| 361 | 361 | } | |
| 362 | 362 | } | |
| 363 | - | if effect != nil && effect.ended.Load() { | |
| 364 | - | fresh, admitted := ac.beginAttachmentEffect(effect.token) | |
| 363 | + | if effect != nil && !effect.current() { | |
| 364 | + | fresh, admitted := ac.beginAttachmentEffect(effect.capability()) | |
| 365 | 365 | if !admitted { | |
| 366 | 366 | return | |
| 367 | 367 | } | |
| @@ -379,20 +379,12 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 379 | 379 | if result.exit && !committing { | |
| 380 | 380 | back := ac.startupOverlay == ports.StartupOverlaySessionPicker && ac.navigationCapabilities&ports.NavigationCapabilityBack != 0 | |
| 381 | 381 | if back { | |
| 382 | - | var token attachmentConnectionToken | |
| 383 | - | if len(effects) != 0 && effects[0] != nil { | |
| 384 | - | token = effects[0].connectionToken() | |
| 385 | - | } else { | |
| 386 | - | var effect *attachmentEffectTicket | |
| 387 | - | var admitted bool | |
| 388 | - | token, effect, admitted = ac.beginCurrentAttachmentEffect(sess, ac.transport()) | |
| 389 | - | if !admitted { | |
| 390 | - | d.invalidateRender(sess, ac, true, "picker.go") | |
| 391 | - | return | |
| 392 | - | } | |
| 393 | - | defer effect.End() | |
| 382 | + | if len(effects) == 0 || effects[0] == nil { | |
| 383 | + | d.invalidateRender(sess, ac, true, "picker.go") | |
| 384 | + | return | |
| 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 | 388 | d.reportAttachmentError(sess, err) | |
| 397 | 389 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 398 | 390 | return | |
| @@ -423,7 +415,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...* | |||
| 423 | 415 | } | |
| 424 | 416 | var err error | |
| 425 | 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 | 419 | } else { | |
| 428 | 420 | d.closePicker(ac) | |
| 429 | 421 | err = d.switchToTarget(sess, ac, target) | |
| @@ -793,8 +785,8 @@ type sessionHandoffGuard struct { | |||
| 793 | 785 | // centralized transition. The transition releases admission at its freeze seam | |
| 794 | 786 | // and revalidates the exact initiating token before changing target focus or | |
| 795 | 787 | // attachment ownership. | |
| 796 | - | func (d *Daemon) switchActiveTargetForAttachment(token attachmentConnectionToken, target picker.Target) error { | |
| 797 | - | return d.switchActiveTargetForAttachmentGuarded(token, target, sessionHandoffGuard{}, "jump-attention") | |
| 788 | + | func (d *Daemon) switchActiveTargetForAttachment(effect *attachmentEffect, target picker.Target) error { | |
| 789 | + | return d.switchActiveTargetForAttachmentGuarded(effect, target, sessionHandoffGuard{}, "jump-attention") | |
| 798 | 790 | } | |
| 799 | 791 | ||
| 800 | 792 | func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence { | |
| @@ -819,43 +811,44 @@ func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence | |||
| 819 | 811 | return fence | |
| 820 | 812 | } | |
| 821 | 813 | ||
| 822 | - | func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error { | |
| 823 | - | if token.sess == nil || token.ac == nil { | |
| 814 | + | func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, action string) error { | |
| 815 | + | if !effect.current() || effect.sess == nil || effect.ac == nil { | |
| 824 | 816 | return nil | |
| 825 | 817 | } | |
| 826 | 818 | d.mu.Lock() | |
| 827 | 819 | targetSess := d.sessions[target.Session] | |
| 828 | 820 | d.mu.Unlock() | |
| 829 | 821 | if targetSess == nil { | |
| 830 | - | if !token.attachmentCurrent() { | |
| 822 | + | if !effect.current() { | |
| 831 | 823 | 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 | 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 | 829 | return nil | |
| 838 | 830 | } | |
| 839 | 831 | ||
| 832 | + | capability := effect.capability() | |
| 840 | 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 | 837 | expectedTargetLifecycle: pickerTargetLifecycleFence(target), | |
| 845 | 838 | activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true, | |
| 846 | 839 | }) | |
| 847 | 840 | if err != nil { | |
| 848 | 841 | // Losing the exact source role is a benign stale action, not a notice for | |
| 849 | 842 | // whichever attachment replaced the initiator. | |
| 850 | - | if !token.attachmentCurrent() { | |
| 843 | + | if !capability.current() { | |
| 851 | 844 | return nil | |
| 852 | 845 | } | |
| 853 | 846 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | |
| 854 | 847 | } | |
| 855 | 848 | if guard.closePicker { | |
| 856 | - | fresh, admitted := token.ac.beginAttachmentEffect(transition.published) | |
| 849 | + | fresh, admitted := effect.ac.beginAttachmentEffect(transition.published) | |
| 857 | 850 | if admitted { | |
| 858 | - | d.closePicker(token.ac) | |
| 851 | + | d.closePicker(effect.ac) | |
| 859 | 852 | fresh.End() | |
| 860 | 853 | } | |
| 861 | 854 | } | |
| @@ -876,21 +869,21 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker | |||
| 876 | 869 | // intent captures the exact initiating capability before admission is released; | |
| 877 | 870 | // every active, stopped, and same-session target then uses transitionAttachment | |
| 878 | 871 | // for frozen, atomic source-token preflight. | |
| 879 | - | func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error { | |
| 880 | - | if token.sess == nil || token.ac == nil || token.effect == nil { | |
| 872 | + | func (d *Daemon) switchToTargetForAttachment(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, action string) error { | |
| 873 | + | if !effect.current() || effect.sess == nil || effect.ac == nil { | |
| 881 | 874 | return nil | |
| 882 | 875 | } | |
| 883 | 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 | 882 | // sendLocalAttachTargetForAttachment offers an endpoint-empty, exact local | |
| 890 | 883 | // target on the current authenticated connection. A current client confirms it | |
| 891 | 884 | // with MsgSamePeerSwitchRequest; an interrupted or older client retains the | |
| 892 | 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 | 887 | d.mu.Lock() | |
| 895 | 888 | var targetSess *session | |
| 896 | 889 | var sessionName string | |
| @@ -915,13 +908,13 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||
| 915 | 908 | targetSess.mu.Unlock() | |
| 916 | 909 | } | |
| 917 | 910 | d.mu.Unlock() | |
| 918 | - | if !matches || !token.attachmentCurrent() { | |
| 911 | + | if !matches || !effect.current() { | |
| 919 | 912 | return errAttachmentTransition | |
| 920 | 913 | } | |
| 921 | 914 | if targetSess != nil && target.TabID != "" { | |
| 922 | 915 | // An explicit tab row is a direct user choice, not route memory; retain | |
| 923 | 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 | 920 | // An active session row with no explicit non-default tab is on the | |
| @@ -943,15 +936,15 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||
| 943 | 936 | // same-peer transition keeps the attachment and its namespace; the target | |
| 944 | 937 | // scene diff deletes and replaces the source session's placements. | |
| 945 | 938 | if !samePeerEligible { | |
| 946 | - | if err := d.cleanupAttachmentOutput(token.ac); err != nil { | |
| 939 | + | if err := d.cleanupAttachmentOutput(effect.ac); err != nil { | |
| 947 | 940 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't clean up attachment output before local handoff", err) | |
| 948 | 941 | } | |
| 949 | 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 | 946 | if samePeerEligible { | |
| 954 | - | token.ac.clearSamePeerOffer() | |
| 947 | + | effect.ac.clearSamePeerOffer() | |
| 955 | 948 | } | |
| 956 | 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 | 954 | // Keep the source link open until the client receives the ordered cleanup | |
| 962 | 955 | // and handoff frames, then let the client's close drive ordinary parking. | |
| 963 | 956 | if guard.closePicker { | |
| 964 | - | d.closePicker(token.ac) | |
| 957 | + | d.closePicker(effect.ac) | |
| 965 | 958 | } | |
| 966 | - | token.effect.bindActionEnd(d, "detach") | |
| 967 | - | token.effect.End() | |
| 959 | + | effect.bindActionEnd(d, "detach") | |
| 960 | + | effect.End() | |
| 968 | 961 | } | |
| 969 | 962 | return nil | |
| 970 | 963 | } | |
| @@ -973,10 +966,10 @@ func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionTo | |||
| 973 | 966 | // endpoint to the thin client. The daemon owns no remote session shadow: after | |
| 974 | 967 | // the target is sent, the local attachment is detached and the client opens a | |
| 975 | 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 | 970 | failUnavailable := func() error { | |
| 978 | - | if token.attachmentCurrent() { | |
| 979 | - | d.notifyRemotePickerUnavailable(token.sess, target) | |
| 971 | + | if effect.current() { | |
| 972 | + | d.notifyRemotePickerUnavailable(effect.sess, target) | |
| 980 | 973 | } | |
| 981 | 974 | return errAttachmentTransition | |
| 982 | 975 | } | |
| @@ -999,13 +992,13 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionT | |||
| 999 | 992 | if payload == nil { | |
| 1000 | 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 | 996 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err) | |
| 1004 | 997 | } | |
| 1005 | 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 | 1002 | return nil | |
| 1010 | 1003 | } | |
| 1011 | 1004 | ||
| @@ -1054,14 +1047,19 @@ func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target | |||
| 1054 | 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 | 1056 | if target.Name != "" { | |
| 1059 | 1057 | ctx := d.serveCtx | |
| 1060 | 1058 | if ctx == nil { | |
| 1061 | 1059 | ctx = context.Background() | |
| 1062 | 1060 | } | |
| 1063 | 1061 | if err := d.waitForTargetRestore(ctx, target.Name); err != nil { | |
| 1064 | - | if sourceToken != nil && !sourceToken.attachmentCurrent() { | |
| 1062 | + | if sourceCapability != nil && !sourceCapability.current() { | |
| 1065 | 1063 | return errAttachmentTransition | |
| 1066 | 1064 | } | |
| 1067 | 1065 | d.invalidateRender(from, ac, true, "picker.go") | |
| @@ -1080,22 +1078,22 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC | |||
| 1080 | 1078 | active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target) | |
| 1081 | 1079 | if ok { | |
| 1082 | 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 | 1082 | } else { | |
| 1085 | 1083 | // The snapshot ID may describe the stopped representation. The | |
| 1086 | 1084 | // locked name/lifecycle resolver chose this active incarnation. | |
| 1087 | 1085 | resolvedTarget := target | |
| 1088 | 1086 | 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 | 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 | 1093 | d.mu.Unlock() | |
| 1096 | 1094 | ||
| 1097 | 1095 | if !switched { | |
| 1098 | - | if sourceToken != nil && !sourceToken.attachmentCurrent() { | |
| 1096 | + | if sourceCapability != nil && !sourceCapability.current() { | |
| 1099 | 1097 | return errAttachmentTransition | |
| 1100 | 1098 | } | |
| 1101 | 1099 | if guard.expectedSource != nil { | |
| @@ -1105,7 +1103,7 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC | |||
| 1105 | 1103 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause) | |
| 1106 | 1104 | } | |
| 1107 | 1105 | if guard.closePicker { | |
| 1108 | - | if sourceToken != nil { | |
| 1106 | + | if sourceEffect != nil { | |
| 1109 | 1107 | fresh, admitted := ac.beginAttachmentEffect(transition.published) | |
| 1110 | 1108 | if admitted { | |
| 1111 | 1109 | d.closePicker(ac) | |
| @@ -1117,7 +1115,7 @@ func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedC | |||
| 1117 | 1115 | } | |
| 1118 | 1116 | d.deferAttachmentTransitionCleanups(transition) | |
| 1119 | 1117 | if targetSess == from { | |
| 1120 | - | if sourceToken != nil { | |
| 1118 | + | if sourceEffect != nil { | |
| 1121 | 1119 | fresh, admitted := ac.beginAttachmentEffect(transition.published) | |
| 1122 | 1120 | if admitted { | |
| 1123 | 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 | 1155 | // switchToActiveTargetLocked commits an active target handoff through the | |
| 1158 | 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 | 1163 | if d.sessions[target.Session] != targetSess { | |
| 1161 | 1164 | return nil, attachmentTransitionResult{}, false | |
| 1162 | 1165 | } | |
| 1163 | 1166 | if targetSess == from { | |
| 1164 | - | if sourceToken == nil { | |
| 1167 | + | if sourceEffect == nil { | |
| 1165 | 1168 | targetSess.mu.Lock() | |
| 1166 | 1169 | defer targetSess.mu.Unlock() | |
| 1167 | 1170 | _, attached := targetSess.attachments[ac] | |
| @@ -1177,8 +1180,9 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 1177 | 1180 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1178 | 1181 | source: from, target: targetSess, next: ac, | |
| 1179 | 1182 | ||
| 1180 | - | expectedTransport: sourceToken.transport, | |
| 1181 | - | sourceToken: sourceToken, | |
| 1183 | + | expectedTransport: sourceCapability.transport, | |
| 1184 | + | sourceCapability: sourceCapability, | |
| 1185 | + | sourceEffect: sourceEffect, | |
| 1182 | 1186 | action: action, | |
| 1183 | 1187 | expectedTargetLifecycle: pickerTargetLifecycleFence(target), | |
| 1184 | 1188 | activateTargetTab: true, | |
| @@ -1215,8 +1219,8 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 1215 | 1219 | // d.mu and revalidates both registered lifecycles for publication. | |
| 1216 | 1220 | d.mu.Unlock() | |
| 1217 | 1221 | expectedTransport := ac.transportSnapshot() | |
| 1218 | - | if sourceToken != nil { | |
| 1219 | - | expectedTransport = sourceToken.transport | |
| 1222 | + | if sourceCapability != nil { | |
| 1223 | + | expectedTransport = sourceCapability.transport | |
| 1220 | 1224 | } | |
| 1221 | 1225 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1222 | 1226 | source: from, | |
| @@ -1224,7 +1228,8 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 1224 | 1228 | next: ac, | |
| 1225 | 1229 | ||
| 1226 | 1230 | expectedTransport: expectedTransport, | |
| 1227 | - | sourceToken: sourceToken, | |
| 1231 | + | sourceCapability: sourceCapability, | |
| 1232 | + | sourceEffect: sourceEffect, | |
| 1228 | 1233 | action: action, | |
| 1229 | 1234 | expectedTargetLifecycle: pickerTargetLifecycleFence(target), | |
| 1230 | 1235 | expectedSourceTab: guard.expectedSource, | |
| @@ -1244,7 +1249,7 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t | |||
| 1244 | 1249 | // resumeStoppedAndSwitchLocked creates the stopped representation and commits | |
| 1245 | 1250 | // the handoff while d.mu is held. Creation failure leaves the source client | |
| 1246 | 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 | 1253 | if stopped.broken() { | |
| 1249 | 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 | 1257 | return nil, attachmentTransitionResult{}, false, errAttachmentTransition | |
| 1253 | 1258 | } | |
| 1254 | 1259 | ||
| 1255 | - | if sourceToken != nil { | |
| 1260 | + | if sourceEffect != nil { | |
| 1261 | + | sourceCapability := sourceEffect.capability() | |
| 1256 | 1262 | var targetSess *session | |
| 1257 | 1263 | d.mu.Unlock() | |
| 1258 | 1264 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 1259 | 1265 | source: from, next: ac, | |
| 1260 | - | expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action, | |
| 1266 | + | expectedTransport: sourceCapability.transport, sourceCapability: &sourceCapability, sourceEffect: sourceEffect, action: action, | |
| 1261 | 1267 | expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true, | |
| 1262 | 1268 | createTargetLocked: func() (*session, error) { | |
| 1263 | 1269 | current, ok := d.inactive[target.Name] | |
| @@ -1359,19 +1365,20 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 1359 | 1365 | return true | |
| 1360 | 1366 | } | |
| 1361 | 1367 | ||
| 1362 | - | func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attachmentConnectionToken) error { | |
| 1363 | - | if token.ac == nil { | |
| 1368 | + | func (d *Daemon) killPickerTargetForAttachment(target picker.Target, effect *attachmentEffect) error { | |
| 1369 | + | if effect == nil || effect.ac == nil { | |
| 1364 | 1370 | return d.killPickerTarget(target) | |
| 1365 | 1371 | } | |
| 1366 | 1372 | if target.RemoteKey != nil { | |
| 1367 | 1373 | return nil | |
| 1368 | 1374 | } | |
| 1375 | + | capability := effect.capability() | |
| 1369 | 1376 | if target.Stopped { | |
| 1370 | - | token.effect.bindActionEnd(d, "picker-delete") | |
| 1371 | - | token.effect.End() | |
| 1372 | - | frozen := freezeAttachmentEffectGates(token.ac) | |
| 1377 | + | effect.bindActionEnd(d, "picker-delete") | |
| 1378 | + | effect.End() | |
| 1379 | + | frozen := freezeAttachmentEffectGates(effect.ac) | |
| 1373 | 1380 | defer frozen.unfreeze() | |
| 1374 | - | if !d.sourceAttachmentTokenCurrentFrozen(token) { | |
| 1381 | + | if !d.sourceAttachmentCapabilityCurrentFrozen(capability) { | |
| 1375 | 1382 | return nil | |
| 1376 | 1383 | } | |
| 1377 | 1384 | return d.killPickerTarget(target) | |
| @@ -1382,7 +1389,7 @@ func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attac | |||
| 1382 | 1389 | if targetSess == nil { | |
| 1383 | 1390 | 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 | 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 | 900 | ac.overlays.pickerMu.Unlock() | |
| 901 | 901 | ||
| 902 | 902 | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 903 | - | token := sess.attachmentToken(ac, tr) | |
| 903 | + | token := sess.captureAttachmentCapability(ac, tr) | |
| 904 | 904 | token.lease = rc.attachmentLease(ac) | |
| 905 | - | ac.publishAttachmentCapability(token) | |
| 905 | + | ac.installTestAttachmentCapability(token) | |
| 906 | 906 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 907 | 907 | require.True(t, admitted) | |
| 908 | 908 | defer effect.End() | |
| 909 | - | token.effect = effect | |
| 910 | 909 | ||
| 911 | 910 | d.handlePickerInput(ac, []byte("\r"), effect) | |
| 912 | 911 | require.True(t, ac.overlays.pickerActive(), "failed navigation send must leave the picker open") | |
| @@ -1063,7 +1062,7 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing | |||
| 1063 | 1062 | case <-time.After(2 * time.Second): | |
| 1064 | 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 | 1066 | select { | |
| 1068 | 1067 | case <-handoffAtSendMu: | |
| 1069 | 1068 | t.Fatal("output rebase ran during architecture publication") | |
| @@ -1584,9 +1583,9 @@ func TestPickerAttachmentEffectDeleteRemovesSelectedSessionAndRefreshes(t *testi | |||
| 1584 | 1583 | require.True(t, ok) | |
| 1585 | 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 | 1587 | token.lease = current.renderCoordinator().attachmentLease(ac) | |
| 1589 | - | ac.publishAttachmentCapability(token) | |
| 1588 | + | ac.installTestAttachmentCapability(token) | |
| 1590 | 1589 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 1591 | 1590 | require.True(t, admitted) | |
| 1592 | 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 | 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 | 27 | d.closePrompt(ac) | |
| 28 | 28 | ac.overlays.promptMu.Lock() | |
| 29 | 29 | ac.overlays.prompt = promptui.New(title, initial) | |
| @@ -50,7 +50,7 @@ func promptValidationError(err error) bool { | |||
| 50 | 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 | 54 | sess := ac.currentAttachmentSession() | |
| 55 | 55 | if sess == nil { | |
| 56 | 56 | return | |
| @@ -66,7 +66,7 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...* | |||
| 66 | 66 | changed := false | |
| 67 | 67 | exit := false | |
| 68 | 68 | var submit func(string) error | |
| 69 | - | var transitionSubmit func(string, attachmentConnectionToken) error | |
| 69 | + | var transitionSubmit func(string, *attachmentEffect) error | |
| 70 | 70 | var submittedPrompt *promptui.Model | |
| 71 | 71 | var value string | |
| 72 | 72 | ||
| @@ -94,11 +94,11 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte, effects ...* | |||
| 94 | 94 | if submit != nil || transitionSubmit != nil { | |
| 95 | 95 | var err error | |
| 96 | 96 | if transitionSubmit != nil { | |
| 97 | - | var token attachmentConnectionToken | |
| 98 | - | if len(effects) != 0 && effects[0] != nil { | |
| 99 | - | token = effects[0].connectionToken() | |
| 97 | + | var effect *attachmentEffect | |
| 98 | + | if len(effects) != 0 { | |
| 99 | + | effect = effects[0] | |
| 100 | 100 | } | |
| 101 | - | err = transitionSubmit(value, token) | |
| 101 | + | err = transitionSubmit(value, effect) | |
| 102 | 102 | } else { | |
| 103 | 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 | 65 | func TestRecentRouteSnapshotRepaintsWithoutDeferredIdentity(t *testing.T) { | |
| 66 | 66 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 67 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 68 | - | ac.publishAttachmentCapability(token) | |
| 67 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 68 | + | ac.installTestAttachmentCapability(token) | |
| 69 | 69 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |
| 70 | 70 | ||
| 71 | 71 | payload, err := ports.MarshalRecentRouteSnapshot(ports.RecentRouteSnapshot{Generation: 2}) | |
| @@ -93,13 +93,12 @@ func TestPaletteRecentRouteSelectionSendsTypedClientAction(t *testing.T) { | |||
| 93 | 93 | transport := &closeTrackingTransport{} | |
| 94 | 94 | ac.replaceTransport(transport) | |
| 95 | 95 | rc := d.attachCoordinator(source, nil, ac, true) | |
| 96 | - | token := source.attachmentToken(ac, transport) | |
| 96 | + | token := source.captureAttachmentCapability(ac, transport) | |
| 97 | 97 | token.lease = rc.attachmentLease(ac) | |
| 98 | - | ac.publishAttachmentCapability(token) | |
| 98 | + | ac.installTestAttachmentCapability(token) | |
| 99 | 99 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 100 | 100 | require.True(t, admitted) | |
| 101 | 101 | defer effect.End() | |
| 102 | - | token.effect = effect | |
| 103 | 102 | ||
| 104 | 103 | exec := paletteExec{ | |
| 105 | 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 | 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 | 106 | directive := ports.NavigationDirective{Action: action} | |
| 107 | 107 | armed := false | |
| 108 | 108 | if action == ports.NavigationOpenHomePicker { | |
| 109 | - | leaseID, err := d.armParkedRoute(token) | |
| 109 | + | leaseID, err := d.armParkedRoute(effect) | |
| 110 | 110 | if err != nil { | |
| 111 | 111 | return err | |
| 112 | 112 | } | |
| @@ -115,7 +115,7 @@ func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionTok | |||
| 115 | 115 | } | |
| 116 | 116 | rollback := func() { | |
| 117 | 117 | if armed { | |
| 118 | - | token.ac.clearParkedRoute() | |
| 118 | + | effect.ac.clearParkedRoute() | |
| 119 | 119 | } | |
| 120 | 120 | } | |
| 121 | 121 | payload := ports.MarshalNavigationDirective(directive) | |
| @@ -123,36 +123,36 @@ func (d *Daemon) sendNavigationActionForAttachment(token attachmentConnectionTok | |||
| 123 | 123 | rollback() | |
| 124 | 124 | 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 | 127 | rollback() | |
| 128 | 128 | return err | |
| 129 | 129 | } | |
| 130 | 130 | 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 | 134 | payload, err := ports.MarshalRouteNavigationAction(action) | |
| 135 | 135 | if err != nil { | |
| 136 | 136 | 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 { | |
| 142 | - | if token.sess == nil { | |
| 141 | + | func (d *Daemon) sendCommittedRouteIdentityForAttachment(effect *attachmentEffect) error { | |
| 142 | + | if !effect.current() || effect.sess == nil { | |
| 143 | 143 | return errAttachmentTransition | |
| 144 | 144 | } | |
| 145 | - | token.sess.mu.Lock() | |
| 145 | + | effect.sess.mu.Lock() | |
| 146 | 146 | identity := ports.CommittedRouteIdentity{ | |
| 147 | - | Target: ports.ExactSessionTarget{LifecycleID: token.sess.incarnation, SessionName: token.sess.name}, | |
| 148 | - | Ephemeral: token.sess.ephemeral, | |
| 147 | + | Target: ports.ExactSessionTarget{LifecycleID: effect.sess.incarnation, SessionName: effect.sess.name}, | |
| 148 | + | Ephemeral: effect.sess.ephemeral, | |
| 149 | 149 | } | |
| 150 | - | token.sess.mu.Unlock() | |
| 150 | + | effect.sess.mu.Unlock() | |
| 151 | 151 | payload, err := ports.MarshalCommittedRouteIdentity(identity) | |
| 152 | 152 | if err != nil { | |
| 153 | 153 | 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 | 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 | 22 | source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source") | |
| 23 | 23 | target, _, _ := addRemoteRefreshPickerOwner(t, d, "target") | |
| 24 | 24 | target.incarnation = remoteLifecycleForTest() | |
| 25 | - | token := source.attachmentToken(ac, ac.transport()) | |
| 25 | + | token := source.captureAttachmentCapability(ac, ac.transport()) | |
| 26 | 26 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 27 | 27 | require.True(t, admitted) | |
| 28 | - | token.effect = effect | |
| 29 | 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 | 31 | frame := receiveRemotePicker(t, sends, "local attach target") | |
| 33 | 32 | got, err := ports.UnmarshalAttachTarget(frame.Payload) | |
| 34 | 33 | require.NoError(t, err) | |
| @@ -51,13 +50,12 @@ func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) { | |||
| 51 | 50 | graphics.mayHaveEmitted = true | |
| 52 | 51 | ac.output.graphicsOutput = graphics | |
| 53 | 52 | ||
| 54 | - | token := source.attachmentToken(ac, ac.transport()) | |
| 53 | + | token := source.captureAttachmentCapability(ac, ac.transport()) | |
| 55 | 54 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 56 | 55 | require.True(t, admitted) | |
| 57 | - | token.effect = effect | |
| 58 | 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 | 59 | Name: "stopped", Incarnation: lifecycle, | |
| 62 | 60 | }, sessionHandoffGuard{closePicker: true, allowSamePeer: true}, "picker-select")) | |
| 63 | 61 | ||
| @@ -85,15 +83,14 @@ func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | |||
| 85 | 83 | d.remoteCatalog.status["arch"] = remoteHostFresh | |
| 86 | 84 | d.remoteCatalog.mu.Unlock() | |
| 87 | 85 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 88 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 86 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 89 | 87 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 90 | 88 | require.True(t, admitted) | |
| 91 | - | token.effect = effect | |
| 92 | 89 | defer effect.End() | |
| 93 | 90 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 94 | 91 | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 95 | 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 | 94 | frame := receiveRemotePicker(t, sends, "rich attach target") | |
| 98 | 95 | got, err := ports.UnmarshalAttachTarget(frame.Payload) | |
| 99 | 96 | require.NoError(t, err) | |
| @@ -128,10 +125,9 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) { | |||
| 128 | 125 | d.remoteCatalog.status["arch"] = remoteHostFresh | |
| 129 | 126 | d.remoteCatalog.mu.Unlock() | |
| 130 | 127 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 131 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 128 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 132 | 129 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 133 | 130 | require.True(t, admitted) | |
| 134 | - | token.effect = effect | |
| 135 | 131 | defer effect.End() | |
| 136 | 132 | ||
| 137 | 133 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| @@ -139,7 +135,7 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) { | |||
| 139 | 135 | target := picker.Target{Session: key.ID(), RemoteKey: &key, RemoteTarget: &remoteTarget, TabID: "tab-1"} | |
| 140 | 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 | 139 | for { | |
| 144 | 140 | select { | |
| 145 | 141 | case frame := <-sends: | |
| @@ -166,15 +162,14 @@ func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) { | |||
| 166 | 162 | d.remoteCatalog.status["arch"] = remoteHostFresh | |
| 167 | 163 | d.remoteCatalog.mu.Unlock() | |
| 168 | 164 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 169 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 165 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 170 | 166 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 171 | 167 | require.True(t, admitted) | |
| 172 | - | token.effect = effect | |
| 173 | 168 | defer effect.End() | |
| 174 | 169 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 175 | 170 | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 176 | 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 | 173 | for { | |
| 179 | 174 | select { | |
| 180 | 175 | case frame := <-sends: | |
M internal/usecase/daemon/remote_picker_test.go +11 -15
| @@ -686,14 +686,13 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) { | |||
| 686 | 686 | d.remoteCatalog.status["arch"] = remoteHostFresh | |
| 687 | 687 | d.remoteCatalog.mu.Unlock() | |
| 688 | 688 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 689 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 689 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 690 | 690 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 691 | 691 | require.True(t, admitted) | |
| 692 | - | token.effect = effect | |
| 693 | 692 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 694 | 693 | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 695 | 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 | 697 | frame := receiveRemotePicker(t, sends, "attach target") | |
| 699 | 698 | require.Equal(t, ports.MsgAttachTarget, frame.Type) | |
| @@ -742,11 +741,10 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) { | |||
| 742 | 741 | local.remoteCatalog.status["arch"] = remoteHostFresh | |
| 743 | 742 | local.remoteCatalog.mu.Unlock() | |
| 744 | 743 | localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local") | |
| 745 | - | token := localSession.attachmentToken(localAttachment, localAttachment.transport()) | |
| 744 | + | token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport()) | |
| 746 | 745 | effect, admitted := localAttachment.beginAttachmentEffect(token) | |
| 747 | 746 | require.True(t, admitted) | |
| 748 | - | token.effect = effect | |
| 749 | - | require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 747 | + | require.NoError(t, local.sendRemoteAttachTargetForAttachment(effect, selected, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 750 | 748 | ||
| 751 | 749 | frame := receiveRemotePicker(t, sends, "stopped remote target") | |
| 752 | 750 | handoff, err := ports.UnmarshalAttachTarget(frame.Payload) | |
| @@ -814,11 +812,10 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing | |||
| 814 | 812 | local.remoteCatalog.status["arch"] = remoteHostFresh | |
| 815 | 813 | local.remoteCatalog.mu.Unlock() | |
| 816 | 814 | localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local") | |
| 817 | - | token := localSession.attachmentToken(localAttachment, localAttachment.transport()) | |
| 815 | + | token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport()) | |
| 818 | 816 | effect, admitted := localAttachment.beginAttachmentEffect(token) | |
| 819 | 817 | require.True(t, admitted) | |
| 820 | - | token.effect = effect | |
| 821 | - | require.NoError(t, local.sendRemoteAttachTargetForAttachment(token, selected, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 818 | + | require.NoError(t, local.sendRemoteAttachTargetForAttachment(effect, selected, sessionHandoffGuard{closePicker: true}, "picker-select")) | |
| 822 | 819 | ||
| 823 | 820 | frame := receiveRemotePicker(t, sends, "stopped remote target without tab metadata") | |
| 824 | 821 | handoff, err := ports.UnmarshalAttachTarget(frame.Payload) | |
| @@ -858,11 +855,11 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) { | |||
| 858 | 855 | if tt.action == ports.NavigationOpenHomePicker { | |
| 859 | 856 | ac.navigationCapabilities = ports.NavigationCapabilityHomePicker | |
| 860 | 857 | } | |
| 861 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 858 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 862 | 859 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 863 | 860 | require.True(t, admitted) | |
| 864 | - | token.effect = effect | |
| 865 | - | require.NoError(t, d.sendNavigationActionForAttachment(token, tt.action)) | |
| 861 | + | defer effect.End() | |
| 862 | + | require.NoError(t, d.sendNavigationActionForAttachment(effect, tt.action)) | |
| 866 | 863 | frame := receiveRemotePicker(t, sends, "navigation action") | |
| 867 | 864 | require.Equal(t, ports.MsgNavigationAction, frame.Type) | |
| 868 | 865 | directive, err := ports.UnmarshalNavigationDirective(frame.Payload) | |
| @@ -901,15 +898,14 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) { | |||
| 901 | 898 | ||
| 902 | 899 | gone := make(chan struct{}) | |
| 903 | 900 | d.afterClientGoneDetach = func() { close(gone) } | |
| 904 | - | token := sess.attachmentToken(ac, tr) | |
| 901 | + | token := sess.captureAttachmentCapability(ac, tr) | |
| 905 | 902 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 906 | 903 | require.True(t, admitted) | |
| 907 | - | token.effect = effect | |
| 908 | 904 | defer effect.End() | |
| 909 | 905 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 910 | 906 | remoteTarget := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | |
| 911 | 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 | 909 | var userErr *domain.UserError | |
| 914 | 910 | require.ErrorAs(t, err, &userErr) | |
| 915 | 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 | 457 | client.release = nil | |
| 458 | 458 | client.startedSignal = false | |
| 459 | 459 | client.mu.Unlock() | |
| 460 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 460 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 461 | 461 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 462 | 462 | require.True(t, admitted) | |
| 463 | - | token.effect = effect | |
| 464 | 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 | 465 | <-secondStarted | |
| 467 | 466 | wantWidth, wantHeight := remotePickerPreviewSize(domain.Size{Cols: 100, Rows: 30}) | |
| 468 | 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 | 294 | type runtimeMarkBatch struct { | |
| 295 | 295 | observer ports.RuntimeObserver | |
| 296 | 296 | marks []ports.RuntimeMark | |
| 297 | - | attachmentEffect *attachmentEffectTicket | |
| 297 | + | attachmentEffect *attachmentEffect | |
| 298 | 298 | } | |
| 299 | 299 | ||
| 300 | 300 | func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch { | |
| @@ -338,13 +338,15 @@ func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *at | |||
| 338 | 338 | sess := entry | |
| 339 | 339 | local := sess != nil | |
| 340 | 340 | marks := d.newRuntimeMarkBatch() | |
| 341 | + | var paintEffect *attachmentEffect | |
| 341 | 342 | if lease != nil { | |
| 342 | - | token := attachmentToken(entry, ac, ac.transport()) | |
| 343 | + | token := captureAttachmentCapability(entry, ac, ac.transport()) | |
| 343 | 344 | token.lease = lease | |
| 344 | 345 | ticket, admitted := ac.beginAttachmentEffect(token) | |
| 345 | 346 | if !admitted { | |
| 346 | 347 | return paintRejected | |
| 347 | 348 | } | |
| 349 | + | paintEffect = ticket | |
| 348 | 350 | marks.attachmentEffect = ticket | |
| 349 | 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 | 361 | ac.sendMu.Lock() | |
| 360 | - | // sendMu is the attachment ownership boundary. Check the session's | |
| 361 | - | // published identity while holding it so a deadline captured before an | |
| 362 | - | // attach/replace cannot emit on either the old or new output chain. | |
| 363 | - | entry.core().mu.Lock() | |
| 364 | - | _, owned := entry.core().attachments[ac] | |
| 365 | - | entry.core().mu.Unlock() | |
| 366 | - | if !owned || ac.currentAttachmentSession() != entry || ac.parkedRouteOutput.Load() { | |
| 367 | - | ac.sendMu.Unlock() | |
| 368 | - | return paintRejected | |
| 369 | - | } | |
| 370 | - | if ac.parkedRouteFullPending.Load() { | |
| 371 | - | reset = true | |
| 372 | - | } | |
| 373 | - | if lease != nil { | |
| 374 | - | rc := attachmentRenderCoordinator(entry) | |
| 375 | - | if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) { | |
| 362 | + | if paintEffect != nil { | |
| 363 | + | if !paintEffect.current() || ac.parkedRouteOutput.Load() { | |
| 376 | 364 | ac.sendMu.Unlock() | |
| 377 | 365 | 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 | 381 | // Capacity is checked before any destructive capture. Refresh the atomic | |
| 381 | 382 | // 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 | 424 | ActiveTabID: state.view.tabID, | |
| 425 | 425 | } | |
| 426 | 426 | entry.core().mu.Unlock() | |
| 427 | - | if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry { | |
| 428 | - | ac.sendMu.Unlock() | |
| 429 | - | return false | |
| 430 | - | } | |
| 431 | - | if state.lease != nil { | |
| 432 | - | rc := attachmentRenderCoordinator(entry) | |
| 433 | - | if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) { | |
| 427 | + | if marks.attachmentEffect != nil { | |
| 428 | + | if !marks.attachmentEffect.current() || state.attachment != ac { | |
| 429 | + | ac.sendMu.Unlock() | |
| 430 | + | return false | |
| 431 | + | } | |
| 432 | + | } else { | |
| 433 | + | if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry { | |
| 434 | 434 | ac.sendMu.Unlock() | |
| 435 | 435 | 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 | 445 | endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0) | |
| 439 | 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 | 588 | // Capture the exact admitted capability, including its coordinator | |
| 582 | 589 | // lease, before End permits a new attachment publication. Reserve | |
| 583 | 590 | // cleanup accounting before End so terminal Wait cannot race a later Add. | |
| 584 | - | token := marks.attachmentEffect.connectionToken() | |
| 585 | - | launchCleanup := d.reserveAttachmentSendErrorCleanup(token, sendTr) | |
| 591 | + | capability := marks.attachmentEffect.capability() | |
| 592 | + | launchCleanup := d.reserveAttachmentSendErrorCleanup(capability, sendTr) | |
| 586 | 593 | marks.attachmentEffect.End() | |
| 587 | 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 | 687 | func TestEmitFrameSkipsTransportSendWhenAttachmentEffectFenceRejects(t *testing.T) { | |
| 688 | 688 | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 689 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 689 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 690 | 690 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 691 | 691 | require.True(t, admitted) | |
| 692 | - | ac.attachmentEffects.mu.Lock() | |
| 693 | - | ac.attachmentEffects.capability.transport = transportSnapshot{} | |
| 694 | - | ac.attachmentEffects.mu.Unlock() | |
| 692 | + | ac.lifecycle.mu.Lock() | |
| 693 | + | ac.lifecycle.capability.transport = transportSnapshot{} | |
| 694 | + | ac.lifecycle.mu.Unlock() | |
| 695 | 695 | ||
| 696 | 696 | state := cacheState("stale", 1) | |
| 697 | 697 | state.attachment = ac | |
M internal/usecase/daemon/resize_publication.go +8 -14
| @@ -11,7 +11,7 @@ type resizeCommitPublication struct { | |||
| 11 | 11 | attachment *attachedClient | |
| 12 | 12 | lease *attachmentLease | |
| 13 | 13 | epoch uint64 | |
| 14 | - | connection attachmentConnectionToken | |
| 14 | + | effect *attachmentEffect | |
| 15 | 15 | coordinator *renderCoordinator | |
| 16 | 16 | observer ports.RuntimeObserver | |
| 17 | 17 | mark ports.RuntimeMark | |
| @@ -24,19 +24,13 @@ func (p resizeCommitPublication) current() bool { | |||
| 24 | 24 | if p.attachment == nil { | |
| 25 | 25 | return true | |
| 26 | 26 | } | |
| 27 | - | // Attached resize publication is admitted only through a live attachment effect ticket. | |
| 28 | - | if p.connection.effect == nil { | |
| 27 | + | // Attached resize publication retains the exact admitted effect; lifecycle | |
| 28 | + | // identity cannot change until that effect ends. | |
| 29 | + | if !p.effect.current() || p.effect.sess != p.session || | |
| 30 | + | p.effect.ac != p.attachment || p.effect.lease != p.lease || p.coordinator == nil { | |
| 29 | 31 | return false | |
| 30 | 32 | } | |
| 31 | - | if p.connection.effect.ended.Load() || p.connection.sess != p.session || | |
| 32 | - | p.connection.ac != p.attachment || p.connection.lease != p.lease || | |
| 33 | - | p.connection.generation != p.attachment.connectionGeneration.Load() || | |
| 34 | - | !p.attachment.transportSnapshotCurrent(p.connection.transport) || | |
| 35 | - | p.attachment.currentSession() != p.session || p.coordinator == nil { | |
| 36 | - | return false | |
| 37 | - | } | |
| 38 | - | registered := attachmentRegistered(p.session, p.attachment) | |
| 39 | - | return registered && p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false) | |
| 33 | + | return p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false) | |
| 40 | 34 | } | |
| 41 | 35 | ||
| 42 | 36 | // emit performs observer work only after every resize-owner fence and | |
| @@ -57,7 +51,7 @@ func (p resizeCommitPublication) emit() bool { | |||
| 57 | 51 | // reservation with pane ownership. sendMu is acquired before resize fences, so | |
| 58 | 52 | // the canonical rule never acquires an attachment lock while a pane fence is | |
| 59 | 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 | 55 | if ac != nil && ticket == nil { | |
| 62 | 56 | return false | |
| 63 | 57 | } | |
| @@ -85,7 +79,7 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac * | |||
| 85 | 79 | mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true), | |
| 86 | 80 | } | |
| 87 | 81 | if ticket != nil { | |
| 88 | - | publication.connection = ticket.connectionToken() | |
| 82 | + | publication.effect = ticket | |
| 89 | 83 | publication.coordinator = sess.renderCoordinator() | |
| 90 | 84 | } | |
| 91 | 85 | if !publication.current() { | |
M internal/usecase/daemon/resume.go +18 -3
| @@ -362,16 +362,32 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool { | |||
| 362 | 362 | d.mu.Unlock() | |
| 363 | 363 | 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 | 377 | parked.claimed = false | |
| 366 | 378 | ac.resumeClaimToken = 0 | |
| 367 | 379 | ac.resumeToken = token | |
| 368 | 380 | ac.parked = true | |
| 369 | - | ac.connectionGeneration.Add(1) | |
| 370 | 381 | sess := parked.sess | |
| 371 | 382 | if sess != nil { | |
| 372 | 383 | sess.mu.Lock() | |
| 373 | 384 | sess.unregisterAttachmentLocked(ac) | |
| 385 | + | ac.setSession(nil) | |
| 386 | + | ac.invalidateFrozenAttachmentCapability() | |
| 374 | 387 | sess.mu.Unlock() | |
| 388 | + | } else { | |
| 389 | + | ac.setSession(nil) | |
| 390 | + | ac.invalidateFrozenAttachmentCapability() | |
| 375 | 391 | } | |
| 376 | 392 | // Recreate the parked entry and its watcher instead of restoring a timer | |
| 377 | 393 | // 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 | 407 | d.parked[token] = rearmed | |
| 392 | 408 | captured := ac.transportSnapshot().transport | |
| 393 | - | ac.setSession(nil) | |
| 394 | 409 | d.mu.Unlock() | |
| 395 | 410 | if sess != nil { | |
| 396 | 411 | d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go") | |
| @@ -414,8 +429,8 @@ func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttach | |||
| 414 | 429 | d.discardAttachmentOutputLocked(parked.ac) | |
| 415 | 430 | parked.ac.resumeToken = 0 | |
| 416 | 431 | parked.ac.parked = false | |
| 417 | - | parked.ac.connectionGeneration.Add(1) | |
| 418 | 432 | parked.ac.setSession(nil) | |
| 433 | + | parked.ac.invalidateRetiredAttachmentCapability() | |
| 419 | 434 | return parkedAttachmentRetirement{ | |
| 420 | 435 | parked: parked, | |
| 421 | 436 | pickerGeneration: parked.pickerGeneration, | |
M internal/usecase/daemon/resume_test.go +2 -2
| @@ -1150,7 +1150,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing. | |||
| 1150 | 1150 | token := ac.resumeToken | |
| 1151 | 1151 | require.True(t, sess.detachIfCurrent(ac)) | |
| 1152 | 1152 | require.True(t, d.parkAttachment(sess, ac)) | |
| 1153 | - | generation := ac.connectionGeneration.Load() | |
| 1153 | + | generation := ac.lifecycle.generationValue() | |
| 1154 | 1154 | ||
| 1155 | 1155 | newTr := &closeTrackingTransport{} | |
| 1156 | 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 | 1158 | require.True(t, ok) | |
| 1159 | 1159 | require.Same(t, sess, resumedSess) | |
| 1160 | 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 | 1163 | _ = ac.closeCapturedTransport(oldTr) | |
| 1164 | 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 | 89 | <-wakeDispatched | |
| 90 | 90 | ||
| 91 | - | oldGeneration := ac.connectionGeneration.Load() | |
| 91 | + | oldGeneration := ac.lifecycle.generationValue() | |
| 92 | 92 | result, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 93 | 93 | source: sess, target: sess, next: ac, | |
| 94 | 94 | ||
| @@ -96,7 +96,7 @@ func TestSameAttachmentRebindRejectsQueuedWakeAndFansOutFreshGeneration(t *testi | |||
| 96 | 96 | }) | |
| 97 | 97 | require.NoError(t, err) | |
| 98 | 98 | d.deferAttachmentTransitionCleanups(result) | |
| 99 | - | require.Greater(t, ac.connectionGeneration.Load(), oldGeneration) | |
| 99 | + | require.Greater(t, ac.lifecycle.generationValue(), oldGeneration) | |
| 100 | 100 | freshLease := rc.attachmentLease(ac) | |
| 101 | 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 | 89 | require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac))) | |
| 90 | 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 | 93 | sess.selectAttachmentTab(ac, "tab-2") | |
| 94 | 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 | 33 | // switchSamePeerForAttachment commits one client-confirmed endpoint-empty | |
| 34 | 34 | // target. The request never names an endpoint: its authority is the current | |
| 35 | 35 | // authenticated attachment plus the target's exact lifecycle identity. | |
| 36 | - | func (d *Daemon) switchSamePeerForAttachment(token attachmentConnectionToken, request ports.SamePeerSwitchRequest) { | |
| 37 | - | if err := request.Validate(); err != nil || token.sess == nil || token.ac == nil { | |
| 36 | + | func (d *Daemon) switchSamePeerForAttachment(effect *attachmentEffect, request ports.SamePeerSwitchRequest) { | |
| 37 | + | if err := request.Validate(); err != nil || !effect.current() || effect.sess == nil || effect.ac == nil { | |
| 38 | 38 | return | |
| 39 | 39 | } | |
| 40 | - | if !token.ac.consumeSamePeerOffer(request.Target) { | |
| 41 | - | d.sendSamePeerSwitchFailure(token, request.RequestID, ports.SamePeerSwitchStaleTarget) | |
| 40 | + | if !effect.ac.consumeSamePeerOffer(request.Target) { | |
| 41 | + | d.sendSamePeerSwitchFailure(effect, request.RequestID, ports.SamePeerSwitchStaleTarget) | |
| 42 | 42 | return | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | target, targetTabIndex, ok := d.samePeerTarget(request) | |
| 46 | - | if !ok || target == token.sess { | |
| 47 | - | d.sendSamePeerSwitchFailure(token, request.RequestID, ports.SamePeerSwitchStaleTarget) | |
| 46 | + | if !ok || target == effect.sess { | |
| 47 | + | d.sendSamePeerSwitchFailure(effect, request.RequestID, ports.SamePeerSwitchStaleTarget) | |
| 48 | 48 | return | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | capability := effect.capability() | |
| 51 | 52 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 52 | - | source: token.sess, | |
| 53 | + | source: effect.sess, | |
| 53 | 54 | target: target, | |
| 54 | - | next: token.ac, | |
| 55 | - | expectedTransport: token.transport, | |
| 56 | - | sourceToken: &token, | |
| 55 | + | next: effect.ac, | |
| 56 | + | expectedTransport: effect.transport, | |
| 57 | + | sourceCapability: &capability, | |
| 58 | + | sourceEffect: effect, | |
| 57 | 59 | action: "same-peer-switch", | |
| 58 | 60 | expectedTargetLifecycle: &attachmentLifecycleFence{ | |
| 59 | 61 | name: request.Target.SessionName, | |
| @@ -65,12 +67,12 @@ func (d *Daemon) switchSamePeerForAttachment(token attachmentConnectionToken, re | |||
| 65 | 67 | ready: true, | |
| 66 | 68 | }) | |
| 67 | 69 | if err != nil { | |
| 68 | - | d.sendSamePeerSwitchFailure(token, request.RequestID, ports.SamePeerSwitchStaleTarget) | |
| 70 | + | d.sendSamePeerSwitchFailure(effect, request.RequestID, ports.SamePeerSwitchStaleTarget) | |
| 69 | 71 | return | |
| 70 | 72 | } | |
| 71 | 73 | ||
| 72 | - | if fresh, admitted := token.ac.beginAttachmentEffect(transition.published); admitted { | |
| 73 | - | d.closePicker(token.ac) | |
| 74 | + | if fresh, admitted := effect.ac.beginAttachmentEffect(transition.published); admitted { | |
| 75 | + | d.closePicker(effect.ac) | |
| 74 | 76 | fresh.End() | |
| 75 | 77 | } | |
| 76 | 78 | d.touchMRU(target) | |
| @@ -106,15 +108,24 @@ func (d *Daemon) samePeerTarget(request ports.SamePeerSwitchRequest) (*session, | |||
| 106 | 108 | return target, tabIndex, true | |
| 107 | 109 | } | |
| 108 | 110 | ||
| 109 | - | func (d *Daemon) sendSamePeerSwitchFailure(token attachmentConnectionToken, requestID uint64, code ports.SamePeerSwitchFailureCode) { | |
| 110 | - | if !token.attachmentCurrent() { | |
| 111 | + | func (d *Daemon) sendSamePeerSwitchFailure(effect *attachmentEffect, requestID uint64, code ports.SamePeerSwitchFailureCode) { | |
| 112 | + | if effect == nil || effect.ac == nil { | |
| 111 | 113 | 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 | 124 | payload, err := ports.MarshalSamePeerSwitchFailure(ports.SamePeerSwitchFailure{RequestID: requestID, Code: code}) | |
| 114 | 125 | if err != nil { | |
| 115 | 126 | return | |
| 116 | 127 | } | |
| 117 | - | if err := token.sendControl(ports.Frame{Type: ports.MsgSamePeerSwitchFailure, Payload: payload}); err != nil { | |
| 118 | - | d.detachOnAttachmentSendError(token, token.transport.transport) | |
| 128 | + | if err := sender.sendControl(ports.Frame{Type: ports.MsgSamePeerSwitchFailure, Payload: payload}); err != nil { | |
| 129 | + | d.detachOnAttachmentSendError(sender.capability(), sender.transport.transport) | |
| 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 | 70 | d.mu.Unlock() | |
| 71 | 71 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |
| 72 | 72 | ||
| 73 | - | token := source.attachmentToken(ac, ac.transport()) | |
| 73 | + | token := source.captureAttachmentCapability(ac, ac.transport()) | |
| 74 | 74 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 75 | 75 | require.True(t, admitted) | |
| 76 | - | token.effect = effect | |
| 77 | 76 | defer effect.End() | |
| 78 | 77 | requestTarget := ports.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"} | |
| 79 | 78 | ac.offerSamePeerTarget(requestTarget) | |
| 80 | 79 | ||
| 81 | - | d.switchSamePeerForAttachment(token, ports.SamePeerSwitchRequest{ | |
| 80 | + | d.switchSamePeerForAttachment(effect, ports.SamePeerSwitchRequest{ | |
| 82 | 81 | RequestID: 1, | |
| 83 | 82 | Target: requestTarget, | |
| 84 | 83 | PreferredTabID: domain.TabStableID(target.tabs[1].stableID), | |
| @@ -109,13 +108,12 @@ func TestSamePeerSwitchRejectsStaleTargetWithoutMutation(t *testing.T) { | |||
| 109 | 108 | defer releaseAll(releases) | |
| 110 | 109 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |
| 111 | 110 | ||
| 112 | - | token := source.attachmentToken(ac, ac.transport()) | |
| 111 | + | token := source.captureAttachmentCapability(ac, ac.transport()) | |
| 113 | 112 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 114 | 113 | require.True(t, admitted) | |
| 115 | - | token.effect = effect | |
| 116 | 114 | defer effect.End() | |
| 117 | 115 | ||
| 118 | - | d.switchSamePeerForAttachment(token, ports.SamePeerSwitchRequest{ | |
| 116 | + | d.switchSamePeerForAttachment(effect, ports.SamePeerSwitchRequest{ | |
| 119 | 117 | RequestID: 1, | |
| 120 | 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 | 512 | // createSessionAndSwitchForAttachment creates no target until the initiating role | |
| 513 | 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 | 515 | if name == "" { | |
| 516 | 516 | return errSessionNameRequired | |
| 517 | 517 | } | |
| 518 | 518 | if err := domain.ValidateSessionName(name); err != nil { | |
| 519 | 519 | 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 | 522 | return errAttachmentTransition | |
| 523 | 523 | } | |
| 524 | - | token.effect.bindActionEnd(d, "create-session") | |
| 525 | - | token.effect.End() | |
| 524 | + | effect.bindActionEnd(d, "create-session") | |
| 525 | + | effect.End() | |
| 526 | + | capability := effect.capability() | |
| 526 | 527 | ||
| 527 | 528 | var created *session | |
| 528 | 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 | 533 | copySourceEnvironment: true, ready: true, | |
| 533 | 534 | createTargetLocked: func() (*session, error) { | |
| 534 | - | source := token.sess | |
| 535 | + | source := capability.sess | |
| 535 | 536 | if source == nil { | |
| 536 | 537 | return nil, errAttachmentTransition | |
| 537 | 538 | } | |
| @@ -545,7 +546,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT | |||
| 545 | 546 | cwd, env := source.cwd, copyEnvironment(source.env) | |
| 546 | 547 | source.mu.Unlock() | |
| 547 | 548 | 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 | 550 | return created, createErr | |
| 550 | 551 | }, | |
| 551 | 552 | }) | |
| @@ -557,7 +558,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT | |||
| 557 | 558 | } | |
| 558 | 559 | ||
| 559 | 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 | 562 | d.deferAttachmentTransitionCleanups(transition) | |
| 562 | 563 | d.firstPaintForTransition(transition.published) | |
| 563 | 564 | return nil | |
| @@ -599,29 +600,30 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie | |||
| 599 | 600 | return nil | |
| 600 | 601 | } | |
| 601 | 602 | ||
| 602 | - | func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(token attachmentConnectionToken) error { | |
| 603 | - | if token.sess == nil || token.ac == nil || token.effect == nil { | |
| 603 | + | func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(effect *attachmentEffect) error { | |
| 604 | + | if effect == nil || effect.sess == nil || effect.ac == nil { | |
| 604 | 605 | return errAttachmentTransition | |
| 605 | 606 | } | |
| 606 | - | token.effect.bindActionEnd(d, "create-ephemeral-session") | |
| 607 | - | token.effect.End() | |
| 607 | + | effect.bindActionEnd(d, "create-ephemeral-session") | |
| 608 | + | effect.End() | |
| 609 | + | capability := effect.capability() | |
| 608 | 610 | ||
| 609 | 611 | var created *session | |
| 610 | 612 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 611 | - | source: token.sess, next: token.ac, | |
| 612 | - | expectedTransport: token.transport, sourceToken: &token, action: "create-ephemeral-session", | |
| 613 | + | source: capability.sess, next: capability.ac, | |
| 614 | + | expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-ephemeral-session", | |
| 613 | 615 | copySourceEnvironment: true, ready: true, | |
| 614 | 616 | createTargetLocked: func() (*session, error) { | |
| 615 | - | if token.sess == nil || d.closing { | |
| 617 | + | if capability.sess == nil || d.closing { | |
| 616 | 618 | return nil, errAttachmentTransition | |
| 617 | 619 | } | |
| 618 | - | source := token.sess | |
| 620 | + | source := capability.sess | |
| 619 | 621 | source.mu.Lock() | |
| 620 | 622 | cwd, env := source.cwd, copyEnvironment(source.env) | |
| 621 | 623 | source.mu.Unlock() | |
| 622 | 624 | name := d.allocEphemeralNameLocked() | |
| 623 | 625 | 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 | 627 | return created, createErr | |
| 626 | 628 | }, | |
| 627 | 629 | }) | |
| @@ -632,7 +634,7 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(token attachmentCo | |||
| 632 | 634 | return err | |
| 633 | 635 | } | |
| 634 | 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 | 638 | d.deferAttachmentTransitionCleanups(transition) | |
| 637 | 639 | d.firstPaintForTransition(transition.published) | |
| 638 | 640 | return nil | |
| @@ -855,17 +857,6 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |||
| 855 | 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 | 860 | // detachIfCurrent publishes terminal attachment invalidation through the attachment | |
| 870 | 861 | // gate. Freeze/drain happens before routing or session locks are acquired. | |
| 871 | 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 | 884 | if current { | |
| 894 | 885 | sess.unregisterAttachmentLocked(ac) | |
| 895 | - | ac.connectionGeneration.Add(1) | |
| 896 | 886 | ac.setSession(nil) | |
| 897 | 887 | ac.invalidateFrozenAttachmentCapability() | |
| 898 | 888 | } | |
| @@ -904,11 +894,11 @@ func (d *Daemon) detachIfCurrentTransport(sess *session, ac *attachedClient, exp | |||
| 904 | 894 | // detachIfAttachmentCurrent invalidates only the exact attachment generation and transport | |
| 905 | 895 | // whose admitted send failed. A queued stale error cannot detach a later | |
| 906 | 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 | 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 | 902 | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 913 | 903 | return false | |
| 914 | 904 | } | |
| @@ -938,13 +928,9 @@ func (d *Daemon) detachIfAttachmentCurrentUntil(token attachmentConnectionToken, | |||
| 938 | 928 | if coordinator != nil { | |
| 939 | 929 | coordinator.mu.Lock() | |
| 940 | 930 | } | |
| 941 | - | req := attachmentTransitionRequest{ | |
| 942 | - | source: token.sess, next: token.ac, expectedTransport: token.transport, | |
| 943 | - | } | |
| 944 | - | current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 931 | + | current := coordinator != nil && token.currentInSessionAndLeaseLocked(token.sess, token.ac, coordinator) | |
| 945 | 932 | if current { | |
| 946 | 933 | unregisterAttachmentSessionLocked(token.sess, token.ac) | |
| 947 | - | token.ac.connectionGeneration.Add(1) | |
| 948 | 934 | token.ac.setSession(nil) | |
| 949 | 935 | token.ac.invalidateFrozenAttachmentCapability() | |
| 950 | 936 | } | |
| @@ -1059,7 +1045,7 @@ func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) { | |||
| 1059 | 1045 | if ac.deferRouteIdentityUntilSnapshot() { | |
| 1060 | 1046 | continue | |
| 1061 | 1047 | } | |
| 1062 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 1048 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 1063 | 1049 | if token.ac == nil { | |
| 1064 | 1050 | continue | |
| 1065 | 1051 | } | |
| @@ -1067,11 +1053,11 @@ func (d *Daemon) publishRenamedSessionRouteIdentity(sess *session) { | |||
| 1067 | 1053 | if !admitted { | |
| 1068 | 1054 | continue | |
| 1069 | 1055 | } | |
| 1070 | - | token = effect.connectionToken() | |
| 1071 | - | err := d.sendCommittedRouteIdentityForAttachment(token) | |
| 1056 | + | err := d.sendCommittedRouteIdentityForAttachment(effect) | |
| 1057 | + | capability := effect.capability() | |
| 1072 | 1058 | effect.End() | |
| 1073 | 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 | 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 | 1139 | if sess == nil || tb == nil { | |
| 1154 | 1140 | return layout.ErrNotFound | |
| 1155 | 1141 | } | |
| @@ -1178,7 +1164,7 @@ func (d *Daemon) closeTabLockedWithEffect(sess *session, tb *tab, repaint bool, | |||
| 1178 | 1164 | var err error | |
| 1179 | 1165 | if effect != nil { | |
| 1180 | 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 | 1168 | } else { | |
| 1183 | 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 | 1293 | type sessionKillAdmission struct { | |
| 1308 | - | token attachmentConnectionToken | |
| 1309 | - | action string | |
| 1294 | + | capability attachmentCapability | |
| 1295 | + | effect *attachmentEffect | |
| 1296 | + | action string | |
| 1310 | 1297 | } | |
| 1311 | 1298 | ||
| 1312 | 1299 | type sessionKillParticipants struct { | |
| @@ -1315,12 +1302,15 @@ type sessionKillParticipants struct { | |||
| 1315 | 1302 | effectGates []*attachedClient | |
| 1316 | 1303 | interrupts []attachmentTransportInterrupt | |
| 1317 | 1304 | source *session | |
| 1318 | - | sourceToken *attachmentConnectionToken | |
| 1305 | + | sourceToken *attachmentCapability | |
| 1319 | 1306 | target *session | |
| 1320 | 1307 | } | |
| 1321 | 1308 | ||
| 1322 | - | func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge bool, token attachmentConnectionToken, action string) error { | |
| 1323 | - | return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{token: token, action: action}) | |
| 1309 | + | func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge bool, effect *attachmentEffect, action string) error { | |
| 1310 | + | if effect == nil { | |
| 1311 | + | return errAttachmentTransition | |
| 1312 | + | } | |
| 1313 | + | return d.killSessionWithSnapshotDeadline(sess, reason, purge, nil, &sessionKillAdmission{capability: effect.capability(), effect: effect, action: action}) | |
| 1324 | 1314 | } | |
| 1325 | 1315 | ||
| 1326 | 1316 | // snapshotSessionKillParticipants captures attachment membership under the canonical | |
| @@ -1330,7 +1320,7 @@ func (d *Daemon) killSessionForAttachment(sess *session, reason uint8, purge boo | |||
| 1330 | 1320 | func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *sessionKillAdmission, requireEmpty bool) (sessionKillParticipants, bool) { | |
| 1331 | 1321 | snapshot := sessionKillParticipants{target: target} | |
| 1332 | 1322 | if admission != nil { | |
| 1333 | - | token := admission.token | |
| 1323 | + | token := admission.capability | |
| 1334 | 1324 | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 1335 | 1325 | return sessionKillParticipants{}, false | |
| 1336 | 1326 | } | |
| @@ -1338,7 +1328,7 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||
| 1338 | 1328 | if snapshot.source == nil { | |
| 1339 | 1329 | return sessionKillParticipants{}, false | |
| 1340 | 1330 | } | |
| 1341 | - | snapshot.sourceToken = &admission.token | |
| 1331 | + | snapshot.sourceToken = &admission.capability | |
| 1342 | 1332 | } | |
| 1343 | 1333 | ||
| 1344 | 1334 | d.mu.Lock() | |
| @@ -1381,7 +1371,7 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||
| 1381 | 1371 | snapshot.effectGates = append(snapshot.effectGates, ac) | |
| 1382 | 1372 | } | |
| 1383 | 1373 | if admission != nil { | |
| 1384 | - | add(admission.token.ac) | |
| 1374 | + | add(admission.capability.ac) | |
| 1385 | 1375 | } | |
| 1386 | 1376 | for ac := range snapshot.attachments { | |
| 1387 | 1377 | add(ac) | |
| @@ -1440,10 +1430,7 @@ func sessionKillParticipantsCurrentLocked(snapshot sessionKillParticipants, sour | |||
| 1440 | 1430 | return true | |
| 1441 | 1431 | } | |
| 1442 | 1432 | token := *snapshot.sourceToken | |
| 1443 | - | req := attachmentTransitionRequest{ | |
| 1444 | - | source: token.sess, next: token.ac, expectedTransport: token.transport, | |
| 1445 | - | } | |
| 1446 | - | return transitionSourceTokenCurrentLocked(token, token.sess, sourceCoordinator, req) | |
| 1433 | + | return token.currentInSessionAndLeaseLocked(token.sess, token.ac, sourceCoordinator) | |
| 1447 | 1434 | } | |
| 1448 | 1435 | ||
| 1449 | 1436 | // stopInMemoryLifecycle cancels every session producer and closes every PTY | |
| @@ -1570,7 +1557,7 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas | |||
| 1570 | 1557 | return nil | |
| 1571 | 1558 | } | |
| 1572 | 1559 | if admission != nil { | |
| 1573 | - | d.endActionAttachmentEffect(admission.token.effect, admission.action) | |
| 1560 | + | d.endActionAttachmentEffect(admission.effect, admission.action) | |
| 1574 | 1561 | } | |
| 1575 | 1562 | action := "" | |
| 1576 | 1563 | if admission != nil { | |
| @@ -1740,7 +1727,6 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas | |||
| 1740 | 1727 | return | |
| 1741 | 1728 | } | |
| 1742 | 1729 | attachments = append(attachments, detachedAttachmentSnapshot{ac: attached, transport: participants.transports[attached]}) | |
| 1743 | - | attached.connectionGeneration.Add(1) | |
| 1744 | 1730 | attached.setSession(nil) | |
| 1745 | 1731 | attached.invalidateFrozenAttachmentCapability() | |
| 1746 | 1732 | } | |
| @@ -1769,7 +1755,7 @@ func (d *Daemon) killSessionWithSnapshotDeadlineAndCondition(sess *session, reas | |||
| 1769 | 1755 | d.mu.Unlock() | |
| 1770 | 1756 | d.finishParkedAttachmentRetirements(parkedRetirements) | |
| 1771 | 1757 | frozen.unfreeze() | |
| 1772 | - | frozen = frozenAttachmentEffectGates{} | |
| 1758 | + | frozen = attachmentTransitionGuard{} | |
| 1773 | 1759 | d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason) | |
| 1774 | 1760 | ||
| 1775 | 1761 | var purgeErr error | |
M internal/usecase/daemon/session_back.go +6 -7
| @@ -8,11 +8,11 @@ import ( | |||
| 8 | 8 | // backSessionForAttachment delegates previous-route ownership to the client | |
| 9 | 9 | // once it has published a complete route snapshot. There is no daemon-local | |
| 10 | 10 | // previous-session fallback after the global-history cutover. | |
| 11 | - | func (d *Daemon) backSessionForAttachment(token attachmentConnectionToken) error { | |
| 12 | - | if d == nil || token.sess == nil || token.ac == nil { | |
| 11 | + | func (d *Daemon) backSessionForAttachment(effect *attachmentEffect) error { | |
| 12 | + | if d == nil || !effect.current() || effect.sess == nil || effect.ac == nil { | |
| 13 | 13 | return nil | |
| 14 | 14 | } | |
| 15 | - | snapshot := token.ac.routeSnapshotCopy() | |
| 15 | + | snapshot := effect.ac.routeSnapshotCopy() | |
| 16 | 16 | if snapshot.Generation == 0 { | |
| 17 | 17 | if d.log != nil { | |
| 18 | 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 | 22 | if snapshot.Previous.Key == 0 || snapshot.Previous.Generation == 0 { | |
| 23 | 23 | return nil | |
| 24 | 24 | } | |
| 25 | - | return d.sendRecentRouteNavigationActionForAttachment(token, ports.RouteNavigationAction{ | |
| 25 | + | return d.sendRecentRouteNavigationActionForAttachment(effect, ports.RouteNavigationAction{ | |
| 26 | 26 | SnapshotGeneration: snapshot.Generation, | |
| 27 | 27 | Key: snapshot.Previous.Key, | |
| 28 | 28 | Generation: snapshot.Previous.Generation, | |
| @@ -33,15 +33,14 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) { | |||
| 33 | 33 | if d == nil || current == nil || ac == nil { | |
| 34 | 34 | return | |
| 35 | 35 | } | |
| 36 | - | token := attachmentToken(current, ac, ac.transport()) | |
| 36 | + | token := captureAttachmentCapability(current, ac, ac.transport()) | |
| 37 | 37 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 38 | 38 | if !admitted { | |
| 39 | 39 | d.reportError(current, domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", errAttachmentTransition)) | |
| 40 | 40 | return | |
| 41 | 41 | } | |
| 42 | - | token.effect = effect | |
| 43 | 42 | defer effect.End() | |
| 44 | - | if err := d.backSessionForAttachment(token); err != nil { | |
| 43 | + | if err := d.backSessionForAttachment(effect); err != nil { | |
| 45 | 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 | 12 | transport := &closeTrackingTransport{} | |
| 13 | 13 | ac.replaceTransport(transport) | |
| 14 | 14 | rc := d.attachCoordinator(source, nil, ac, true) | |
| 15 | - | token := source.attachmentToken(ac, transport) | |
| 15 | + | token := source.captureAttachmentCapability(ac, transport) | |
| 16 | 16 | token.lease = rc.attachmentLease(ac) | |
| 17 | - | ac.publishAttachmentCapability(token) | |
| 17 | + | ac.installTestAttachmentCapability(token) | |
| 18 | 18 | effect, admitted := ac.beginAttachmentEffect(token) | |
| 19 | 19 | require.True(t, admitted) | |
| 20 | 20 | defer effect.End() | |
| 21 | - | token.effect = effect | |
| 22 | 21 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{ | |
| 23 | 22 | Generation: 5, | |
| 24 | 23 | Active: ports.RouteRef{Key: 9, Generation: 5}, | |
| @@ -26,7 +25,7 @@ func TestBackSessionUsesClientPreviousRouteAfterSnapshotPublication(t *testing.T | |||
| 26 | 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 | 29 | frames := transport.Sends() | |
| 31 | 30 | require.NotEmpty(t, frames) | |
| 32 | 31 | 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 | 17 | sess.ephemeral = true | |
| 18 | 18 | sess.incarnation = domain.IncarnationID{1} | |
| 19 | 19 | sess.mu.Unlock() | |
| 20 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 21 | - | ac.publishAttachmentCapability(token) | |
| 20 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 21 | + | ac.installTestAttachmentCapability(token) | |
| 22 | 22 | ac.setRouteSnapshot(ports.RecentRouteSnapshot{Generation: 1}) | |
| 23 | 23 | ||
| 24 | 24 | require.NoError(t, d.renameSession(sess, "vps-infra")) | |
| @@ -40,8 +40,8 @@ func TestRenameDefersCommittedRouteIdentityUntilFirstRouteSnapshot(t *testing.T) | |||
| 40 | 40 | sess.ephemeral = true | |
| 41 | 41 | sess.incarnation = domain.IncarnationID{1} | |
| 42 | 42 | sess.mu.Unlock() | |
| 43 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 44 | - | ac.publishAttachmentCapability(token) | |
| 43 | + | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 44 | + | ac.installTestAttachmentCapability(token) | |
| 45 | 45 | ||
| 46 | 46 | require.NoError(t, d.renameSession(sess, "vps-infra")) | |
| 47 | 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 | 703 | return false | |
| 704 | 704 | } | |
| 705 | 705 | } | |
| 706 | - | connectionGeneration := ac.connectionGeneration.Load() | |
| 707 | - | connection := ac.transportSnapshot() | |
| 706 | + | effect, admitted := beginAttachmentLeaseEffect(sess, ac, lease) | |
| 707 | + | if !admitted { | |
| 708 | + | return false | |
| 709 | + | } | |
| 710 | + | defer effect.End() | |
| 708 | 711 | current := func() bool { | |
| 709 | - | return ac.connectionGeneration.Load() == connectionGeneration && | |
| 710 | - | ac.transportSnapshotCurrent(connection) && | |
| 711 | - | sess.geometryClaimTokenCurrent(ac, geometryClaim) && | |
| 712 | + | return effect.current() && sess.geometryClaimTokenCurrent(ac, geometryClaim) && | |
| 712 | 713 | rc.resizeCurrentForLease(epoch, ac, lease, false) | |
| 713 | 714 | } | |
| 714 | 715 | if !current() { | |