test(daemon): verify snatched client lifecycle
- commit
- 707a1cd91adf75dbf36c545e50ad0657818ad24f
- author
- brice · · 2026-07-28 01:12 UTC
- parent
- 3de5569
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +1392 -1036
M README.md +2 -0
| @@ -38,6 +38,8 @@ vev kill <name> kill a session (--all kills everything) | |||
| 38 | 38 | ||
| 39 | 39 | The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes. | |
| 40 | 40 | ||
| 41 | + | Each session has one active client. Attaching another client shows `Session snatched` on the prior client; press `r` to resume control, or `q`/Esc to quit that attachment. A remote snatched client that reconnects stays snatched and does not take control automatically. | |
| 42 | + | ||
| 41 | 43 | ## Keys | |
| 42 | 44 | ||
| 43 | 45 | No prefix key; everything is Alt. | |
| @@ -38,6 +38,8 @@ vev kill <name> kill a session (--all kills everything) | |||||
| 38 | 38 | ||||
| 39 | The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes. | 39 | The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes. | ||
| 40 | 40 | ||||
| 41 | + | Each session has one active client. Attaching another client shows `Session snatched` on the prior client; press `r` to resume control, or `q`/Esc to quit that attachment. A remote snatched client that reconnects stays snatched and does not take control automatically. | |||
| 42 | + | ||||
| 41 | ## Keys | 43 | ## Keys | ||
| 42 | 44 | ||||
| 43 | No prefix key; everything is Alt. | 45 | No prefix key; everything is Alt. | ||
M internal/usecase/client/reconnect_toast_test.go +4 -2
| @@ -1118,14 +1118,16 @@ func TestAttachAttemptReturnsWhileSenderSendIsBlockedAfterCancellation(t *testin | |||
| 1118 | 1118 | releaseSender() | |
| 1119 | 1119 | } | |
| 1120 | 1120 | ||
| 1121 | - | func TestDetachedResultReplacementAndCleanDetach(t *testing.T) { | |
| 1121 | + | func TestDetachedResultLegacyReplacementCompatibilityAndCleanDetach(t *testing.T) { | |
| 1122 | + | // Current daemons keep healthy displaced clients connected. Continue decoding | |
| 1123 | + | // ReasonReplaced from older peers so mixed-version detach handling stays clear. | |
| 1122 | 1124 | for _, tt := range []struct { | |
| 1123 | 1125 | name string | |
| 1124 | 1126 | reason uint8 | |
| 1125 | 1127 | wantText string | |
| 1126 | 1128 | }{ | |
| 1127 | 1129 | {name: "clean detach", reason: ports.ReasonDetach}, | |
| 1128 | - | {name: "replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"}, | |
| 1130 | + | {name: "legacy replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"}, | |
| 1129 | 1131 | } { | |
| 1130 | 1132 | t.Run(tt.name, func(t *testing.T) { | |
| 1131 | 1133 | err := detachedResult(tt.reason) | |
| @@ -1118,14 +1118,16 @@ func TestAttachAttemptReturnsWhileSenderSendIsBlockedAfterCancellation(t *testin | |||||
| 1118 | releaseSender() | 1118 | releaseSender() | ||
| 1119 | } | 1119 | } | ||
| 1120 | 1120 | ||||
| 1121 | - | func TestDetachedResultReplacementAndCleanDetach(t *testing.T) { | 1121 | + | func TestDetachedResultLegacyReplacementCompatibilityAndCleanDetach(t *testing.T) { |
| 1122 | + | // Current daemons keep healthy displaced clients connected. Continue decoding | |||
| 1123 | + | // ReasonReplaced from older peers so mixed-version detach handling stays clear. | |||
| 1122 | for _, tt := range []struct { | 1124 | for _, tt := range []struct { | ||
| 1123 | name string | 1125 | name string | ||
| 1124 | reason uint8 | 1126 | reason uint8 | ||
| 1125 | wantText string | 1127 | wantText string | ||
| 1126 | }{ | 1128 | }{ | ||
| 1127 | {name: "clean detach", reason: ports.ReasonDetach}, | 1129 | {name: "clean detach", reason: ports.ReasonDetach}, | ||
| 1128 | - | {name: "replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"}, | 1130 | + | {name: "legacy replacement", reason: ports.ReasonReplaced, wantText: "session taken over by another client"}, |
| 1129 | } { | 1131 | } { | ||
| 1130 | t.Run(tt.name, func(t *testing.T) { | 1132 | t.Run(tt.name, func(t *testing.T) { | ||
| 1131 | err := detachedResult(tt.reason) | 1133 | err := detachedResult(tt.reason) | ||
A internal/usecase/daemon/attachment_role.go +165 -0
| @@ -0,0 +1,165 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/internal/ports" | |
| 4 | + | ||
| 5 | + | type attachmentRole uint8 | |
| 6 | + | ||
| 7 | + | const ( | |
| 8 | + | attachmentDetached attachmentRole = iota | |
| 9 | + | attachmentActive | |
| 10 | + | attachmentSnatched | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // attachmentRole derives the attachment's role from the session-owned | |
| 14 | + | // registries. There is deliberately no second role field that could drift. | |
| 15 | + | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | |
| 16 | + | if s == nil || ac == nil { | |
| 17 | + | return attachmentDetached | |
| 18 | + | } | |
| 19 | + | s.mu.Lock() | |
| 20 | + | defer s.mu.Unlock() | |
| 21 | + | return s.attachmentRoleLocked(ac) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // attachmentRoleLocked requires s.mu. Active membership wins if an invalid | |
| 25 | + | // intermediate state contains the same attachment in both registries. | |
| 26 | + | func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole { | |
| 27 | + | if ac == nil { | |
| 28 | + | return attachmentDetached | |
| 29 | + | } | |
| 30 | + | if s.client == ac { | |
| 31 | + | return attachmentActive | |
| 32 | + | } | |
| 33 | + | if _, ok := s.snatched[ac]; ok { | |
| 34 | + | return attachmentSnatched | |
| 35 | + | } | |
| 36 | + | return attachmentDetached | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (s *session) addSnatchedLocked(ac *attachedClient) { | |
| 40 | + | if ac == nil { | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | if s.snatched == nil { | |
| 44 | + | s.snatched = make(map[*attachedClient]struct{}) | |
| 45 | + | } | |
| 46 | + | s.snatched[ac] = struct{}{} | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | type attachmentRoleToken struct { | |
| 50 | + | sess *session | |
| 51 | + | ac *attachedClient | |
| 52 | + | role attachmentRole | |
| 53 | + | generation uint64 | |
| 54 | + | transport transportSnapshot | |
| 55 | + | lease *attachmentLease | |
| 56 | + | effect *roleEffectTicket | |
| 57 | + | // rebase marks a cross-session ownership move whose first paint must start a | |
| 58 | + | // dependency-free output chain. The rebase is deliberately deferred until | |
| 59 | + | // after transition publication releases all architecture locks. | |
| 60 | + | rebase bool | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |
| 64 | + | if s == nil || ac == nil || tr == nil { | |
| 65 | + | return attachmentRoleToken{} | |
| 66 | + | } | |
| 67 | + | s.mu.Lock() | |
| 68 | + | transport := ac.transportSnapshot() | |
| 69 | + | if transport.transport != tr { | |
| 70 | + | s.mu.Unlock() | |
| 71 | + | return attachmentRoleToken{} | |
| 72 | + | } | |
| 73 | + | token := attachmentRoleToken{ | |
| 74 | + | sess: s, | |
| 75 | + | ac: ac, | |
| 76 | + | role: s.attachmentRoleLocked(ac), | |
| 77 | + | generation: ac.roleGeneration.Load(), | |
| 78 | + | transport: transport, | |
| 79 | + | } | |
| 80 | + | s.mu.Unlock() | |
| 81 | + | if token.role == attachmentActive { | |
| 82 | + | if rc := s.renderCoordinator(); rc != nil { | |
| 83 | + | token.lease = rc.attachmentLease(ac) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ac.bootstrapRoleCapability(token) | |
| 87 | + | return token | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (t attachmentRoleToken) current() bool { | |
| 91 | + | return t.sess != nil && t.ac != nil && | |
| 92 | + | t.ac.roleGeneration.Load() == t.generation && | |
| 93 | + | t.ac.transportSnapshotCurrent(t.transport) && | |
| 94 | + | t.sess.attachmentRole(t.ac) == t.role | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // activeCurrent admits post-transition effects only for the exact published | |
| 98 | + | // role, transport incarnation, current-session link, and coordinator lease. | |
| 99 | + | func (t attachmentRoleToken) activeCurrent() bool { | |
| 100 | + | if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess { | |
| 101 | + | return false | |
| 102 | + | } | |
| 103 | + | rc := t.sess.renderCoordinator() | |
| 104 | + | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // activeEffect is the canonical admission check for a client-frame mutation. | |
| 108 | + | // Tokens bind role generation, transport incarnation, session ownership, and | |
| 109 | + | // coordinator lease, so handlers never rediscover authority from mutable state. | |
| 110 | + | func (t attachmentRoleToken) activeEffect() bool { | |
| 111 | + | if t.effect != nil && !t.effect.ended.Load() { | |
| 112 | + | return true | |
| 113 | + | } | |
| 114 | + | return t.activeCurrent() | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) { | |
| 118 | + | if sess == nil || ac == nil || lease == nil { | |
| 119 | + | return nil, false | |
| 120 | + | } | |
| 121 | + | token := sess.attachmentToken(ac, ac.transport()) | |
| 122 | + | token.lease = lease | |
| 123 | + | return ac.beginRoleEffect(token) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (t *attachmentRoleToken) endRoleEffect() { | |
| 127 | + | if t == nil || t.effect == nil { | |
| 128 | + | return | |
| 129 | + | } | |
| 130 | + | t.effect.End() | |
| 131 | + | t.effect = nil | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // activeEffectSessionLocked is the same admission check while t.sess.mu is | |
| 135 | + | // already held at a session-owned mutation boundary. | |
| 136 | + | func (t attachmentRoleToken) activeEffectSessionLocked() bool { | |
| 137 | + | if t.effect != nil && !t.effect.ended.Load() { | |
| 138 | + | return true | |
| 139 | + | } | |
| 140 | + | if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil || | |
| 141 | + | t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 142 | + | t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive { | |
| 143 | + | return false | |
| 144 | + | } | |
| 145 | + | rc := t.sess.renderCoordinator() | |
| 146 | + | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error { | |
| 150 | + | if t.ac == nil { | |
| 151 | + | return errAttachmentTransition | |
| 152 | + | } | |
| 153 | + | t.ac.sendMu.Lock() | |
| 154 | + | defer t.ac.sendMu.Unlock() | |
| 155 | + | if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 156 | + | !t.effect.beginTransportSend(t.transport) { | |
| 157 | + | return errAttachmentTransition | |
| 158 | + | } | |
| 159 | + | err := t.transport.transport.Send(frame) | |
| 160 | + | if err != nil { | |
| 161 | + | t.effect.reportTransportFailure(t.transport) | |
| 162 | + | } | |
| 163 | + | t.effect.endTransportSend() | |
| 164 | + | return err | |
| 165 | + | } | |
| @@ -0,0 +1,165 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/ports" | |||
| 4 | + | ||||
| 5 | + | type attachmentRole uint8 | |||
| 6 | + | ||||
| 7 | + | const ( | |||
| 8 | + | attachmentDetached attachmentRole = iota | |||
| 9 | + | attachmentActive | |||
| 10 | + | attachmentSnatched | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // attachmentRole derives the attachment's role from the session-owned | |||
| 14 | + | // registries. There is deliberately no second role field that could drift. | |||
| 15 | + | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | |||
| 16 | + | if s == nil || ac == nil { | |||
| 17 | + | return attachmentDetached | |||
| 18 | + | } | |||
| 19 | + | s.mu.Lock() | |||
| 20 | + | defer s.mu.Unlock() | |||
| 21 | + | return s.attachmentRoleLocked(ac) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // attachmentRoleLocked requires s.mu. Active membership wins if an invalid | |||
| 25 | + | // intermediate state contains the same attachment in both registries. | |||
| 26 | + | func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole { | |||
| 27 | + | if ac == nil { | |||
| 28 | + | return attachmentDetached | |||
| 29 | + | } | |||
| 30 | + | if s.client == ac { | |||
| 31 | + | return attachmentActive | |||
| 32 | + | } | |||
| 33 | + | if _, ok := s.snatched[ac]; ok { | |||
| 34 | + | return attachmentSnatched | |||
| 35 | + | } | |||
| 36 | + | return attachmentDetached | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (s *session) addSnatchedLocked(ac *attachedClient) { | |||
| 40 | + | if ac == nil { | |||
| 41 | + | return | |||
| 42 | + | } | |||
| 43 | + | if s.snatched == nil { | |||
| 44 | + | s.snatched = make(map[*attachedClient]struct{}) | |||
| 45 | + | } | |||
| 46 | + | s.snatched[ac] = struct{}{} | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | type attachmentRoleToken struct { | |||
| 50 | + | sess *session | |||
| 51 | + | ac *attachedClient | |||
| 52 | + | role attachmentRole | |||
| 53 | + | generation uint64 | |||
| 54 | + | transport transportSnapshot | |||
| 55 | + | lease *attachmentLease | |||
| 56 | + | effect *roleEffectTicket | |||
| 57 | + | // rebase marks a cross-session ownership move whose first paint must start a | |||
| 58 | + | // dependency-free output chain. The rebase is deliberately deferred until | |||
| 59 | + | // after transition publication releases all architecture locks. | |||
| 60 | + | rebase bool | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |||
| 64 | + | if s == nil || ac == nil || tr == nil { | |||
| 65 | + | return attachmentRoleToken{} | |||
| 66 | + | } | |||
| 67 | + | s.mu.Lock() | |||
| 68 | + | transport := ac.transportSnapshot() | |||
| 69 | + | if transport.transport != tr { | |||
| 70 | + | s.mu.Unlock() | |||
| 71 | + | return attachmentRoleToken{} | |||
| 72 | + | } | |||
| 73 | + | token := attachmentRoleToken{ | |||
| 74 | + | sess: s, | |||
| 75 | + | ac: ac, | |||
| 76 | + | role: s.attachmentRoleLocked(ac), | |||
| 77 | + | generation: ac.roleGeneration.Load(), | |||
| 78 | + | transport: transport, | |||
| 79 | + | } | |||
| 80 | + | s.mu.Unlock() | |||
| 81 | + | if token.role == attachmentActive { | |||
| 82 | + | if rc := s.renderCoordinator(); rc != nil { | |||
| 83 | + | token.lease = rc.attachmentLease(ac) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ac.bootstrapRoleCapability(token) | |||
| 87 | + | return token | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (t attachmentRoleToken) current() bool { | |||
| 91 | + | return t.sess != nil && t.ac != nil && | |||
| 92 | + | t.ac.roleGeneration.Load() == t.generation && | |||
| 93 | + | t.ac.transportSnapshotCurrent(t.transport) && | |||
| 94 | + | t.sess.attachmentRole(t.ac) == t.role | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // activeCurrent admits post-transition effects only for the exact published | |||
| 98 | + | // role, transport incarnation, current-session link, and coordinator lease. | |||
| 99 | + | func (t attachmentRoleToken) activeCurrent() bool { | |||
| 100 | + | if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess { | |||
| 101 | + | return false | |||
| 102 | + | } | |||
| 103 | + | rc := t.sess.renderCoordinator() | |||
| 104 | + | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // activeEffect is the canonical admission check for a client-frame mutation. | |||
| 108 | + | // Tokens bind role generation, transport incarnation, session ownership, and | |||
| 109 | + | // coordinator lease, so handlers never rediscover authority from mutable state. | |||
| 110 | + | func (t attachmentRoleToken) activeEffect() bool { | |||
| 111 | + | if t.effect != nil && !t.effect.ended.Load() { | |||
| 112 | + | return true | |||
| 113 | + | } | |||
| 114 | + | return t.activeCurrent() | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) { | |||
| 118 | + | if sess == nil || ac == nil || lease == nil { | |||
| 119 | + | return nil, false | |||
| 120 | + | } | |||
| 121 | + | token := sess.attachmentToken(ac, ac.transport()) | |||
| 122 | + | token.lease = lease | |||
| 123 | + | return ac.beginRoleEffect(token) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func (t *attachmentRoleToken) endRoleEffect() { | |||
| 127 | + | if t == nil || t.effect == nil { | |||
| 128 | + | return | |||
| 129 | + | } | |||
| 130 | + | t.effect.End() | |||
| 131 | + | t.effect = nil | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // activeEffectSessionLocked is the same admission check while t.sess.mu is | |||
| 135 | + | // already held at a session-owned mutation boundary. | |||
| 136 | + | func (t attachmentRoleToken) activeEffectSessionLocked() bool { | |||
| 137 | + | if t.effect != nil && !t.effect.ended.Load() { | |||
| 138 | + | return true | |||
| 139 | + | } | |||
| 140 | + | if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil || | |||
| 141 | + | t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) || | |||
| 142 | + | t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive { | |||
| 143 | + | return false | |||
| 144 | + | } | |||
| 145 | + | rc := t.sess.renderCoordinator() | |||
| 146 | + | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error { | |||
| 150 | + | if t.ac == nil { | |||
| 151 | + | return errAttachmentTransition | |||
| 152 | + | } | |||
| 153 | + | t.ac.sendMu.Lock() | |||
| 154 | + | defer t.ac.sendMu.Unlock() | |||
| 155 | + | if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) || | |||
| 156 | + | !t.effect.beginTransportSend(t.transport) { | |||
| 157 | + | return errAttachmentTransition | |||
| 158 | + | } | |||
| 159 | + | err := t.transport.transport.Send(frame) | |||
| 160 | + | if err != nil { | |||
| 161 | + | t.effect.reportTransportFailure(t.transport) | |||
| 162 | + | } | |||
| 163 | + | t.effect.endTransportSend() | |||
| 164 | + | return err | |||
| 165 | + | } | |||
A internal/usecase/daemon/attachment_transition.go +223 -0
| @@ -0,0 +1,223 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import "errors" | |
| 4 | + | ||
| 5 | + | var errAttachmentTransition = errors.New("attachment transition is no longer valid") | |
| 6 | + | ||
| 7 | + | type attachmentTransitionRequest struct { | |
| 8 | + | source *session | |
| 9 | + | target *session | |
| 10 | + | next *attachedClient | |
| 11 | + | expectedRole attachmentRole | |
| 12 | + | targetRole attachmentRole | |
| 13 | + | expectedTransport transportSnapshot | |
| 14 | + | sourceToken *attachmentRoleToken | |
| 15 | + | action string | |
| 16 | + | activateTargetTab bool | |
| 17 | + | targetTabIndex int | |
| 18 | + | copySourceEnvironment bool | |
| 19 | + | // preserveRole commits navigation state without replacing attachment | |
| 20 | + | // ownership. It is used for same-session picker selections after the exact | |
| 21 | + | // initiating capability has been frozen and revalidated. | |
| 22 | + | preserveRole bool | |
| 23 | + | expectedSourceTab *tab | |
| 24 | + | createTargetLocked func() (*session, error) | |
| 25 | + | ready bool | |
| 26 | + | expectedTargetCurrent *attachedClient | |
| 27 | + | expectedTargetTransport transportSnapshot | |
| 28 | + | preflighted bool | |
| 29 | + | roleEffectsFrozen bool | |
| 30 | + | expectedTargetTransportInterrupted bool | |
| 31 | + | // activationBarrier is used when promoting an already connected snatched | |
| 32 | + | // attachment. Its role gate is deadline-drained before sendMu is acquired, | |
| 33 | + | // preserving the gate -> sendMu -> daemon/routing/session publication order. | |
| 34 | + | activationBarrier bool | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool { | |
| 38 | + | return token.sess == source && token.ac == req.next && token.role == req.expectedRole && | |
| 39 | + | token.transport.transport == req.expectedTransport.transport && | |
| 40 | + | token.transport.incarnation == req.expectedTransport.incarnation | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // transitionSourceTokenCurrentLocked requires source.mu and, for an active | |
| 44 | + | // source, sourceCoordinator.mu. This is the canonical handoff preflight: every | |
| 45 | + | // client-originated navigation, delete, or create intent is bound to one exact | |
| 46 | + | // role generation, transport incarnation, and coordinator lease. | |
| 47 | + | func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { | |
| 48 | + | if token.sess != source || token.ac != req.next || token.role != req.expectedRole || | |
| 49 | + | token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role || | |
| 50 | + | req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) { | |
| 51 | + | return false | |
| 52 | + | } | |
| 53 | + | if token.role != attachmentActive { | |
| 54 | + | return true | |
| 55 | + | } | |
| 56 | + | return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next && | |
| 57 | + | sourceCoordinator.leaseCurrentLocked(token.lease, true) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | type attachmentTransitionResult struct { | |
| 61 | + | published attachmentRoleToken | |
| 62 | + | displaced attachmentRoleToken | |
| 63 | + | displacedInterrupted bool | |
| 64 | + | cleanups []renderLifecycleCleanup | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | type attachmentTransitionParticipants struct { | |
| 68 | + | clients []*attachedClient | |
| 69 | + | interrupts []roleTransportInterrupt | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // snapshotAttachmentTransition discovers the exact participants while d.mu is | |
| 73 | + | // held. It releases every architecture lock before any role effect is ended or | |
| 74 | + | // any gate is frozen and drained. | |
| 75 | + | func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionRequest, attachmentTransitionParticipants, error) { | |
| 76 | + | d.mu.Lock() | |
| 77 | + | defer d.mu.Unlock() | |
| 78 | + | ||
| 79 | + | source := req.source | |
| 80 | + | if source == nil { | |
| 81 | + | source = req.target | |
| 82 | + | } | |
| 83 | + | if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing || | |
| 84 | + | d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target || | |
| 85 | + | req.activationBarrier && (req.sourceToken == nil || source != req.target || | |
| 86 | + | req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) { | |
| 87 | + | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |
| 88 | + | } | |
| 89 | + | if req.target != nil { | |
| 90 | + | req.target.mu.Lock() | |
| 91 | + | req.expectedTargetCurrent = req.target.client | |
| 92 | + | if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive { | |
| 93 | + | req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot() | |
| 94 | + | } | |
| 95 | + | req.target.mu.Unlock() | |
| 96 | + | } | |
| 97 | + | if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) { | |
| 98 | + | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |
| 99 | + | } | |
| 100 | + | req.preflighted = true | |
| 101 | + | return req, attachmentTransitionParticipants{ | |
| 102 | + | clients: []*attachedClient{req.next, req.expectedTargetCurrent}, | |
| 103 | + | interrupts: []roleTransportInterrupt{{ | |
| 104 | + | ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport, | |
| 105 | + | }}, | |
| 106 | + | }, nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // freezeAttachmentTransition drains the snapshotted participants without any | |
| 110 | + | // architecture lock held. The returned guard remains frozen through atomic | |
| 111 | + | // publication and must be released by the caller. | |
| 112 | + | func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenRoleEffectGates, bool, error) { | |
| 113 | + | if d.afterRoleEffectParticipantsSnapshotted != nil { | |
| 114 | + | d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients) | |
| 115 | + | } | |
| 116 | + | var drainDeadline *roleEffectDrainDeadline | |
| 117 | + | var drainDone func() <-chan struct{} | |
| 118 | + | if req.activationBarrier { | |
| 119 | + | drainDeadline = newRoleEffectDrainDeadline(d.clock) | |
| 120 | + | drainDone = drainDeadline.Done | |
| 121 | + | } | |
| 122 | + | frozen := freezeRoleEffectGatesInterruptingObservedUntil(participants.interrupts, drainDone, func(ac *attachedClient) { | |
| 123 | + | if d.afterRoleEffectGateFrozen != nil { | |
| 124 | + | d.afterRoleEffectGateFrozen(req.action, ac) | |
| 125 | + | } | |
| 126 | + | }, participants.clients...) | |
| 127 | + | if drainDeadline != nil { | |
| 128 | + | drainDeadline.stop() | |
| 129 | + | } | |
| 130 | + | if !frozen.acquired || !frozen.drained { | |
| 131 | + | if req.activationBarrier { | |
| 132 | + | _ = req.next.closeCapturedTransport(req.expectedTransport.transport) | |
| 133 | + | return frozen, false, errSendTimedOut | |
| 134 | + | } | |
| 135 | + | return frozen, false, errAttachmentTransition | |
| 136 | + | } | |
| 137 | + | interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport) | |
| 138 | + | if d.afterRoleEffectsFrozen != nil { | |
| 139 | + | d.afterRoleEffectsFrozen() | |
| 140 | + | } | |
| 141 | + | return frozen, interrupted, nil | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // publishAttachmentTransition revalidates the frozen source, creates an | |
| 145 | + | // optional target, and performs publication while d.mu remains held. | |
| 146 | + | func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 147 | + | d.mu.Lock() | |
| 148 | + | defer d.mu.Unlock() | |
| 149 | + | ||
| 150 | + | req.roleEffectsFrozen = true | |
| 151 | + | if req.target == nil { | |
| 152 | + | if !d.transitionSourcePreflightLocked(req) { | |
| 153 | + | return attachmentTransitionResult{}, errAttachmentTransition | |
| 154 | + | } | |
| 155 | + | var err error | |
| 156 | + | req.target, err = req.createTargetLocked() | |
| 157 | + | if err != nil { | |
| 158 | + | return attachmentTransitionResult{}, err | |
| 159 | + | } | |
| 160 | + | req.expectedTargetCurrent = nil | |
| 161 | + | } | |
| 162 | + | return d.transitionAttachmentLocked(req) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // prepareActivatedAttachment rebases the first active paint while the caller | |
| 166 | + | // still owns the activation barrier (and therefore sendMu). | |
| 167 | + | func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) { | |
| 168 | + | ac := result.published.ac | |
| 169 | + | if ac.output != nil { | |
| 170 | + | ac.output.rebase() | |
| 171 | + | } | |
| 172 | + | ac.captureFrames = nil | |
| 173 | + | d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false) | |
| 174 | + | // The first paint still requests a reset, but the old panel dependency chain | |
| 175 | + | // has already been rebased atomically under the activation barrier. | |
| 176 | + | result.published.rebase = false | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 180 | + | req, participants, err := d.snapshotAttachmentTransition(req) | |
| 181 | + | if err != nil { | |
| 182 | + | return attachmentTransitionResult{}, err | |
| 183 | + | } | |
| 184 | + | if req.sourceToken != nil { | |
| 185 | + | d.endActionRoleEffect(req.sourceToken.effect, req.action) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | frozen, interrupted, err := d.freezeAttachmentTransition(req, participants) | |
| 189 | + | defer frozen.unfreeze() | |
| 190 | + | if err != nil { | |
| 191 | + | return attachmentTransitionResult{}, err | |
| 192 | + | } | |
| 193 | + | req.expectedTargetTransportInterrupted = interrupted | |
| 194 | + | ||
| 195 | + | var releaseActivation func() | |
| 196 | + | if req.activationBarrier { | |
| 197 | + | releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken) | |
| 198 | + | if err != nil { | |
| 199 | + | return attachmentTransitionResult{}, err | |
| 200 | + | } | |
| 201 | + | defer func() { | |
| 202 | + | if releaseActivation != nil { | |
| 203 | + | releaseActivation() | |
| 204 | + | } | |
| 205 | + | }() | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | result, err := d.publishAttachmentTransition(req) | |
| 209 | + | if err != nil { | |
| 210 | + | return result, err | |
| 211 | + | } | |
| 212 | + | if req.activationBarrier && result.published.role == attachmentActive { | |
| 213 | + | d.prepareActivatedAttachment(&result) | |
| 214 | + | } | |
| 215 | + | if releaseActivation != nil { | |
| 216 | + | releaseActivation() | |
| 217 | + | releaseActivation = nil | |
| 218 | + | } | |
| 219 | + | if result.published.role == attachmentActive { | |
| 220 | + | result.published.ac.clearSnatchedInput() | |
| 221 | + | } | |
| 222 | + | return result, nil | |
| 223 | + | } | |
| @@ -0,0 +1,223 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "errors" | |||
| 4 | + | ||||
| 5 | + | var errAttachmentTransition = errors.New("attachment transition is no longer valid") | |||
| 6 | + | ||||
| 7 | + | type attachmentTransitionRequest struct { | |||
| 8 | + | source *session | |||
| 9 | + | target *session | |||
| 10 | + | next *attachedClient | |||
| 11 | + | expectedRole attachmentRole | |||
| 12 | + | targetRole attachmentRole | |||
| 13 | + | expectedTransport transportSnapshot | |||
| 14 | + | sourceToken *attachmentRoleToken | |||
| 15 | + | action string | |||
| 16 | + | activateTargetTab bool | |||
| 17 | + | targetTabIndex int | |||
| 18 | + | copySourceEnvironment bool | |||
| 19 | + | // preserveRole commits navigation state without replacing attachment | |||
| 20 | + | // ownership. It is used for same-session picker selections after the exact | |||
| 21 | + | // initiating capability has been frozen and revalidated. | |||
| 22 | + | preserveRole bool | |||
| 23 | + | expectedSourceTab *tab | |||
| 24 | + | createTargetLocked func() (*session, error) | |||
| 25 | + | ready bool | |||
| 26 | + | expectedTargetCurrent *attachedClient | |||
| 27 | + | expectedTargetTransport transportSnapshot | |||
| 28 | + | preflighted bool | |||
| 29 | + | roleEffectsFrozen bool | |||
| 30 | + | expectedTargetTransportInterrupted bool | |||
| 31 | + | // activationBarrier is used when promoting an already connected snatched | |||
| 32 | + | // attachment. Its role gate is deadline-drained before sendMu is acquired, | |||
| 33 | + | // preserving the gate -> sendMu -> daemon/routing/session publication order. | |||
| 34 | + | activationBarrier bool | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool { | |||
| 38 | + | return token.sess == source && token.ac == req.next && token.role == req.expectedRole && | |||
| 39 | + | token.transport.transport == req.expectedTransport.transport && | |||
| 40 | + | token.transport.incarnation == req.expectedTransport.incarnation | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // transitionSourceTokenCurrentLocked requires source.mu and, for an active | |||
| 44 | + | // source, sourceCoordinator.mu. This is the canonical handoff preflight: every | |||
| 45 | + | // client-originated navigation, delete, or create intent is bound to one exact | |||
| 46 | + | // role generation, transport incarnation, and coordinator lease. | |||
| 47 | + | func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { | |||
| 48 | + | if token.sess != source || token.ac != req.next || token.role != req.expectedRole || | |||
| 49 | + | token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role || | |||
| 50 | + | req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) { | |||
| 51 | + | return false | |||
| 52 | + | } | |||
| 53 | + | if token.role != attachmentActive { | |||
| 54 | + | return true | |||
| 55 | + | } | |||
| 56 | + | return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next && | |||
| 57 | + | sourceCoordinator.leaseCurrentLocked(token.lease, true) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | type attachmentTransitionResult struct { | |||
| 61 | + | published attachmentRoleToken | |||
| 62 | + | displaced attachmentRoleToken | |||
| 63 | + | displacedInterrupted bool | |||
| 64 | + | cleanups []renderLifecycleCleanup | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | type attachmentTransitionParticipants struct { | |||
| 68 | + | clients []*attachedClient | |||
| 69 | + | interrupts []roleTransportInterrupt | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // snapshotAttachmentTransition discovers the exact participants while d.mu is | |||
| 73 | + | // held. It releases every architecture lock before any role effect is ended or | |||
| 74 | + | // any gate is frozen and drained. | |||
| 75 | + | func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionRequest, attachmentTransitionParticipants, error) { | |||
| 76 | + | d.mu.Lock() | |||
| 77 | + | defer d.mu.Unlock() | |||
| 78 | + | ||||
| 79 | + | source := req.source | |||
| 80 | + | if source == nil { | |||
| 81 | + | source = req.target | |||
| 82 | + | } | |||
| 83 | + | if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing || | |||
| 84 | + | d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target || | |||
| 85 | + | req.activationBarrier && (req.sourceToken == nil || source != req.target || | |||
| 86 | + | req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) { | |||
| 87 | + | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |||
| 88 | + | } | |||
| 89 | + | if req.target != nil { | |||
| 90 | + | req.target.mu.Lock() | |||
| 91 | + | req.expectedTargetCurrent = req.target.client | |||
| 92 | + | if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive { | |||
| 93 | + | req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot() | |||
| 94 | + | } | |||
| 95 | + | req.target.mu.Unlock() | |||
| 96 | + | } | |||
| 97 | + | if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) { | |||
| 98 | + | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |||
| 99 | + | } | |||
| 100 | + | req.preflighted = true | |||
| 101 | + | return req, attachmentTransitionParticipants{ | |||
| 102 | + | clients: []*attachedClient{req.next, req.expectedTargetCurrent}, | |||
| 103 | + | interrupts: []roleTransportInterrupt{{ | |||
| 104 | + | ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport, | |||
| 105 | + | }}, | |||
| 106 | + | }, nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // freezeAttachmentTransition drains the snapshotted participants without any | |||
| 110 | + | // architecture lock held. The returned guard remains frozen through atomic | |||
| 111 | + | // publication and must be released by the caller. | |||
| 112 | + | func (d *Daemon) freezeAttachmentTransition(req attachmentTransitionRequest, participants attachmentTransitionParticipants) (frozenRoleEffectGates, bool, error) { | |||
| 113 | + | if d.afterRoleEffectParticipantsSnapshotted != nil { | |||
| 114 | + | d.afterRoleEffectParticipantsSnapshotted(req.action, participants.clients) | |||
| 115 | + | } | |||
| 116 | + | var drainDeadline *roleEffectDrainDeadline | |||
| 117 | + | var drainDone func() <-chan struct{} | |||
| 118 | + | if req.activationBarrier { | |||
| 119 | + | drainDeadline = newRoleEffectDrainDeadline(d.clock) | |||
| 120 | + | drainDone = drainDeadline.Done | |||
| 121 | + | } | |||
| 122 | + | frozen := freezeRoleEffectGatesInterruptingObservedUntil(participants.interrupts, drainDone, func(ac *attachedClient) { | |||
| 123 | + | if d.afterRoleEffectGateFrozen != nil { | |||
| 124 | + | d.afterRoleEffectGateFrozen(req.action, ac) | |||
| 125 | + | } | |||
| 126 | + | }, participants.clients...) | |||
| 127 | + | if drainDeadline != nil { | |||
| 128 | + | drainDeadline.stop() | |||
| 129 | + | } | |||
| 130 | + | if !frozen.acquired || !frozen.drained { | |||
| 131 | + | if req.activationBarrier { | |||
| 132 | + | _ = req.next.closeCapturedTransport(req.expectedTransport.transport) | |||
| 133 | + | return frozen, false, errSendTimedOut | |||
| 134 | + | } | |||
| 135 | + | return frozen, false, errAttachmentTransition | |||
| 136 | + | } | |||
| 137 | + | interrupted := frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport) | |||
| 138 | + | if d.afterRoleEffectsFrozen != nil { | |||
| 139 | + | d.afterRoleEffectsFrozen() | |||
| 140 | + | } | |||
| 141 | + | return frozen, interrupted, nil | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // publishAttachmentTransition revalidates the frozen source, creates an | |||
| 145 | + | // optional target, and performs publication while d.mu remains held. | |||
| 146 | + | func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |||
| 147 | + | d.mu.Lock() | |||
| 148 | + | defer d.mu.Unlock() | |||
| 149 | + | ||||
| 150 | + | req.roleEffectsFrozen = true | |||
| 151 | + | if req.target == nil { | |||
| 152 | + | if !d.transitionSourcePreflightLocked(req) { | |||
| 153 | + | return attachmentTransitionResult{}, errAttachmentTransition | |||
| 154 | + | } | |||
| 155 | + | var err error | |||
| 156 | + | req.target, err = req.createTargetLocked() | |||
| 157 | + | if err != nil { | |||
| 158 | + | return attachmentTransitionResult{}, err | |||
| 159 | + | } | |||
| 160 | + | req.expectedTargetCurrent = nil | |||
| 161 | + | } | |||
| 162 | + | return d.transitionAttachmentLocked(req) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // prepareActivatedAttachment rebases the first active paint while the caller | |||
| 166 | + | // still owns the activation barrier (and therefore sendMu). | |||
| 167 | + | func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) { | |||
| 168 | + | ac := result.published.ac | |||
| 169 | + | if ac.output != nil { | |||
| 170 | + | ac.output.rebase() | |||
| 171 | + | } | |||
| 172 | + | ac.captureFrames = nil | |||
| 173 | + | d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false) | |||
| 174 | + | // The first paint still requests a reset, but the old panel dependency chain | |||
| 175 | + | // has already been rebased atomically under the activation barrier. | |||
| 176 | + | result.published.rebase = false | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |||
| 180 | + | req, participants, err := d.snapshotAttachmentTransition(req) | |||
| 181 | + | if err != nil { | |||
| 182 | + | return attachmentTransitionResult{}, err | |||
| 183 | + | } | |||
| 184 | + | if req.sourceToken != nil { | |||
| 185 | + | d.endActionRoleEffect(req.sourceToken.effect, req.action) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | frozen, interrupted, err := d.freezeAttachmentTransition(req, participants) | |||
| 189 | + | defer frozen.unfreeze() | |||
| 190 | + | if err != nil { | |||
| 191 | + | return attachmentTransitionResult{}, err | |||
| 192 | + | } | |||
| 193 | + | req.expectedTargetTransportInterrupted = interrupted | |||
| 194 | + | ||||
| 195 | + | var releaseActivation func() | |||
| 196 | + | if req.activationBarrier { | |||
| 197 | + | releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken) | |||
| 198 | + | if err != nil { | |||
| 199 | + | return attachmentTransitionResult{}, err | |||
| 200 | + | } | |||
| 201 | + | defer func() { | |||
| 202 | + | if releaseActivation != nil { | |||
| 203 | + | releaseActivation() | |||
| 204 | + | } | |||
| 205 | + | }() | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | result, err := d.publishAttachmentTransition(req) | |||
| 209 | + | if err != nil { | |||
| 210 | + | return result, err | |||
| 211 | + | } | |||
| 212 | + | if req.activationBarrier && result.published.role == attachmentActive { | |||
| 213 | + | d.prepareActivatedAttachment(&result) | |||
| 214 | + | } | |||
| 215 | + | if releaseActivation != nil { | |||
| 216 | + | releaseActivation() | |||
| 217 | + | releaseActivation = nil | |||
| 218 | + | } | |||
| 219 | + | if result.published.role == attachmentActive { | |||
| 220 | + | result.published.ac.clearSnatchedInput() | |||
| 221 | + | } | |||
| 222 | + | return result, nil | |||
| 223 | + | } | |||
A internal/usecase/daemon/attachment_transition_publish.go +324 -0
| @@ -0,0 +1,324 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | // transitionSourcePreflightLocked validates an initiating role while its gate | |
| 4 | + | // is frozen and d.mu is held. No caller may create or delete a target before | |
| 5 | + | // this succeeds. | |
| 6 | + | func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool { | |
| 7 | + | if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source { | |
| 8 | + | return false | |
| 9 | + | } | |
| 10 | + | d.notices.routingMu.Lock() | |
| 11 | + | defer d.notices.routingMu.Unlock() | |
| 12 | + | req.source.mu.Lock() | |
| 13 | + | defer req.source.mu.Unlock() | |
| 14 | + | coordinator := req.source.renderCoordinator() | |
| 15 | + | if coordinator == nil { | |
| 16 | + | return false | |
| 17 | + | } | |
| 18 | + | coordinator.mu.Lock() | |
| 19 | + | defer coordinator.mu.Unlock() | |
| 20 | + | if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) { | |
| 21 | + | return false | |
| 22 | + | } | |
| 23 | + | return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |
| 27 | + | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | |
| 28 | + | return false | |
| 29 | + | } | |
| 30 | + | d.mu.Lock() | |
| 31 | + | defer d.mu.Unlock() | |
| 32 | + | if d.closing || d.sessions[token.sess.id] != token.sess { | |
| 33 | + | return false | |
| 34 | + | } | |
| 35 | + | d.notices.routingMu.Lock() | |
| 36 | + | defer d.notices.routingMu.Unlock() | |
| 37 | + | token.sess.mu.Lock() | |
| 38 | + | defer token.sess.mu.Unlock() | |
| 39 | + | coordinator := token.sess.renderCoordinator() | |
| 40 | + | if coordinator == nil { | |
| 41 | + | return false | |
| 42 | + | } | |
| 43 | + | coordinator.mu.Lock() | |
| 44 | + | defer coordinator.mu.Unlock() | |
| 45 | + | req := attachmentTransitionRequest{ | |
| 46 | + | source: token.sess, next: token.ac, expectedRole: token.role, | |
| 47 | + | expectedTransport: token.transport, | |
| 48 | + | } | |
| 49 | + | return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | |
| 53 | + | if expected == nil { | |
| 54 | + | return true | |
| 55 | + | } | |
| 56 | + | if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected { | |
| 57 | + | return false | |
| 58 | + | } | |
| 59 | + | expected.mu.Lock() | |
| 60 | + | defer expected.mu.Unlock() | |
| 61 | + | return expected.floating.state != floatingVisible | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // transitionAttachmentLocked is the publication half of transitionAttachment. | |
| 65 | + | // Caller holds d.mu after the lock-free gate freeze/drain has completed. | |
| 66 | + | func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 67 | + | d.notices.routingMu.Lock() | |
| 68 | + | defer d.notices.routingMu.Unlock() | |
| 69 | + | return d.transitionAttachmentRoutedLocked(req) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // attachmentPublication owns the ordered session and coordinator locks and | |
| 73 | + | // accumulates the values needed to construct postcommit capabilities. | |
| 74 | + | type attachmentPublication struct { | |
| 75 | + | req attachmentTransitionRequest | |
| 76 | + | source *session | |
| 77 | + | old *attachedClient | |
| 78 | + | sourceCoordinator *renderCoordinator | |
| 79 | + | targetCoordinator *renderCoordinator | |
| 80 | + | unlockSessions func() | |
| 81 | + | unlockCoordinators func() | |
| 82 | + | displacedTransport transportSnapshot | |
| 83 | + | nextGeneration uint64 | |
| 84 | + | displacedGeneration uint64 | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // unlock releases coordinator locks before the ordered session locks. The | |
| 88 | + | // caller still owns d.mu and notices.routingMu; no external I/O is permitted. | |
| 89 | + | func (p *attachmentPublication) unlock() { | |
| 90 | + | p.unlockCoordinators() | |
| 91 | + | p.unlockSessions() | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // preflightAttachmentPublicationLocked revalidates source and target authority | |
| 95 | + | // and acquires every lock needed by publication. Caller holds d.mu and | |
| 96 | + | // notices.routingMu. On success, the returned guard owns ordered session and | |
| 97 | + | // coordinator locks until unlock is called. | |
| 98 | + | func (d *Daemon) preflightAttachmentPublicationLocked(req attachmentTransitionRequest) (*attachmentPublication, error) { | |
| 99 | + | source := req.source | |
| 100 | + | if source == nil { | |
| 101 | + | source = req.target | |
| 102 | + | } | |
| 103 | + | if !req.preflighted || !req.roleEffectsFrozen || | |
| 104 | + | !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) || | |
| 105 | + | source == nil || req.target == nil || req.next == nil || d.closing || | |
| 106 | + | d.sessions[source.id] != source || d.sessions[req.target.id] != req.target || | |
| 107 | + | req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) { | |
| 108 | + | return nil, errAttachmentTransition | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // Install the target coordinator before taking both session locks. Lease | |
| 112 | + | // binding remains part of the atomic publication below. | |
| 113 | + | var targetCoordinator *renderCoordinator | |
| 114 | + | if req.targetRole == attachmentActive { | |
| 115 | + | targetCoordinator = d.ensureRenderCoordinator(req.target) | |
| 116 | + | } | |
| 117 | + | sourceCoordinator := source.renderCoordinator() | |
| 118 | + | unlockSessions := lockAttachmentSessions(source, req.target) | |
| 119 | + | if source.attachmentRoleLocked(req.next) != req.expectedRole || | |
| 120 | + | !req.next.transportSnapshotCurrent(req.expectedTransport) { | |
| 121 | + | unlockSessions() | |
| 122 | + | return nil, errAttachmentTransition | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | old := req.target.client | |
| 126 | + | if old != req.expectedTargetCurrent || source != req.target && old == req.next { | |
| 127 | + | unlockSessions() | |
| 128 | + | return nil, errAttachmentTransition | |
| 129 | + | } | |
| 130 | + | publication := &attachmentPublication{ | |
| 131 | + | req: req, source: source, old: old, | |
| 132 | + | sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator, | |
| 133 | + | unlockSessions: unlockSessions, | |
| 134 | + | unlockCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator), | |
| 135 | + | } | |
| 136 | + | if req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | |
| 137 | + | !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) { | |
| 138 | + | publication.unlock() | |
| 139 | + | return nil, errAttachmentTransition | |
| 140 | + | } | |
| 141 | + | if targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) { | |
| 142 | + | publication.unlock() | |
| 143 | + | return nil, errAttachmentTransition | |
| 144 | + | } | |
| 145 | + | if req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | |
| 146 | + | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) { | |
| 147 | + | publication.unlock() | |
| 148 | + | return nil, errAttachmentTransition | |
| 149 | + | } | |
| 150 | + | if req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) { | |
| 151 | + | publication.unlock() | |
| 152 | + | return nil, errAttachmentTransition | |
| 153 | + | } | |
| 154 | + | return publication, nil | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // applyTargetStateLocked commits navigation metadata before role ownership, as | |
| 158 | + | // part of the same locked publication. It reports role-preserving transitions. | |
| 159 | + | func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool { | |
| 160 | + | req := publication.req | |
| 161 | + | if req.targetRole == attachmentActive { | |
| 162 | + | d.demoteParkedActiveForSessionLocked(req.target) | |
| 163 | + | } | |
| 164 | + | if req.copySourceEnvironment { | |
| 165 | + | req.target.terminal = publication.source.terminal | |
| 166 | + | req.target.env = copyEnvironment(publication.source.env) | |
| 167 | + | } | |
| 168 | + | if req.activateTargetTab { | |
| 169 | + | req.target.active = req.targetTabIndex | |
| 170 | + | } | |
| 171 | + | return req.preserveRole | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // publishAttachmentOwnershipLocked atomically changes session membership, | |
| 175 | + | // generations, and currentSession while all role gates remain frozen. | |
| 176 | + | func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |
| 177 | + | req := publication.req | |
| 178 | + | if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next { | |
| 179 | + | publication.displacedTransport = publication.old.transportSnapshot() | |
| 180 | + | } | |
| 181 | + | if publication.source != req.target { | |
| 182 | + | if publication.source.client == req.next { | |
| 183 | + | publication.source.client = nil | |
| 184 | + | } | |
| 185 | + | delete(publication.source.snatched, req.next) | |
| 186 | + | } | |
| 187 | + | if req.targetRole == attachmentActive { | |
| 188 | + | delete(req.target.snatched, req.next) | |
| 189 | + | if publication.old != nil && publication.old != req.next { | |
| 190 | + | req.target.addSnatchedLocked(publication.old) | |
| 191 | + | } | |
| 192 | + | req.target.client = req.next | |
| 193 | + | } else { | |
| 194 | + | req.target.addSnatchedLocked(req.next) | |
| 195 | + | } | |
| 196 | + | publication.nextGeneration = req.next.roleGeneration.Add(1) | |
| 197 | + | if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive { | |
| 198 | + | publication.displacedGeneration = publication.old.roleGeneration.Add(1) | |
| 199 | + | } | |
| 200 | + | req.next.setSession(req.target) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // buildAttachmentPostcommitPlanLocked binds coordinator lifecycle cleanup and | |
| 204 | + | // constructs the exact published tokens after ownership has committed. | |
| 205 | + | func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) attachmentTransitionResult { | |
| 206 | + | req := publication.req | |
| 207 | + | result := attachmentTransitionResult{} | |
| 208 | + | if publication.source != req.target && req.expectedRole == attachmentActive && publication.sourceCoordinator != nil { | |
| 209 | + | result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next)) | |
| 210 | + | } | |
| 211 | + | var lease *attachmentLease | |
| 212 | + | if publication.targetCoordinator != nil { | |
| 213 | + | var cleanup renderLifecycleCleanup | |
| 214 | + | cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready) | |
| 215 | + | result.cleanups = append(result.cleanups, cleanup) | |
| 216 | + | } | |
| 217 | + | result.published = attachmentRoleToken{ | |
| 218 | + | sess: req.target, | |
| 219 | + | ac: req.next, | |
| 220 | + | role: req.targetRole, | |
| 221 | + | generation: publication.nextGeneration, | |
| 222 | + | transport: req.expectedTransport, | |
| 223 | + | lease: lease, | |
| 224 | + | rebase: publication.source != req.target && req.expectedRole == attachmentActive, | |
| 225 | + | } | |
| 226 | + | if publication.displacedGeneration != 0 { | |
| 227 | + | result.displaced = attachmentRoleToken{ | |
| 228 | + | sess: req.target, | |
| 229 | + | ac: publication.old, | |
| 230 | + | role: attachmentSnatched, | |
| 231 | + | generation: publication.displacedGeneration, | |
| 232 | + | transport: publication.displacedTransport, | |
| 233 | + | } | |
| 234 | + | result.displacedInterrupted = req.expectedTargetTransportInterrupted | |
| 235 | + | } | |
| 236 | + | // Membership, currentSession, lease, generation, and both exact admission | |
| 237 | + | // capabilities become visible as one frozen publication. The gates remain | |
| 238 | + | // frozen until every architecture lock has been released. | |
| 239 | + | if req.roleEffectsFrozen { | |
| 240 | + | req.next.publishFrozenRoleCapability(result.published) | |
| 241 | + | if result.displaced.ac != nil { | |
| 242 | + | result.displaced.ac.publishFrozenRoleCapability(result.displaced) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | return result | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // transitionAttachmentRoutedLocked performs the centralized role publication. | |
| 249 | + | // Caller holds d.mu and notices.routingMu. Every fallible lifecycle, role, and | |
| 250 | + | // transport check occurs before session.client is changed. Coordinator | |
| 251 | + | // finalization after that commit point cannot roll ownership back. | |
| 252 | + | func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 253 | + | publication, err := d.preflightAttachmentPublicationLocked(req) | |
| 254 | + | if err != nil { | |
| 255 | + | return attachmentTransitionResult{}, err | |
| 256 | + | } | |
| 257 | + | defer publication.unlock() | |
| 258 | + | ||
| 259 | + | if d.applyTargetStateLocked(publication) { | |
| 260 | + | return attachmentTransitionResult{published: *req.sourceToken}, nil | |
| 261 | + | } | |
| 262 | + | publishAttachmentOwnershipLocked(publication) | |
| 263 | + | return buildAttachmentPostcommitPlanLocked(publication), nil | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func validAttachmentTransitionRole(role attachmentRole, expected bool) bool { | |
| 267 | + | if role == attachmentActive || role == attachmentSnatched { | |
| 268 | + | return true | |
| 269 | + | } | |
| 270 | + | return expected && role == attachmentDetached | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // lockAttachmentSessions gives every two-session transition one stable order. | |
| 274 | + | // Session IDs are immutable and unique while their lifecycles are registered. | |
| 275 | + | func lockAttachmentSessions(a, b *session) func() { | |
| 276 | + | if a == b { | |
| 277 | + | a.mu.Lock() | |
| 278 | + | return a.mu.Unlock | |
| 279 | + | } | |
| 280 | + | first, second := a, b | |
| 281 | + | if first.id > second.id { | |
| 282 | + | first, second = second, first | |
| 283 | + | } | |
| 284 | + | first.mu.Lock() | |
| 285 | + | second.mu.Lock() | |
| 286 | + | return func() { | |
| 287 | + | second.mu.Unlock() | |
| 288 | + | first.mu.Unlock() | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // lockAttachmentCoordinators follows the same immutable session-ID order as | |
| 293 | + | // lockAttachmentSessions. Callers already hold the ordered session locks. | |
| 294 | + | func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() { | |
| 295 | + | if aCoordinator == nil && bCoordinator == nil { | |
| 296 | + | return func() {} | |
| 297 | + | } | |
| 298 | + | if a == b || aCoordinator == bCoordinator { | |
| 299 | + | coordinator := aCoordinator | |
| 300 | + | if coordinator == nil { | |
| 301 | + | coordinator = bCoordinator | |
| 302 | + | } | |
| 303 | + | coordinator.mu.Lock() | |
| 304 | + | return coordinator.mu.Unlock | |
| 305 | + | } | |
| 306 | + | first, second := aCoordinator, bCoordinator | |
| 307 | + | if a.id > b.id { | |
| 308 | + | first, second = second, first | |
| 309 | + | } | |
| 310 | + | if first != nil { | |
| 311 | + | first.mu.Lock() | |
| 312 | + | } | |
| 313 | + | if second != nil { | |
| 314 | + | second.mu.Lock() | |
| 315 | + | } | |
| 316 | + | return func() { | |
| 317 | + | if second != nil { | |
| 318 | + | second.mu.Unlock() | |
| 319 | + | } | |
| 320 | + | if first != nil { | |
| 321 | + | first.mu.Unlock() | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | } | |
| @@ -0,0 +1,324 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | // transitionSourcePreflightLocked validates an initiating role while its gate | |||
| 4 | + | // is frozen and d.mu is held. No caller may create or delete a target before | |||
| 5 | + | // this succeeds. | |||
| 6 | + | func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool { | |||
| 7 | + | if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source { | |||
| 8 | + | return false | |||
| 9 | + | } | |||
| 10 | + | d.notices.routingMu.Lock() | |||
| 11 | + | defer d.notices.routingMu.Unlock() | |||
| 12 | + | req.source.mu.Lock() | |||
| 13 | + | defer req.source.mu.Unlock() | |||
| 14 | + | coordinator := req.source.renderCoordinator() | |||
| 15 | + | if coordinator == nil { | |||
| 16 | + | return false | |||
| 17 | + | } | |||
| 18 | + | coordinator.mu.Lock() | |||
| 19 | + | defer coordinator.mu.Unlock() | |||
| 20 | + | if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) { | |||
| 21 | + | return false | |||
| 22 | + | } | |||
| 23 | + | return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |||
| 27 | + | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | |||
| 28 | + | return false | |||
| 29 | + | } | |||
| 30 | + | d.mu.Lock() | |||
| 31 | + | defer d.mu.Unlock() | |||
| 32 | + | if d.closing || d.sessions[token.sess.id] != token.sess { | |||
| 33 | + | return false | |||
| 34 | + | } | |||
| 35 | + | d.notices.routingMu.Lock() | |||
| 36 | + | defer d.notices.routingMu.Unlock() | |||
| 37 | + | token.sess.mu.Lock() | |||
| 38 | + | defer token.sess.mu.Unlock() | |||
| 39 | + | coordinator := token.sess.renderCoordinator() | |||
| 40 | + | if coordinator == nil { | |||
| 41 | + | return false | |||
| 42 | + | } | |||
| 43 | + | coordinator.mu.Lock() | |||
| 44 | + | defer coordinator.mu.Unlock() | |||
| 45 | + | req := attachmentTransitionRequest{ | |||
| 46 | + | source: token.sess, next: token.ac, expectedRole: token.role, | |||
| 47 | + | expectedTransport: token.transport, | |||
| 48 | + | } | |||
| 49 | + | return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | |||
| 53 | + | if expected == nil { | |||
| 54 | + | return true | |||
| 55 | + | } | |||
| 56 | + | if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected { | |||
| 57 | + | return false | |||
| 58 | + | } | |||
| 59 | + | expected.mu.Lock() | |||
| 60 | + | defer expected.mu.Unlock() | |||
| 61 | + | return expected.floating.state != floatingVisible | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // transitionAttachmentLocked is the publication half of transitionAttachment. | |||
| 65 | + | // Caller holds d.mu after the lock-free gate freeze/drain has completed. | |||
| 66 | + | func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |||
| 67 | + | d.notices.routingMu.Lock() | |||
| 68 | + | defer d.notices.routingMu.Unlock() | |||
| 69 | + | return d.transitionAttachmentRoutedLocked(req) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // attachmentPublication owns the ordered session and coordinator locks and | |||
| 73 | + | // accumulates the values needed to construct postcommit capabilities. | |||
| 74 | + | type attachmentPublication struct { | |||
| 75 | + | req attachmentTransitionRequest | |||
| 76 | + | source *session | |||
| 77 | + | old *attachedClient | |||
| 78 | + | sourceCoordinator *renderCoordinator | |||
| 79 | + | targetCoordinator *renderCoordinator | |||
| 80 | + | unlockSessions func() | |||
| 81 | + | unlockCoordinators func() | |||
| 82 | + | displacedTransport transportSnapshot | |||
| 83 | + | nextGeneration uint64 | |||
| 84 | + | displacedGeneration uint64 | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // unlock releases coordinator locks before the ordered session locks. The | |||
| 88 | + | // caller still owns d.mu and notices.routingMu; no external I/O is permitted. | |||
| 89 | + | func (p *attachmentPublication) unlock() { | |||
| 90 | + | p.unlockCoordinators() | |||
| 91 | + | p.unlockSessions() | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // preflightAttachmentPublicationLocked revalidates source and target authority | |||
| 95 | + | // and acquires every lock needed by publication. Caller holds d.mu and | |||
| 96 | + | // notices.routingMu. On success, the returned guard owns ordered session and | |||
| 97 | + | // coordinator locks until unlock is called. | |||
| 98 | + | func (d *Daemon) preflightAttachmentPublicationLocked(req attachmentTransitionRequest) (*attachmentPublication, error) { | |||
| 99 | + | source := req.source | |||
| 100 | + | if source == nil { | |||
| 101 | + | source = req.target | |||
| 102 | + | } | |||
| 103 | + | if !req.preflighted || !req.roleEffectsFrozen || | |||
| 104 | + | !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) || | |||
| 105 | + | source == nil || req.target == nil || req.next == nil || d.closing || | |||
| 106 | + | d.sessions[source.id] != source || d.sessions[req.target.id] != req.target || | |||
| 107 | + | req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) { | |||
| 108 | + | return nil, errAttachmentTransition | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // Install the target coordinator before taking both session locks. Lease | |||
| 112 | + | // binding remains part of the atomic publication below. | |||
| 113 | + | var targetCoordinator *renderCoordinator | |||
| 114 | + | if req.targetRole == attachmentActive { | |||
| 115 | + | targetCoordinator = d.ensureRenderCoordinator(req.target) | |||
| 116 | + | } | |||
| 117 | + | sourceCoordinator := source.renderCoordinator() | |||
| 118 | + | unlockSessions := lockAttachmentSessions(source, req.target) | |||
| 119 | + | if source.attachmentRoleLocked(req.next) != req.expectedRole || | |||
| 120 | + | !req.next.transportSnapshotCurrent(req.expectedTransport) { | |||
| 121 | + | unlockSessions() | |||
| 122 | + | return nil, errAttachmentTransition | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | old := req.target.client | |||
| 126 | + | if old != req.expectedTargetCurrent || source != req.target && old == req.next { | |||
| 127 | + | unlockSessions() | |||
| 128 | + | return nil, errAttachmentTransition | |||
| 129 | + | } | |||
| 130 | + | publication := &attachmentPublication{ | |||
| 131 | + | req: req, source: source, old: old, | |||
| 132 | + | sourceCoordinator: sourceCoordinator, targetCoordinator: targetCoordinator, | |||
| 133 | + | unlockSessions: unlockSessions, | |||
| 134 | + | unlockCoordinators: lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator), | |||
| 135 | + | } | |||
| 136 | + | if req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | |||
| 137 | + | !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) { | |||
| 138 | + | publication.unlock() | |||
| 139 | + | return nil, errAttachmentTransition | |||
| 140 | + | } | |||
| 141 | + | if targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) { | |||
| 142 | + | publication.unlock() | |||
| 143 | + | return nil, errAttachmentTransition | |||
| 144 | + | } | |||
| 145 | + | if req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | |||
| 146 | + | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) { | |||
| 147 | + | publication.unlock() | |||
| 148 | + | return nil, errAttachmentTransition | |||
| 149 | + | } | |||
| 150 | + | if req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) { | |||
| 151 | + | publication.unlock() | |||
| 152 | + | return nil, errAttachmentTransition | |||
| 153 | + | } | |||
| 154 | + | return publication, nil | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // applyTargetStateLocked commits navigation metadata before role ownership, as | |||
| 158 | + | // part of the same locked publication. It reports role-preserving transitions. | |||
| 159 | + | func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool { | |||
| 160 | + | req := publication.req | |||
| 161 | + | if req.targetRole == attachmentActive { | |||
| 162 | + | d.demoteParkedActiveForSessionLocked(req.target) | |||
| 163 | + | } | |||
| 164 | + | if req.copySourceEnvironment { | |||
| 165 | + | req.target.terminal = publication.source.terminal | |||
| 166 | + | req.target.env = copyEnvironment(publication.source.env) | |||
| 167 | + | } | |||
| 168 | + | if req.activateTargetTab { | |||
| 169 | + | req.target.active = req.targetTabIndex | |||
| 170 | + | } | |||
| 171 | + | return req.preserveRole | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // publishAttachmentOwnershipLocked atomically changes session membership, | |||
| 175 | + | // generations, and currentSession while all role gates remain frozen. | |||
| 176 | + | func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |||
| 177 | + | req := publication.req | |||
| 178 | + | if req.targetRole == attachmentActive && publication.old != nil && publication.old != req.next { | |||
| 179 | + | publication.displacedTransport = publication.old.transportSnapshot() | |||
| 180 | + | } | |||
| 181 | + | if publication.source != req.target { | |||
| 182 | + | if publication.source.client == req.next { | |||
| 183 | + | publication.source.client = nil | |||
| 184 | + | } | |||
| 185 | + | delete(publication.source.snatched, req.next) | |||
| 186 | + | } | |||
| 187 | + | if req.targetRole == attachmentActive { | |||
| 188 | + | delete(req.target.snatched, req.next) | |||
| 189 | + | if publication.old != nil && publication.old != req.next { | |||
| 190 | + | req.target.addSnatchedLocked(publication.old) | |||
| 191 | + | } | |||
| 192 | + | req.target.client = req.next | |||
| 193 | + | } else { | |||
| 194 | + | req.target.addSnatchedLocked(req.next) | |||
| 195 | + | } | |||
| 196 | + | publication.nextGeneration = req.next.roleGeneration.Add(1) | |||
| 197 | + | if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive { | |||
| 198 | + | publication.displacedGeneration = publication.old.roleGeneration.Add(1) | |||
| 199 | + | } | |||
| 200 | + | req.next.setSession(req.target) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // buildAttachmentPostcommitPlanLocked binds coordinator lifecycle cleanup and | |||
| 204 | + | // constructs the exact published tokens after ownership has committed. | |||
| 205 | + | func buildAttachmentPostcommitPlanLocked(publication *attachmentPublication) attachmentTransitionResult { | |||
| 206 | + | req := publication.req | |||
| 207 | + | result := attachmentTransitionResult{} | |||
| 208 | + | if publication.source != req.target && req.expectedRole == attachmentActive && publication.sourceCoordinator != nil { | |||
| 209 | + | result.cleanups = append(result.cleanups, publication.sourceCoordinator.beginDetachLocked(req.next)) | |||
| 210 | + | } | |||
| 211 | + | var lease *attachmentLease | |||
| 212 | + | if publication.targetCoordinator != nil { | |||
| 213 | + | var cleanup renderLifecycleCleanup | |||
| 214 | + | cleanup, lease = publication.targetCoordinator.beginReplaceLocked(publication.old, req.next, req.ready) | |||
| 215 | + | result.cleanups = append(result.cleanups, cleanup) | |||
| 216 | + | } | |||
| 217 | + | result.published = attachmentRoleToken{ | |||
| 218 | + | sess: req.target, | |||
| 219 | + | ac: req.next, | |||
| 220 | + | role: req.targetRole, | |||
| 221 | + | generation: publication.nextGeneration, | |||
| 222 | + | transport: req.expectedTransport, | |||
| 223 | + | lease: lease, | |||
| 224 | + | rebase: publication.source != req.target && req.expectedRole == attachmentActive, | |||
| 225 | + | } | |||
| 226 | + | if publication.displacedGeneration != 0 { | |||
| 227 | + | result.displaced = attachmentRoleToken{ | |||
| 228 | + | sess: req.target, | |||
| 229 | + | ac: publication.old, | |||
| 230 | + | role: attachmentSnatched, | |||
| 231 | + | generation: publication.displacedGeneration, | |||
| 232 | + | transport: publication.displacedTransport, | |||
| 233 | + | } | |||
| 234 | + | result.displacedInterrupted = req.expectedTargetTransportInterrupted | |||
| 235 | + | } | |||
| 236 | + | // Membership, currentSession, lease, generation, and both exact admission | |||
| 237 | + | // capabilities become visible as one frozen publication. The gates remain | |||
| 238 | + | // frozen until every architecture lock has been released. | |||
| 239 | + | if req.roleEffectsFrozen { | |||
| 240 | + | req.next.publishFrozenRoleCapability(result.published) | |||
| 241 | + | if result.displaced.ac != nil { | |||
| 242 | + | result.displaced.ac.publishFrozenRoleCapability(result.displaced) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | return result | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // transitionAttachmentRoutedLocked performs the centralized role publication. | |||
| 249 | + | // Caller holds d.mu and notices.routingMu. Every fallible lifecycle, role, and | |||
| 250 | + | // transport check occurs before session.client is changed. Coordinator | |||
| 251 | + | // finalization after that commit point cannot roll ownership back. | |||
| 252 | + | func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |||
| 253 | + | publication, err := d.preflightAttachmentPublicationLocked(req) | |||
| 254 | + | if err != nil { | |||
| 255 | + | return attachmentTransitionResult{}, err | |||
| 256 | + | } | |||
| 257 | + | defer publication.unlock() | |||
| 258 | + | ||||
| 259 | + | if d.applyTargetStateLocked(publication) { | |||
| 260 | + | return attachmentTransitionResult{published: *req.sourceToken}, nil | |||
| 261 | + | } | |||
| 262 | + | publishAttachmentOwnershipLocked(publication) | |||
| 263 | + | return buildAttachmentPostcommitPlanLocked(publication), nil | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func validAttachmentTransitionRole(role attachmentRole, expected bool) bool { | |||
| 267 | + | if role == attachmentActive || role == attachmentSnatched { | |||
| 268 | + | return true | |||
| 269 | + | } | |||
| 270 | + | return expected && role == attachmentDetached | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // lockAttachmentSessions gives every two-session transition one stable order. | |||
| 274 | + | // Session IDs are immutable and unique while their lifecycles are registered. | |||
| 275 | + | func lockAttachmentSessions(a, b *session) func() { | |||
| 276 | + | if a == b { | |||
| 277 | + | a.mu.Lock() | |||
| 278 | + | return a.mu.Unlock | |||
| 279 | + | } | |||
| 280 | + | first, second := a, b | |||
| 281 | + | if first.id > second.id { | |||
| 282 | + | first, second = second, first | |||
| 283 | + | } | |||
| 284 | + | first.mu.Lock() | |||
| 285 | + | second.mu.Lock() | |||
| 286 | + | return func() { | |||
| 287 | + | second.mu.Unlock() | |||
| 288 | + | first.mu.Unlock() | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // lockAttachmentCoordinators follows the same immutable session-ID order as | |||
| 293 | + | // lockAttachmentSessions. Callers already hold the ordered session locks. | |||
| 294 | + | func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() { | |||
| 295 | + | if aCoordinator == nil && bCoordinator == nil { | |||
| 296 | + | return func() {} | |||
| 297 | + | } | |||
| 298 | + | if a == b || aCoordinator == bCoordinator { | |||
| 299 | + | coordinator := aCoordinator | |||
| 300 | + | if coordinator == nil { | |||
| 301 | + | coordinator = bCoordinator | |||
| 302 | + | } | |||
| 303 | + | coordinator.mu.Lock() | |||
| 304 | + | return coordinator.mu.Unlock | |||
| 305 | + | } | |||
| 306 | + | first, second := aCoordinator, bCoordinator | |||
| 307 | + | if a.id > b.id { | |||
| 308 | + | first, second = second, first | |||
| 309 | + | } | |||
| 310 | + | if first != nil { | |||
| 311 | + | first.mu.Lock() | |||
| 312 | + | } | |||
| 313 | + | if second != nil { | |||
| 314 | + | second.mu.Lock() | |||
| 315 | + | } | |||
| 316 | + | return func() { | |||
| 317 | + | if second != nil { | |||
| 318 | + | second.mu.Unlock() | |||
| 319 | + | } | |||
| 320 | + | if first != nil { | |||
| 321 | + | first.mu.Unlock() | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | } | |||
M internal/usecase/daemon/daemon.go +2 -3
| @@ -682,12 +682,11 @@ func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) { | |||
| 682 | 682 | func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) { | |
| 683 | 683 | d.mu.Lock() | |
| 684 | 684 | d.closing = true | |
| 685 | - | for token, parked := range d.parked { | |
| 686 | - | d.removeParkedLocked(token, parked) | |
| 687 | - | } | |
| 685 | + | parkedRetirements := d.purgeAllParkedLocked() | |
| 688 | 686 | snapshot := d.sessionsSnapshotLocked() | |
| 689 | 687 | empty := len(snapshot) == 0 | |
| 690 | 688 | d.mu.Unlock() | |
| 689 | + | finishParkedAttachmentRetirements(parkedRetirements) | |
| 691 | 690 | d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot)) | |
| 692 | 691 | if empty { | |
| 693 | 692 | d.doneOnce.Do(func() { close(d.done) }) | |
| @@ -682,12 +682,11 @@ func (d *Daemon) shutdownAll(reason uint8) (checkpointIncomplete bool) { | |||||
| 682 | func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) { | 682 | func (d *Daemon) shutdownAllWithSnapshotDeadline(reason uint8, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) { | ||
| 683 | d.mu.Lock() | 683 | d.mu.Lock() | ||
| 684 | d.closing = true | 684 | d.closing = true | ||
| 685 | - | for token, parked := range d.parked { | 685 | + | parkedRetirements := d.purgeAllParkedLocked() |
| 686 | - | d.removeParkedLocked(token, parked) | |||
| 687 | - | } | |||
| 688 | snapshot := d.sessionsSnapshotLocked() | 686 | snapshot := d.sessionsSnapshotLocked() | ||
| 689 | empty := len(snapshot) == 0 | 687 | empty := len(snapshot) == 0 | ||
| 690 | d.mu.Unlock() | 688 | d.mu.Unlock() | ||
| 689 | + | finishParkedAttachmentRetirements(parkedRetirements) | |||
| 691 | d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot)) | 690 | d.log.Info("graceful shutdown begin", "reason", reason, "sessions", len(snapshot)) | ||
| 692 | if empty { | 691 | if empty { | ||
| 693 | d.doneOnce.Do(func() { close(d.done) }) | 692 | d.doneOnce.Do(func() { close(d.done) }) | ||
M internal/usecase/daemon/resume.go +46 -4
| @@ -133,13 +133,55 @@ func (d *Daemon) demoteParkedActiveForSessionLocked(sess *session) { | |||
| 133 | 133 | } | |
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | - | // purgeParkedForSessionLocked invalidates every parked token for sess. Caller | |
| 137 | - | // holds d.mu. It is reserved for terminal session kill and daemon shutdown. | |
| 138 | - | func (d *Daemon) purgeParkedForSessionLocked(sess *session) { | |
| 136 | + | type parkedAttachmentRetirement struct { | |
| 137 | + | parked *parkedAttachment | |
| 138 | + | transport transportSnapshot | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement { | |
| 142 | + | delete(d.parked, token) | |
| 143 | + | parked.ac.clearPreviousSession() | |
| 144 | + | parked.ac.resumeToken = 0 | |
| 145 | + | parked.ac.parked = false | |
| 146 | + | parked.ac.roleGeneration.Add(1) | |
| 147 | + | parked.ac.setSession(nil) | |
| 148 | + | return parkedAttachmentRetirement{ | |
| 149 | + | parked: parked, | |
| 150 | + | transport: parked.ac.transportSnapshot(), | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // purgeParkedForSessionLocked invalidates every parked token for sess and | |
| 155 | + | // returns the external resources that must be retired after releasing d.mu. | |
| 156 | + | // It is reserved for terminal session kill and daemon shutdown. | |
| 157 | + | func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement { | |
| 158 | + | var retirements []parkedAttachmentRetirement | |
| 139 | 159 | for token, parked := range d.parked { | |
| 140 | 160 | if parked.sess == sess { | |
| 141 | - | d.removeParkedLocked(token, parked) | |
| 161 | + | retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked)) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | return retirements | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement { | |
| 168 | + | retirements := make([]parkedAttachmentRetirement, 0, len(d.parked)) | |
| 169 | + | for token, parked := range d.parked { | |
| 170 | + | retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked)) | |
| 171 | + | } | |
| 172 | + | return retirements | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // finishParkedAttachmentRetirements stops timers and closes transports without | |
| 176 | + | // holding daemon, session, coordinator, or pane locks. | |
| 177 | + | func finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) { | |
| 178 | + | for _, retirement := range retirements { | |
| 179 | + | if retirement.parked.timer != nil { | |
| 180 | + | retirement.parked.timer.Stop() | |
| 142 | 181 | } | |
| 182 | + | retirement.parked.closeDone() | |
| 183 | + | ac := retirement.parked.ac | |
| 184 | + | _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport)) | |
| 143 | 185 | } | |
| 144 | 186 | } | |
| 145 | 187 | ||
| @@ -133,13 +133,55 @@ func (d *Daemon) demoteParkedActiveForSessionLocked(sess *session) { | |||||
| 133 | } | 133 | } | ||
| 134 | } | 134 | } | ||
| 135 | 135 | ||||
| 136 | - | // purgeParkedForSessionLocked invalidates every parked token for sess. Caller | 136 | + | type parkedAttachmentRetirement struct { |
| 137 | - | // holds d.mu. It is reserved for terminal session kill and daemon shutdown. | 137 | + | parked *parkedAttachment |
| 138 | - | func (d *Daemon) purgeParkedForSessionLocked(sess *session) { | 138 | + | transport transportSnapshot |
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement { | |||
| 142 | + | delete(d.parked, token) | |||
| 143 | + | parked.ac.clearPreviousSession() | |||
| 144 | + | parked.ac.resumeToken = 0 | |||
| 145 | + | parked.ac.parked = false | |||
| 146 | + | parked.ac.roleGeneration.Add(1) | |||
| 147 | + | parked.ac.setSession(nil) | |||
| 148 | + | return parkedAttachmentRetirement{ | |||
| 149 | + | parked: parked, | |||
| 150 | + | transport: parked.ac.transportSnapshot(), | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // purgeParkedForSessionLocked invalidates every parked token for sess and | |||
| 155 | + | // returns the external resources that must be retired after releasing d.mu. | |||
| 156 | + | // It is reserved for terminal session kill and daemon shutdown. | |||
| 157 | + | func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement { | |||
| 158 | + | var retirements []parkedAttachmentRetirement | |||
| 139 | for token, parked := range d.parked { | 159 | for token, parked := range d.parked { | ||
| 140 | if parked.sess == sess { | 160 | if parked.sess == sess { | ||
| 141 | - | d.removeParkedLocked(token, parked) | 161 | + | retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked)) |
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | return retirements | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement { | |||
| 168 | + | retirements := make([]parkedAttachmentRetirement, 0, len(d.parked)) | |||
| 169 | + | for token, parked := range d.parked { | |||
| 170 | + | retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked)) | |||
| 171 | + | } | |||
| 172 | + | return retirements | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // finishParkedAttachmentRetirements stops timers and closes transports without | |||
| 176 | + | // holding daemon, session, coordinator, or pane locks. | |||
| 177 | + | func finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) { | |||
| 178 | + | for _, retirement := range retirements { | |||
| 179 | + | if retirement.parked.timer != nil { | |||
| 180 | + | retirement.parked.timer.Stop() | |||
| 142 | } | 181 | } | ||
| 182 | + | retirement.parked.closeDone() | |||
| 183 | + | ac := retirement.parked.ac | |||
| 184 | + | _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport)) | |||
| 143 | } | 185 | } | ||
| 144 | } | 186 | } | ||
| 145 | 187 | ||||
M internal/usecase/daemon/resume_test.go +7 -5
| @@ -520,18 +520,19 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) { | |||
| 520 | 520 | func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | |
| 521 | 521 | for _, tc := range []struct { | |
| 522 | 522 | name string | |
| 523 | - | discard func(*Daemon, uint64, *parkedAttachment) | |
| 523 | + | discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement | |
| 524 | 524 | }{ | |
| 525 | 525 | { | |
| 526 | 526 | name: "expiry", | |
| 527 | - | discard: func(d *Daemon, token uint64, parked *parkedAttachment) { | |
| 527 | + | discard: func(d *Daemon, token uint64, parked *parkedAttachment) []parkedAttachmentRetirement { | |
| 528 | 528 | d.removeParkedLocked(token, parked) | |
| 529 | + | return nil | |
| 529 | 530 | }, | |
| 530 | 531 | }, | |
| 531 | 532 | { | |
| 532 | 533 | name: "session purge", | |
| 533 | - | discard: func(d *Daemon, _ uint64, parked *parkedAttachment) { | |
| 534 | - | d.purgeParkedForSessionLocked(parked.sess) | |
| 534 | + | discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement { | |
| 535 | + | return d.purgeParkedForSessionLocked(parked.sess) | |
| 535 | 536 | }, | |
| 536 | 537 | }, | |
| 537 | 538 | } { | |
| @@ -549,8 +550,9 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | |||
| 549 | 550 | d.mu.Lock() | |
| 550 | 551 | parked := d.parked[ac.resumeToken] | |
| 551 | 552 | require.NotNil(t, parked) | |
| 552 | - | tc.discard(d, ac.resumeToken, parked) | |
| 553 | + | retirements := tc.discard(d, ac.resumeToken, parked) | |
| 553 | 554 | d.mu.Unlock() | |
| 555 | + | finishParkedAttachmentRetirements(retirements) | |
| 554 | 556 | ||
| 555 | 557 | require.Nil(t, ac.previousSession.Get()) | |
| 556 | 558 | }) | |
| @@ -520,18 +520,19 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) { | |||||
| 520 | func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | 520 | func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | ||
| 521 | for _, tc := range []struct { | 521 | for _, tc := range []struct { | ||
| 522 | name string | 522 | name string | ||
| 523 | - | discard func(*Daemon, uint64, *parkedAttachment) | 523 | + | discard func(*Daemon, uint64, *parkedAttachment) []parkedAttachmentRetirement |
| 524 | }{ | 524 | }{ | ||
| 525 | { | 525 | { | ||
| 526 | name: "expiry", | 526 | name: "expiry", | ||
| 527 | - | discard: func(d *Daemon, token uint64, parked *parkedAttachment) { | 527 | + | discard: func(d *Daemon, token uint64, parked *parkedAttachment) []parkedAttachmentRetirement { |
| 528 | d.removeParkedLocked(token, parked) | 528 | d.removeParkedLocked(token, parked) | ||
| 529 | + | return nil | |||
| 529 | }, | 530 | }, | ||
| 530 | }, | 531 | }, | ||
| 531 | { | 532 | { | ||
| 532 | name: "session purge", | 533 | name: "session purge", | ||
| 533 | - | discard: func(d *Daemon, _ uint64, parked *parkedAttachment) { | 534 | + | discard: func(d *Daemon, _ uint64, parked *parkedAttachment) []parkedAttachmentRetirement { |
| 534 | - | d.purgeParkedForSessionLocked(parked.sess) | 535 | + | return d.purgeParkedForSessionLocked(parked.sess) |
| 535 | }, | 536 | }, | ||
| 536 | }, | 537 | }, | ||
| 537 | } { | 538 | } { | ||
| @@ -549,8 +550,9 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | |||||
| 549 | d.mu.Lock() | 550 | d.mu.Lock() | ||
| 550 | parked := d.parked[ac.resumeToken] | 551 | parked := d.parked[ac.resumeToken] | ||
| 551 | require.NotNil(t, parked) | 552 | require.NotNil(t, parked) | ||
| 552 | - | tc.discard(d, ac.resumeToken, parked) | 553 | + | retirements := tc.discard(d, ac.resumeToken, parked) |
| 553 | d.mu.Unlock() | 554 | d.mu.Unlock() | ||
| 555 | + | finishParkedAttachmentRetirements(retirements) | |||
| 554 | 556 | ||||
| 555 | require.Nil(t, ac.previousSession.Get()) | 557 | require.Nil(t, ac.previousSession.Get()) | ||
| 556 | }) | 558 | }) | ||
A internal/usecase/daemon/role_effect_freeze.go +263 -0
| @@ -0,0 +1,263 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sort" | |
| 5 | + | "sync" | |
| 6 | + | "sync/atomic" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | type roleEffectDrainDeadline struct { | |
| 12 | + | clock ports.Clock | |
| 13 | + | done chan struct{} | |
| 14 | + | stopCh chan struct{} | |
| 15 | + | finished chan struct{} | |
| 16 | + | startOnce sync.Once | |
| 17 | + | stopOnce sync.Once | |
| 18 | + | started atomic.Bool | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline { | |
| 22 | + | return &roleEffectDrainDeadline{ | |
| 23 | + | clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}), | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (d *roleEffectDrainDeadline) stop() { | |
| 28 | + | if d == nil { | |
| 29 | + | return | |
| 30 | + | } | |
| 31 | + | d.stopOnce.Do(func() { | |
| 32 | + | // Claim an unstarted deadline without allocating a timer. Done is never | |
| 33 | + | // called after the owning freeze/drain operation returns. | |
| 34 | + | d.startOnce.Do(func() {}) | |
| 35 | + | if !d.started.Load() { | |
| 36 | + | return | |
| 37 | + | } | |
| 38 | + | close(d.stopCh) | |
| 39 | + | <-d.finished | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (d *roleEffectDrainDeadline) Done() <-chan struct{} { | |
| 44 | + | if d == nil { | |
| 45 | + | return nil | |
| 46 | + | } | |
| 47 | + | d.startOnce.Do(func() { | |
| 48 | + | d.started.Store(true) | |
| 49 | + | timer := d.clock.NewTimer(detachNotifyTimeout) | |
| 50 | + | go func() { | |
| 51 | + | defer close(d.finished) | |
| 52 | + | select { | |
| 53 | + | case <-timer.C(): | |
| 54 | + | close(d.done) | |
| 55 | + | case <-d.stopCh: | |
| 56 | + | timer.Stop() | |
| 57 | + | } | |
| 58 | + | }() | |
| 59 | + | }) | |
| 60 | + | return d.done | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | type frozenRoleEffectGates struct { | |
| 64 | + | clients []*attachedClient | |
| 65 | + | interruptions map[*attachedClient]transportSnapshot | |
| 66 | + | acquired bool | |
| 67 | + | drained bool | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool { | |
| 71 | + | interrupted, ok := f.interruptions[ac] | |
| 72 | + | return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | type roleTransportInterrupt struct { | |
| 76 | + | ac *attachedClient | |
| 77 | + | transport transportSnapshot | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // freezeRoleEffectGates establishes transition priority in immutable attachment | |
| 81 | + | // order, then drains earlier admissions. It must be called without daemon, | |
| 82 | + | // routing, session, coordinator, send, overlay, router, or pane locks held. | |
| 83 | + | func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates { | |
| 84 | + | return freezeRoleEffectGatesInterrupting(nil, clients...) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // freezeRoleEffectGatesInterrupting may close an exact captured transport only | |
| 88 | + | // while an earlier admitted effect is inside that transport's Send. Closing and | |
| 89 | + | // all drain waits happen without the gate mutex or an architecture lock held. | |
| 90 | + | func freezeRoleEffectGatesInterrupting(interrupts []roleTransportInterrupt, clients ...*attachedClient) frozenRoleEffectGates { | |
| 91 | + | return freezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates { | |
| 95 | + | return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound | |
| 99 | + | // for ordered acquisition and drain. Acquisition is all-or-nothing: if the | |
| 100 | + | // bound expires behind another owner, only gates acquired by this call are | |
| 101 | + | // rolled back, in reverse order. Once the complete set is owned, a terminal | |
| 102 | + | // teardown may proceed when done closes during drain; late tickets then retire | |
| 103 | + | // against an invalidated capability without pinning the session owner | |
| 104 | + | // indefinitely. | |
| 105 | + | func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates { | |
| 106 | + | ordered := orderedRoleEffectClients(clients) | |
| 107 | + | frozen, acquired := acquireRoleEffectGates(ordered, done, afterFrozen) | |
| 108 | + | if !acquired { | |
| 109 | + | return frozenRoleEffectGates{} | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | interruptByClient := roleTransportInterruptsByClient(interrupts) | |
| 113 | + | frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient)) | |
| 114 | + | interruptFrozenRoleTransports(ordered, interruptByClient) | |
| 115 | + | frozen.acquired = true | |
| 116 | + | frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, done) | |
| 117 | + | return frozen | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // orderedRoleEffectClients snapshots unique participants in immutable lock | |
| 121 | + | // order. Assigning a previously unused order does not freeze or wait on a gate. | |
| 122 | + | func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient { | |
| 123 | + | unique := make(map[*attachedClient]struct{}, len(clients)) | |
| 124 | + | ordered := make([]*attachedClient, 0, len(clients)) | |
| 125 | + | for _, ac := range clients { | |
| 126 | + | if ac == nil { | |
| 127 | + | continue | |
| 128 | + | } | |
| 129 | + | if _, exists := unique[ac]; exists { | |
| 130 | + | continue | |
| 131 | + | } | |
| 132 | + | unique[ac] = struct{}{} | |
| 133 | + | ac.roleEffects.immutableOrder() | |
| 134 | + | ordered = append(ordered, ac) | |
| 135 | + | } | |
| 136 | + | sort.Slice(ordered, func(i, j int) bool { | |
| 137 | + | return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load() | |
| 138 | + | }) | |
| 139 | + | return ordered | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // waitForRoleEffectChangeLocked requires g.mu and returns with g.mu held. A | |
| 143 | + | // true result means the overall deadline won and the caller must stop waiting. | |
| 144 | + | func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{}) bool { | |
| 145 | + | if done == nil { | |
| 146 | + | g.cond.Wait() | |
| 147 | + | return false | |
| 148 | + | } | |
| 149 | + | changed := g.changed | |
| 150 | + | doneCh := done() | |
| 151 | + | g.mu.Unlock() | |
| 152 | + | select { | |
| 153 | + | case <-changed: | |
| 154 | + | case <-doneCh: | |
| 155 | + | } | |
| 156 | + | g.mu.Lock() | |
| 157 | + | select { | |
| 158 | + | case <-doneCh: | |
| 159 | + | return true | |
| 160 | + | default: | |
| 161 | + | return false | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // acquireRoleEffectGates establishes exclusive transition ownership in order. | |
| 166 | + | // It must run without architecture locks. Deadline rollback releases only this | |
| 167 | + | // call's partial acquisition and does so in reverse order. | |
| 168 | + | func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient)) (frozenRoleEffectGates, bool) { | |
| 169 | + | frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))} | |
| 170 | + | for _, ac := range ordered { | |
| 171 | + | g := &ac.roleEffects | |
| 172 | + | g.mu.Lock() | |
| 173 | + | g.initLocked() | |
| 174 | + | for g.phase == roleEffectsFrozen { | |
| 175 | + | if waitForRoleEffectChangeLocked(g, done) { | |
| 176 | + | g.mu.Unlock() | |
| 177 | + | frozen.unfreeze() | |
| 178 | + | return frozenRoleEffectGates{}, false | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | g.phase = roleEffectsFrozen | |
| 182 | + | g.mu.Unlock() | |
| 183 | + | frozen.clients = append(frozen.clients, ac) | |
| 184 | + | if afterFrozen != nil { | |
| 185 | + | afterFrozen(ac) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | return frozen, true | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*attachedClient]transportSnapshot { | |
| 192 | + | byClient := make(map[*attachedClient]transportSnapshot, len(interrupts)) | |
| 193 | + | for _, interrupt := range interrupts { | |
| 194 | + | if interrupt.ac != nil && interrupt.transport.transport != nil { | |
| 195 | + | byClient[interrupt.ac] = interrupt.transport | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | return byClient | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // interruptFrozenRoleTransports snapshots every exact in-flight link while all | |
| 202 | + | // gates are frozen, then closes the complete set before any drain wait. One | |
| 203 | + | // uncooperative Send cannot prevent another participant from being interrupted. | |
| 204 | + | func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) { | |
| 205 | + | toClose := make([]roleTransportInterrupt, 0, len(interruptByClient)) | |
| 206 | + | for _, ac := range ordered { | |
| 207 | + | expected, mayInterrupt := interruptByClient[ac] | |
| 208 | + | if !mayInterrupt { | |
| 209 | + | continue | |
| 210 | + | } | |
| 211 | + | g := &ac.roleEffects | |
| 212 | + | g.mu.Lock() | |
| 213 | + | if gateHasTransportEffectLocked(g, expected) { | |
| 214 | + | toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected}) | |
| 215 | + | } | |
| 216 | + | g.mu.Unlock() | |
| 217 | + | } | |
| 218 | + | for _, interrupt := range toClose { | |
| 219 | + | _ = interrupt.ac.closeCapturedTransport(interrupt.transport.transport) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // drainFrozenRoleEffects waits for admissions made before the freeze. A closed | |
| 224 | + | // deadline permits terminal teardown to continue with the capability frozen; | |
| 225 | + | // late tickets can then retire without pinning the owner indefinitely. | |
| 226 | + | func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool { | |
| 227 | + | for _, ac := range ordered { | |
| 228 | + | g := &ac.roleEffects | |
| 229 | + | g.mu.Lock() | |
| 230 | + | for g.inFlight != 0 { | |
| 231 | + | if waitForRoleEffectChangeLocked(g, done) { | |
| 232 | + | g.mu.Unlock() | |
| 233 | + | return false | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | if expected, mayInterrupt := interruptByClient[ac]; mayInterrupt && | |
| 237 | + | g.failedTransport.transport == expected.transport && g.failedTransport.incarnation == expected.incarnation { | |
| 238 | + | interruptions[ac] = expected | |
| 239 | + | } | |
| 240 | + | g.mu.Unlock() | |
| 241 | + | } | |
| 242 | + | return true | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool { | |
| 246 | + | for _, active := range g.transportEffects { | |
| 247 | + | if active.transport == expected.transport && active.incarnation == expected.incarnation { | |
| 248 | + | return true | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | return false | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (f frozenRoleEffectGates) unfreeze() { | |
| 255 | + | for i := len(f.clients) - 1; i >= 0; i-- { | |
| 256 | + | g := &f.clients[i].roleEffects | |
| 257 | + | g.mu.Lock() | |
| 258 | + | g.initLocked() | |
| 259 | + | g.phase = roleEffectsStable | |
| 260 | + | g.signalLocked() | |
| 261 | + | g.mu.Unlock() | |
| 262 | + | } | |
| 263 | + | } | |
| @@ -0,0 +1,263 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sort" | |||
| 5 | + | "sync" | |||
| 6 | + | "sync/atomic" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | type roleEffectDrainDeadline struct { | |||
| 12 | + | clock ports.Clock | |||
| 13 | + | done chan struct{} | |||
| 14 | + | stopCh chan struct{} | |||
| 15 | + | finished chan struct{} | |||
| 16 | + | startOnce sync.Once | |||
| 17 | + | stopOnce sync.Once | |||
| 18 | + | started atomic.Bool | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline { | |||
| 22 | + | return &roleEffectDrainDeadline{ | |||
| 23 | + | clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}), | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (d *roleEffectDrainDeadline) stop() { | |||
| 28 | + | if d == nil { | |||
| 29 | + | return | |||
| 30 | + | } | |||
| 31 | + | d.stopOnce.Do(func() { | |||
| 32 | + | // Claim an unstarted deadline without allocating a timer. Done is never | |||
| 33 | + | // called after the owning freeze/drain operation returns. | |||
| 34 | + | d.startOnce.Do(func() {}) | |||
| 35 | + | if !d.started.Load() { | |||
| 36 | + | return | |||
| 37 | + | } | |||
| 38 | + | close(d.stopCh) | |||
| 39 | + | <-d.finished | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (d *roleEffectDrainDeadline) Done() <-chan struct{} { | |||
| 44 | + | if d == nil { | |||
| 45 | + | return nil | |||
| 46 | + | } | |||
| 47 | + | d.startOnce.Do(func() { | |||
| 48 | + | d.started.Store(true) | |||
| 49 | + | timer := d.clock.NewTimer(detachNotifyTimeout) | |||
| 50 | + | go func() { | |||
| 51 | + | defer close(d.finished) | |||
| 52 | + | select { | |||
| 53 | + | case <-timer.C(): | |||
| 54 | + | close(d.done) | |||
| 55 | + | case <-d.stopCh: | |||
| 56 | + | timer.Stop() | |||
| 57 | + | } | |||
| 58 | + | }() | |||
| 59 | + | }) | |||
| 60 | + | return d.done | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | type frozenRoleEffectGates struct { | |||
| 64 | + | clients []*attachedClient | |||
| 65 | + | interruptions map[*attachedClient]transportSnapshot | |||
| 66 | + | acquired bool | |||
| 67 | + | drained bool | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool { | |||
| 71 | + | interrupted, ok := f.interruptions[ac] | |||
| 72 | + | return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | type roleTransportInterrupt struct { | |||
| 76 | + | ac *attachedClient | |||
| 77 | + | transport transportSnapshot | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // freezeRoleEffectGates establishes transition priority in immutable attachment | |||
| 81 | + | // order, then drains earlier admissions. It must be called without daemon, | |||
| 82 | + | // routing, session, coordinator, send, overlay, router, or pane locks held. | |||
| 83 | + | func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates { | |||
| 84 | + | return freezeRoleEffectGatesInterrupting(nil, clients...) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // freezeRoleEffectGatesInterrupting may close an exact captured transport only | |||
| 88 | + | // while an earlier admitted effect is inside that transport's Send. Closing and | |||
| 89 | + | // all drain waits happen without the gate mutex or an architecture lock held. | |||
| 90 | + | func freezeRoleEffectGatesInterrupting(interrupts []roleTransportInterrupt, clients ...*attachedClient) frozenRoleEffectGates { | |||
| 91 | + | return freezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates { | |||
| 95 | + | return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound | |||
| 99 | + | // for ordered acquisition and drain. Acquisition is all-or-nothing: if the | |||
| 100 | + | // bound expires behind another owner, only gates acquired by this call are | |||
| 101 | + | // rolled back, in reverse order. Once the complete set is owned, a terminal | |||
| 102 | + | // teardown may proceed when done closes during drain; late tickets then retire | |||
| 103 | + | // against an invalidated capability without pinning the session owner | |||
| 104 | + | // indefinitely. | |||
| 105 | + | func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates { | |||
| 106 | + | ordered := orderedRoleEffectClients(clients) | |||
| 107 | + | frozen, acquired := acquireRoleEffectGates(ordered, done, afterFrozen) | |||
| 108 | + | if !acquired { | |||
| 109 | + | return frozenRoleEffectGates{} | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | interruptByClient := roleTransportInterruptsByClient(interrupts) | |||
| 113 | + | frozen.interruptions = make(map[*attachedClient]transportSnapshot, len(interruptByClient)) | |||
| 114 | + | interruptFrozenRoleTransports(ordered, interruptByClient) | |||
| 115 | + | frozen.acquired = true | |||
| 116 | + | frozen.drained = drainFrozenRoleEffects(ordered, interruptByClient, frozen.interruptions, done) | |||
| 117 | + | return frozen | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // orderedRoleEffectClients snapshots unique participants in immutable lock | |||
| 121 | + | // order. Assigning a previously unused order does not freeze or wait on a gate. | |||
| 122 | + | func orderedRoleEffectClients(clients []*attachedClient) []*attachedClient { | |||
| 123 | + | unique := make(map[*attachedClient]struct{}, len(clients)) | |||
| 124 | + | ordered := make([]*attachedClient, 0, len(clients)) | |||
| 125 | + | for _, ac := range clients { | |||
| 126 | + | if ac == nil { | |||
| 127 | + | continue | |||
| 128 | + | } | |||
| 129 | + | if _, exists := unique[ac]; exists { | |||
| 130 | + | continue | |||
| 131 | + | } | |||
| 132 | + | unique[ac] = struct{}{} | |||
| 133 | + | ac.roleEffects.immutableOrder() | |||
| 134 | + | ordered = append(ordered, ac) | |||
| 135 | + | } | |||
| 136 | + | sort.Slice(ordered, func(i, j int) bool { | |||
| 137 | + | return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load() | |||
| 138 | + | }) | |||
| 139 | + | return ordered | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // waitForRoleEffectChangeLocked requires g.mu and returns with g.mu held. A | |||
| 143 | + | // true result means the overall deadline won and the caller must stop waiting. | |||
| 144 | + | func waitForRoleEffectChangeLocked(g *roleEffectGate, done func() <-chan struct{}) bool { | |||
| 145 | + | if done == nil { | |||
| 146 | + | g.cond.Wait() | |||
| 147 | + | return false | |||
| 148 | + | } | |||
| 149 | + | changed := g.changed | |||
| 150 | + | doneCh := done() | |||
| 151 | + | g.mu.Unlock() | |||
| 152 | + | select { | |||
| 153 | + | case <-changed: | |||
| 154 | + | case <-doneCh: | |||
| 155 | + | } | |||
| 156 | + | g.mu.Lock() | |||
| 157 | + | select { | |||
| 158 | + | case <-doneCh: | |||
| 159 | + | return true | |||
| 160 | + | default: | |||
| 161 | + | return false | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // acquireRoleEffectGates establishes exclusive transition ownership in order. | |||
| 166 | + | // It must run without architecture locks. Deadline rollback releases only this | |||
| 167 | + | // call's partial acquisition and does so in reverse order. | |||
| 168 | + | func acquireRoleEffectGates(ordered []*attachedClient, done func() <-chan struct{}, afterFrozen func(*attachedClient)) (frozenRoleEffectGates, bool) { | |||
| 169 | + | frozen := frozenRoleEffectGates{clients: make([]*attachedClient, 0, len(ordered))} | |||
| 170 | + | for _, ac := range ordered { | |||
| 171 | + | g := &ac.roleEffects | |||
| 172 | + | g.mu.Lock() | |||
| 173 | + | g.initLocked() | |||
| 174 | + | for g.phase == roleEffectsFrozen { | |||
| 175 | + | if waitForRoleEffectChangeLocked(g, done) { | |||
| 176 | + | g.mu.Unlock() | |||
| 177 | + | frozen.unfreeze() | |||
| 178 | + | return frozenRoleEffectGates{}, false | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | g.phase = roleEffectsFrozen | |||
| 182 | + | g.mu.Unlock() | |||
| 183 | + | frozen.clients = append(frozen.clients, ac) | |||
| 184 | + | if afterFrozen != nil { | |||
| 185 | + | afterFrozen(ac) | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | return frozen, true | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func roleTransportInterruptsByClient(interrupts []roleTransportInterrupt) map[*attachedClient]transportSnapshot { | |||
| 192 | + | byClient := make(map[*attachedClient]transportSnapshot, len(interrupts)) | |||
| 193 | + | for _, interrupt := range interrupts { | |||
| 194 | + | if interrupt.ac != nil && interrupt.transport.transport != nil { | |||
| 195 | + | byClient[interrupt.ac] = interrupt.transport | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | return byClient | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | // interruptFrozenRoleTransports snapshots every exact in-flight link while all | |||
| 202 | + | // gates are frozen, then closes the complete set before any drain wait. One | |||
| 203 | + | // uncooperative Send cannot prevent another participant from being interrupted. | |||
| 204 | + | func interruptFrozenRoleTransports(ordered []*attachedClient, interruptByClient map[*attachedClient]transportSnapshot) { | |||
| 205 | + | toClose := make([]roleTransportInterrupt, 0, len(interruptByClient)) | |||
| 206 | + | for _, ac := range ordered { | |||
| 207 | + | expected, mayInterrupt := interruptByClient[ac] | |||
| 208 | + | if !mayInterrupt { | |||
| 209 | + | continue | |||
| 210 | + | } | |||
| 211 | + | g := &ac.roleEffects | |||
| 212 | + | g.mu.Lock() | |||
| 213 | + | if gateHasTransportEffectLocked(g, expected) { | |||
| 214 | + | toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected}) | |||
| 215 | + | } | |||
| 216 | + | g.mu.Unlock() | |||
| 217 | + | } | |||
| 218 | + | for _, interrupt := range toClose { | |||
| 219 | + | _ = interrupt.ac.closeCapturedTransport(interrupt.transport.transport) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // drainFrozenRoleEffects waits for admissions made before the freeze. A closed | |||
| 224 | + | // deadline permits terminal teardown to continue with the capability frozen; | |||
| 225 | + | // late tickets can then retire without pinning the owner indefinitely. | |||
| 226 | + | func drainFrozenRoleEffects(ordered []*attachedClient, interruptByClient, interruptions map[*attachedClient]transportSnapshot, done func() <-chan struct{}) bool { | |||
| 227 | + | for _, ac := range ordered { | |||
| 228 | + | g := &ac.roleEffects | |||
| 229 | + | g.mu.Lock() | |||
| 230 | + | for g.inFlight != 0 { | |||
| 231 | + | if waitForRoleEffectChangeLocked(g, done) { | |||
| 232 | + | g.mu.Unlock() | |||
| 233 | + | return false | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | if expected, mayInterrupt := interruptByClient[ac]; mayInterrupt && | |||
| 237 | + | g.failedTransport.transport == expected.transport && g.failedTransport.incarnation == expected.incarnation { | |||
| 238 | + | interruptions[ac] = expected | |||
| 239 | + | } | |||
| 240 | + | g.mu.Unlock() | |||
| 241 | + | } | |||
| 242 | + | return true | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool { | |||
| 246 | + | for _, active := range g.transportEffects { | |||
| 247 | + | if active.transport == expected.transport && active.incarnation == expected.incarnation { | |||
| 248 | + | return true | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | return false | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (f frozenRoleEffectGates) unfreeze() { | |||
| 255 | + | for i := len(f.clients) - 1; i >= 0; i-- { | |||
| 256 | + | g := &f.clients[i].roleEffects | |||
| 257 | + | g.mu.Lock() | |||
| 258 | + | g.initLocked() | |||
| 259 | + | g.phase = roleEffectsStable | |||
| 260 | + | g.signalLocked() | |||
| 261 | + | g.mu.Unlock() | |||
| 262 | + | } | |||
| 263 | + | } | |||
M internal/usecase/daemon/role_effect_gate.go +0 -227
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "sort" | |
| 5 | 4 | "sync" | |
| 6 | 5 | "sync/atomic" | |
| 7 | 6 | ||
| @@ -56,58 +55,6 @@ type roleEffectGate struct { | |||
| 56 | 55 | ||
| 57 | 56 | var nextRoleEffectGateOrder atomic.Uint64 | |
| 58 | 57 | ||
| 59 | - | type roleEffectDrainDeadline struct { | |
| 60 | - | clock ports.Clock | |
| 61 | - | done chan struct{} | |
| 62 | - | stopCh chan struct{} | |
| 63 | - | finished chan struct{} | |
| 64 | - | startOnce sync.Once | |
| 65 | - | stopOnce sync.Once | |
| 66 | - | started atomic.Bool | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline { | |
| 70 | - | return &roleEffectDrainDeadline{ | |
| 71 | - | clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}), | |
| 72 | - | } | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | func (d *roleEffectDrainDeadline) stop() { | |
| 76 | - | if d == nil { | |
| 77 | - | return | |
| 78 | - | } | |
| 79 | - | d.stopOnce.Do(func() { | |
| 80 | - | // Claim an unstarted deadline without allocating a timer. Done is never | |
| 81 | - | // called after the owning freeze/drain operation returns. | |
| 82 | - | d.startOnce.Do(func() {}) | |
| 83 | - | if !d.started.Load() { | |
| 84 | - | return | |
| 85 | - | } | |
| 86 | - | close(d.stopCh) | |
| 87 | - | <-d.finished | |
| 88 | - | }) | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | func (d *roleEffectDrainDeadline) Done() <-chan struct{} { | |
| 92 | - | if d == nil { | |
| 93 | - | return nil | |
| 94 | - | } | |
| 95 | - | d.startOnce.Do(func() { | |
| 96 | - | d.started.Store(true) | |
| 97 | - | timer := d.clock.NewTimer(detachNotifyTimeout) | |
| 98 | - | go func() { | |
| 99 | - | defer close(d.finished) | |
| 100 | - | select { | |
| 101 | - | case <-timer.C(): | |
| 102 | - | close(d.done) | |
| 103 | - | case <-d.stopCh: | |
| 104 | - | timer.Stop() | |
| 105 | - | } | |
| 106 | - | }() | |
| 107 | - | }) | |
| 108 | - | return d.done | |
| 109 | - | } | |
| 110 | - | ||
| 111 | 58 | func (g *roleEffectGate) initLocked() { | |
| 112 | 59 | if g.cond == nil { | |
| 113 | 60 | g.cond = sync.NewCond(&g.mu) | |
| @@ -349,177 +296,3 @@ func (ac *attachedClient) invalidateFrozenRoleCapability() { | |||
| 349 | 296 | g.failedTransport = transportSnapshot{} | |
| 350 | 297 | g.mu.Unlock() | |
| 351 | 298 | } | |
| 352 | - | ||
| 353 | - | type frozenRoleEffectGates struct { | |
| 354 | - | clients []*attachedClient | |
| 355 | - | interruptions map[*attachedClient]transportSnapshot | |
| 356 | - | acquired bool | |
| 357 | - | drained bool | |
| 358 | - | } | |
| 359 | - | ||
| 360 | - | func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool { | |
| 361 | - | interrupted, ok := f.interruptions[ac] | |
| 362 | - | return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation | |
| 363 | - | } | |
| 364 | - | ||
| 365 | - | type roleTransportInterrupt struct { | |
| 366 | - | ac *attachedClient | |
| 367 | - | transport transportSnapshot | |
| 368 | - | } | |
| 369 | - | ||
| 370 | - | // freezeRoleEffectGates establishes transition priority in immutable attachment | |
| 371 | - | // order, then drains earlier admissions. It must be called without daemon, | |
| 372 | - | // routing, session, coordinator, send, overlay, router, or pane locks held. | |
| 373 | - | func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates { | |
| 374 | - | return freezeRoleEffectGatesInterrupting(nil, clients...) | |
| 375 | - | } | |
| 376 | - | ||
| 377 | - | // freezeRoleEffectGatesInterrupting may close an exact captured transport only | |
| 378 | - | // while an earlier admitted effect is inside that transport's Send. Closing and | |
| 379 | - | // all drain waits happen without the gate mutex or an architecture lock held. | |
| 380 | - | func freezeRoleEffectGatesInterrupting(interrupts []roleTransportInterrupt, clients ...*attachedClient) frozenRoleEffectGates { | |
| 381 | - | return freezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...) | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates { | |
| 385 | - | return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...) | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | // freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound | |
| 389 | - | // for ordered acquisition and drain. Acquisition is all-or-nothing: if the | |
| 390 | - | // bound expires behind another owner, only gates acquired by this call are | |
| 391 | - | // rolled back, in reverse order. Once the complete set is owned, a terminal | |
| 392 | - | // teardown may proceed when done closes during drain; late tickets then retire | |
| 393 | - | // against an invalidated capability without pinning the session owner | |
| 394 | - | // indefinitely. | |
| 395 | - | func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates { | |
| 396 | - | unique := make(map[*attachedClient]struct{}, len(clients)) | |
| 397 | - | ordered := make([]*attachedClient, 0, len(clients)) | |
| 398 | - | for _, ac := range clients { | |
| 399 | - | if ac == nil { | |
| 400 | - | continue | |
| 401 | - | } | |
| 402 | - | if _, exists := unique[ac]; exists { | |
| 403 | - | continue | |
| 404 | - | } | |
| 405 | - | unique[ac] = struct{}{} | |
| 406 | - | ac.roleEffects.immutableOrder() | |
| 407 | - | ordered = append(ordered, ac) | |
| 408 | - | } | |
| 409 | - | sort.Slice(ordered, func(i, j int) bool { | |
| 410 | - | return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load() | |
| 411 | - | }) | |
| 412 | - | ||
| 413 | - | frozenClients := make([]*attachedClient, 0, len(ordered)) | |
| 414 | - | for _, ac := range ordered { | |
| 415 | - | g := &ac.roleEffects | |
| 416 | - | g.mu.Lock() | |
| 417 | - | g.initLocked() | |
| 418 | - | for g.phase == roleEffectsFrozen { | |
| 419 | - | if done == nil { | |
| 420 | - | g.cond.Wait() | |
| 421 | - | continue | |
| 422 | - | } | |
| 423 | - | changed := g.changed | |
| 424 | - | doneCh := done() | |
| 425 | - | g.mu.Unlock() | |
| 426 | - | select { | |
| 427 | - | case <-changed: | |
| 428 | - | case <-doneCh: | |
| 429 | - | } | |
| 430 | - | g.mu.Lock() | |
| 431 | - | select { | |
| 432 | - | case <-doneCh: | |
| 433 | - | g.mu.Unlock() | |
| 434 | - | partial := frozenRoleEffectGates{clients: frozenClients} | |
| 435 | - | partial.unfreeze() | |
| 436 | - | return frozenRoleEffectGates{} | |
| 437 | - | default: | |
| 438 | - | } | |
| 439 | - | } | |
| 440 | - | g.phase = roleEffectsFrozen | |
| 441 | - | g.mu.Unlock() | |
| 442 | - | frozenClients = append(frozenClients, ac) | |
| 443 | - | if afterFrozen != nil { | |
| 444 | - | afterFrozen(ac) | |
| 445 | - | } | |
| 446 | - | } | |
| 447 | - | interruptByClient := make(map[*attachedClient]transportSnapshot, len(interrupts)) | |
| 448 | - | for _, interrupt := range interrupts { | |
| 449 | - | if interrupt.ac != nil && interrupt.transport.transport != nil { | |
| 450 | - | interruptByClient[interrupt.ac] = interrupt.transport | |
| 451 | - | } | |
| 452 | - | } | |
| 453 | - | // Snapshot every exact in-flight link while all participant gates are frozen, | |
| 454 | - | // then close the complete set before waiting on any one ticket. One | |
| 455 | - | // uncooperative Send therefore cannot prevent another participant's Send | |
| 456 | - | // from being interrupted. | |
| 457 | - | interruptions := make(map[*attachedClient]transportSnapshot, len(interruptByClient)) | |
| 458 | - | toClose := make([]roleTransportInterrupt, 0, len(interruptByClient)) | |
| 459 | - | for _, ac := range ordered { | |
| 460 | - | expected, mayInterrupt := interruptByClient[ac] | |
| 461 | - | if !mayInterrupt { | |
| 462 | - | continue | |
| 463 | - | } | |
| 464 | - | g := &ac.roleEffects | |
| 465 | - | g.mu.Lock() | |
| 466 | - | if gateHasTransportEffectLocked(g, expected) { | |
| 467 | - | toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected}) | |
| 468 | - | } | |
| 469 | - | g.mu.Unlock() | |
| 470 | - | } | |
| 471 | - | for _, interrupt := range toClose { | |
| 472 | - | _ = interrupt.ac.closeCapturedTransport(interrupt.transport.transport) | |
| 473 | - | } | |
| 474 | - | ||
| 475 | - | for _, ac := range ordered { | |
| 476 | - | g := &ac.roleEffects | |
| 477 | - | g.mu.Lock() | |
| 478 | - | for g.inFlight != 0 { | |
| 479 | - | if done == nil { | |
| 480 | - | g.cond.Wait() | |
| 481 | - | continue | |
| 482 | - | } | |
| 483 | - | changed := g.changed | |
| 484 | - | doneCh := done() | |
| 485 | - | g.mu.Unlock() | |
| 486 | - | select { | |
| 487 | - | case <-changed: | |
| 488 | - | case <-doneCh: | |
| 489 | - | } | |
| 490 | - | g.mu.Lock() | |
| 491 | - | select { | |
| 492 | - | case <-doneCh: | |
| 493 | - | g.mu.Unlock() | |
| 494 | - | return frozenRoleEffectGates{clients: frozenClients, interruptions: interruptions, acquired: true} | |
| 495 | - | default: | |
| 496 | - | } | |
| 497 | - | } | |
| 498 | - | if expected, mayInterrupt := interruptByClient[ac]; mayInterrupt && | |
| 499 | - | g.failedTransport.transport == expected.transport && g.failedTransport.incarnation == expected.incarnation { | |
| 500 | - | interruptions[ac] = expected | |
| 501 | - | } | |
| 502 | - | g.mu.Unlock() | |
| 503 | - | } | |
| 504 | - | return frozenRoleEffectGates{clients: frozenClients, interruptions: interruptions, acquired: true, drained: true} | |
| 505 | - | } | |
| 506 | - | ||
| 507 | - | func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool { | |
| 508 | - | for _, active := range g.transportEffects { | |
| 509 | - | if active.transport == expected.transport && active.incarnation == expected.incarnation { | |
| 510 | - | return true | |
| 511 | - | } | |
| 512 | - | } | |
| 513 | - | return false | |
| 514 | - | } | |
| 515 | - | ||
| 516 | - | func (f frozenRoleEffectGates) unfreeze() { | |
| 517 | - | for i := len(f.clients) - 1; i >= 0; i-- { | |
| 518 | - | g := &f.clients[i].roleEffects | |
| 519 | - | g.mu.Lock() | |
| 520 | - | g.initLocked() | |
| 521 | - | g.phase = roleEffectsStable | |
| 522 | - | g.signalLocked() | |
| 523 | - | g.mu.Unlock() | |
| 524 | - | } | |
| 525 | - | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "sort" | |||
| 5 | "sync" | 4 | "sync" | ||
| 6 | "sync/atomic" | 5 | "sync/atomic" | ||
| 7 | 6 | ||||
| @@ -56,58 +55,6 @@ type roleEffectGate struct { | |||||
| 56 | 55 | ||||
| 57 | var nextRoleEffectGateOrder atomic.Uint64 | 56 | var nextRoleEffectGateOrder atomic.Uint64 | ||
| 58 | 57 | ||||
| 59 | - | type roleEffectDrainDeadline struct { | |||
| 60 | - | clock ports.Clock | |||
| 61 | - | done chan struct{} | |||
| 62 | - | stopCh chan struct{} | |||
| 63 | - | finished chan struct{} | |||
| 64 | - | startOnce sync.Once | |||
| 65 | - | stopOnce sync.Once | |||
| 66 | - | started atomic.Bool | |||
| 67 | - | } | |||
| 68 | - | ||||
| 69 | - | func newRoleEffectDrainDeadline(clock ports.Clock) *roleEffectDrainDeadline { | |||
| 70 | - | return &roleEffectDrainDeadline{ | |||
| 71 | - | clock: clock, done: make(chan struct{}), stopCh: make(chan struct{}), finished: make(chan struct{}), | |||
| 72 | - | } | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | func (d *roleEffectDrainDeadline) stop() { | |||
| 76 | - | if d == nil { | |||
| 77 | - | return | |||
| 78 | - | } | |||
| 79 | - | d.stopOnce.Do(func() { | |||
| 80 | - | // Claim an unstarted deadline without allocating a timer. Done is never | |||
| 81 | - | // called after the owning freeze/drain operation returns. | |||
| 82 | - | d.startOnce.Do(func() {}) | |||
| 83 | - | if !d.started.Load() { | |||
| 84 | - | return | |||
| 85 | - | } | |||
| 86 | - | close(d.stopCh) | |||
| 87 | - | <-d.finished | |||
| 88 | - | }) | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | func (d *roleEffectDrainDeadline) Done() <-chan struct{} { | |||
| 92 | - | if d == nil { | |||
| 93 | - | return nil | |||
| 94 | - | } | |||
| 95 | - | d.startOnce.Do(func() { | |||
| 96 | - | d.started.Store(true) | |||
| 97 | - | timer := d.clock.NewTimer(detachNotifyTimeout) | |||
| 98 | - | go func() { | |||
| 99 | - | defer close(d.finished) | |||
| 100 | - | select { | |||
| 101 | - | case <-timer.C(): | |||
| 102 | - | close(d.done) | |||
| 103 | - | case <-d.stopCh: | |||
| 104 | - | timer.Stop() | |||
| 105 | - | } | |||
| 106 | - | }() | |||
| 107 | - | }) | |||
| 108 | - | return d.done | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | func (g *roleEffectGate) initLocked() { | 58 | func (g *roleEffectGate) initLocked() { | ||
| 112 | if g.cond == nil { | 59 | if g.cond == nil { | ||
| 113 | g.cond = sync.NewCond(&g.mu) | 60 | g.cond = sync.NewCond(&g.mu) | ||
| @@ -349,177 +296,3 @@ func (ac *attachedClient) invalidateFrozenRoleCapability() { | |||||
| 349 | g.failedTransport = transportSnapshot{} | 296 | g.failedTransport = transportSnapshot{} | ||
| 350 | g.mu.Unlock() | 297 | g.mu.Unlock() | ||
| 351 | } | 298 | } | ||
| 352 | - | ||||
| 353 | - | type frozenRoleEffectGates struct { | |||
| 354 | - | clients []*attachedClient | |||
| 355 | - | interruptions map[*attachedClient]transportSnapshot | |||
| 356 | - | acquired bool | |||
| 357 | - | drained bool | |||
| 358 | - | } | |||
| 359 | - | ||||
| 360 | - | func (f frozenRoleEffectGates) interrupted(ac *attachedClient, expected transportSnapshot) bool { | |||
| 361 | - | interrupted, ok := f.interruptions[ac] | |||
| 362 | - | return ok && interrupted.transport == expected.transport && interrupted.incarnation == expected.incarnation | |||
| 363 | - | } | |||
| 364 | - | ||||
| 365 | - | type roleTransportInterrupt struct { | |||
| 366 | - | ac *attachedClient | |||
| 367 | - | transport transportSnapshot | |||
| 368 | - | } | |||
| 369 | - | ||||
| 370 | - | // freezeRoleEffectGates establishes transition priority in immutable attachment | |||
| 371 | - | // order, then drains earlier admissions. It must be called without daemon, | |||
| 372 | - | // routing, session, coordinator, send, overlay, router, or pane locks held. | |||
| 373 | - | func freezeRoleEffectGates(clients ...*attachedClient) frozenRoleEffectGates { | |||
| 374 | - | return freezeRoleEffectGatesInterrupting(nil, clients...) | |||
| 375 | - | } | |||
| 376 | - | ||||
| 377 | - | // freezeRoleEffectGatesInterrupting may close an exact captured transport only | |||
| 378 | - | // while an earlier admitted effect is inside that transport's Send. Closing and | |||
| 379 | - | // all drain waits happen without the gate mutex or an architecture lock held. | |||
| 380 | - | func freezeRoleEffectGatesInterrupting(interrupts []roleTransportInterrupt, clients ...*attachedClient) frozenRoleEffectGates { | |||
| 381 | - | return freezeRoleEffectGatesInterruptingObserved(interrupts, nil, clients...) | |||
| 382 | - | } | |||
| 383 | - | ||||
| 384 | - | func freezeRoleEffectGatesInterruptingObserved(interrupts []roleTransportInterrupt, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates { | |||
| 385 | - | return freezeRoleEffectGatesInterruptingObservedUntil(interrupts, nil, afterFrozen, clients...) | |||
| 386 | - | } | |||
| 387 | - | ||||
| 388 | - | // freezeRoleEffectGatesInterruptingObservedUntil uses done as one overall bound | |||
| 389 | - | // for ordered acquisition and drain. Acquisition is all-or-nothing: if the | |||
| 390 | - | // bound expires behind another owner, only gates acquired by this call are | |||
| 391 | - | // rolled back, in reverse order. Once the complete set is owned, a terminal | |||
| 392 | - | // teardown may proceed when done closes during drain; late tickets then retire | |||
| 393 | - | // against an invalidated capability without pinning the session owner | |||
| 394 | - | // indefinitely. | |||
| 395 | - | func freezeRoleEffectGatesInterruptingObservedUntil(interrupts []roleTransportInterrupt, done func() <-chan struct{}, afterFrozen func(*attachedClient), clients ...*attachedClient) frozenRoleEffectGates { | |||
| 396 | - | unique := make(map[*attachedClient]struct{}, len(clients)) | |||
| 397 | - | ordered := make([]*attachedClient, 0, len(clients)) | |||
| 398 | - | for _, ac := range clients { | |||
| 399 | - | if ac == nil { | |||
| 400 | - | continue | |||
| 401 | - | } | |||
| 402 | - | if _, exists := unique[ac]; exists { | |||
| 403 | - | continue | |||
| 404 | - | } | |||
| 405 | - | unique[ac] = struct{}{} | |||
| 406 | - | ac.roleEffects.immutableOrder() | |||
| 407 | - | ordered = append(ordered, ac) | |||
| 408 | - | } | |||
| 409 | - | sort.Slice(ordered, func(i, j int) bool { | |||
| 410 | - | return ordered[i].roleEffects.order.Load() < ordered[j].roleEffects.order.Load() | |||
| 411 | - | }) | |||
| 412 | - | ||||
| 413 | - | frozenClients := make([]*attachedClient, 0, len(ordered)) | |||
| 414 | - | for _, ac := range ordered { | |||
| 415 | - | g := &ac.roleEffects | |||
| 416 | - | g.mu.Lock() | |||
| 417 | - | g.initLocked() | |||
| 418 | - | for g.phase == roleEffectsFrozen { | |||
| 419 | - | if done == nil { | |||
| 420 | - | g.cond.Wait() | |||
| 421 | - | continue | |||
| 422 | - | } | |||
| 423 | - | changed := g.changed | |||
| 424 | - | doneCh := done() | |||
| 425 | - | g.mu.Unlock() | |||
| 426 | - | select { | |||
| 427 | - | case <-changed: | |||
| 428 | - | case <-doneCh: | |||
| 429 | - | } | |||
| 430 | - | g.mu.Lock() | |||
| 431 | - | select { | |||
| 432 | - | case <-doneCh: | |||
| 433 | - | g.mu.Unlock() | |||
| 434 | - | partial := frozenRoleEffectGates{clients: frozenClients} | |||
| 435 | - | partial.unfreeze() | |||
| 436 | - | return frozenRoleEffectGates{} | |||
| 437 | - | default: | |||
| 438 | - | } | |||
| 439 | - | } | |||
| 440 | - | g.phase = roleEffectsFrozen | |||
| 441 | - | g.mu.Unlock() | |||
| 442 | - | frozenClients = append(frozenClients, ac) | |||
| 443 | - | if afterFrozen != nil { | |||
| 444 | - | afterFrozen(ac) | |||
| 445 | - | } | |||
| 446 | - | } | |||
| 447 | - | interruptByClient := make(map[*attachedClient]transportSnapshot, len(interrupts)) | |||
| 448 | - | for _, interrupt := range interrupts { | |||
| 449 | - | if interrupt.ac != nil && interrupt.transport.transport != nil { | |||
| 450 | - | interruptByClient[interrupt.ac] = interrupt.transport | |||
| 451 | - | } | |||
| 452 | - | } | |||
| 453 | - | // Snapshot every exact in-flight link while all participant gates are frozen, | |||
| 454 | - | // then close the complete set before waiting on any one ticket. One | |||
| 455 | - | // uncooperative Send therefore cannot prevent another participant's Send | |||
| 456 | - | // from being interrupted. | |||
| 457 | - | interruptions := make(map[*attachedClient]transportSnapshot, len(interruptByClient)) | |||
| 458 | - | toClose := make([]roleTransportInterrupt, 0, len(interruptByClient)) | |||
| 459 | - | for _, ac := range ordered { | |||
| 460 | - | expected, mayInterrupt := interruptByClient[ac] | |||
| 461 | - | if !mayInterrupt { | |||
| 462 | - | continue | |||
| 463 | - | } | |||
| 464 | - | g := &ac.roleEffects | |||
| 465 | - | g.mu.Lock() | |||
| 466 | - | if gateHasTransportEffectLocked(g, expected) { | |||
| 467 | - | toClose = append(toClose, roleTransportInterrupt{ac: ac, transport: expected}) | |||
| 468 | - | } | |||
| 469 | - | g.mu.Unlock() | |||
| 470 | - | } | |||
| 471 | - | for _, interrupt := range toClose { | |||
| 472 | - | _ = interrupt.ac.closeCapturedTransport(interrupt.transport.transport) | |||
| 473 | - | } | |||
| 474 | - | ||||
| 475 | - | for _, ac := range ordered { | |||
| 476 | - | g := &ac.roleEffects | |||
| 477 | - | g.mu.Lock() | |||
| 478 | - | for g.inFlight != 0 { | |||
| 479 | - | if done == nil { | |||
| 480 | - | g.cond.Wait() | |||
| 481 | - | continue | |||
| 482 | - | } | |||
| 483 | - | changed := g.changed | |||
| 484 | - | doneCh := done() | |||
| 485 | - | g.mu.Unlock() | |||
| 486 | - | select { | |||
| 487 | - | case <-changed: | |||
| 488 | - | case <-doneCh: | |||
| 489 | - | } | |||
| 490 | - | g.mu.Lock() | |||
| 491 | - | select { | |||
| 492 | - | case <-doneCh: | |||
| 493 | - | g.mu.Unlock() | |||
| 494 | - | return frozenRoleEffectGates{clients: frozenClients, interruptions: interruptions, acquired: true} | |||
| 495 | - | default: | |||
| 496 | - | } | |||
| 497 | - | } | |||
| 498 | - | if expected, mayInterrupt := interruptByClient[ac]; mayInterrupt && | |||
| 499 | - | g.failedTransport.transport == expected.transport && g.failedTransport.incarnation == expected.incarnation { | |||
| 500 | - | interruptions[ac] = expected | |||
| 501 | - | } | |||
| 502 | - | g.mu.Unlock() | |||
| 503 | - | } | |||
| 504 | - | return frozenRoleEffectGates{clients: frozenClients, interruptions: interruptions, acquired: true, drained: true} | |||
| 505 | - | } | |||
| 506 | - | ||||
| 507 | - | func gateHasTransportEffectLocked(g *roleEffectGate, expected transportSnapshot) bool { | |||
| 508 | - | for _, active := range g.transportEffects { | |||
| 509 | - | if active.transport == expected.transport && active.incarnation == expected.incarnation { | |||
| 510 | - | return true | |||
| 511 | - | } | |||
| 512 | - | } | |||
| 513 | - | return false | |||
| 514 | - | } | |||
| 515 | - | ||||
| 516 | - | func (f frozenRoleEffectGates) unfreeze() { | |||
| 517 | - | for i := len(f.clients) - 1; i >= 0; i-- { | |||
| 518 | - | g := &f.clients[i].roleEffects | |||
| 519 | - | g.mu.Lock() | |||
| 520 | - | g.initLocked() | |||
| 521 | - | g.phase = roleEffectsStable | |||
| 522 | - | g.signalLocked() | |||
| 523 | - | g.mu.Unlock() | |||
| 524 | - | } | |||
| 525 | - | } | |||
M internal/usecase/daemon/session.go +2 -1
| @@ -1436,7 +1436,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1436 | 1436 | // while the complete immutable gate set remains frozen. | |
| 1437 | 1437 | delete(d.sessions, sess.id) | |
| 1438 | 1438 | d.clearBarScriptsForSession(sess.id) | |
| 1439 | - | d.purgeParkedForSessionLocked(sess) | |
| 1439 | + | parkedRetirements := d.purgeParkedForSessionLocked(sess) | |
| 1440 | 1440 | detachedActive := sess.client | |
| 1441 | 1441 | attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates)) | |
| 1442 | 1442 | capture := func(attached *attachedClient) { | |
| @@ -1474,6 +1474,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1474 | 1474 | d.closing = true | |
| 1475 | 1475 | } | |
| 1476 | 1476 | d.mu.Unlock() | |
| 1477 | + | finishParkedAttachmentRetirements(parkedRetirements) | |
| 1477 | 1478 | frozen.unfreeze() | |
| 1478 | 1479 | frozen = frozenRoleEffectGates{} | |
| 1479 | 1480 | d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason) | |
| @@ -1436,7 +1436,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||||
| 1436 | // while the complete immutable gate set remains frozen. | 1436 | // while the complete immutable gate set remains frozen. | ||
| 1437 | delete(d.sessions, sess.id) | 1437 | delete(d.sessions, sess.id) | ||
| 1438 | d.clearBarScriptsForSession(sess.id) | 1438 | d.clearBarScriptsForSession(sess.id) | ||
| 1439 | - | d.purgeParkedForSessionLocked(sess) | 1439 | + | parkedRetirements := d.purgeParkedForSessionLocked(sess) |
| 1440 | detachedActive := sess.client | 1440 | detachedActive := sess.client | ||
| 1441 | attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates)) | 1441 | attachments := make([]detachedAttachmentSnapshot, 0, len(participants.roleGates)) | ||
| 1442 | capture := func(attached *attachedClient) { | 1442 | capture := func(attached *attachedClient) { | ||
| @@ -1474,6 +1474,7 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||||
| 1474 | d.closing = true | 1474 | d.closing = true | ||
| 1475 | } | 1475 | } | ||
| 1476 | d.mu.Unlock() | 1476 | d.mu.Unlock() | ||
| 1477 | + | finishParkedAttachmentRetirements(parkedRetirements) | |||
| 1477 | frozen.unfreeze() | 1478 | frozen.unfreeze() | ||
| 1478 | frozen = frozenRoleEffectGates{} | 1479 | frozen = frozenRoleEffectGates{} | ||
| 1479 | d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason) | 1480 | d.log.Info("session closed", "session", stoppedName, "id", sess.id, "ephemeral", ephemeral, "purge", purge, "reason", reason) | ||
A internal/usecase/daemon/snatched_cleanup.go +171 -0
| @@ -0,0 +1,171 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | // removeSnatchedAttachment removes exactly one role generation and transport | |
| 4 | + | // from session routing. It never touches the active attachment or closes a | |
| 5 | + | // transport, allowing callers that already interrupted a blocked send to avoid | |
| 6 | + | // closing the same link twice. | |
| 7 | + | func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool { | |
| 8 | + | return d.unrouteSnatchedAttachment(token, true) | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool { | |
| 12 | + | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 13 | + | return false | |
| 14 | + | } | |
| 15 | + | frozen := freezeRoleEffectGates(token.ac) | |
| 16 | + | defer frozen.unfreeze() | |
| 17 | + | d.notices.routingMu.Lock() | |
| 18 | + | token.sess.mu.Lock() | |
| 19 | + | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 20 | + | token.ac.roleGeneration.Load() == token.generation && | |
| 21 | + | token.ac.transportSnapshotCurrent(token.transport) | |
| 22 | + | if current { | |
| 23 | + | delete(token.sess.snatched, token.ac) | |
| 24 | + | token.ac.roleGeneration.Add(1) | |
| 25 | + | token.ac.invalidateFrozenRoleCapability() | |
| 26 | + | } | |
| 27 | + | token.sess.mu.Unlock() | |
| 28 | + | d.notices.routingMu.Unlock() | |
| 29 | + | if !current { | |
| 30 | + | return false | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | token.ac.clearSnatchedInput() | |
| 34 | + | d.unregisterPreview(token.ac) | |
| 35 | + | if terminal { | |
| 36 | + | token.ac.clearPreviousSession() | |
| 37 | + | } | |
| 38 | + | token.ac.setSession(nil) | |
| 39 | + | token.ac.clearCaptureFrames() | |
| 40 | + | return true | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // parkSnatchedAttachment removes only the exact lost snatched incarnation and | |
| 44 | + | // retains its resume identity for a role-preserving reconnect. | |
| 45 | + | func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool { | |
| 46 | + | if token.ac == nil || !token.ac.resumeCapable || !d.unrouteSnatchedAttachment(token, false) { | |
| 47 | + | return false | |
| 48 | + | } | |
| 49 | + | if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | |
| 50 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 51 | + | return true | |
| 52 | + | } | |
| 53 | + | token.ac.clearPreviousSession() | |
| 54 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 55 | + | return false | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool { | |
| 59 | + | if d.parkSnatchedAttachment(token) { | |
| 60 | + | return true | |
| 61 | + | } | |
| 62 | + | return d.dropSnatchedAttachment(token) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced | |
| 66 | + | // transport that was closed to drain an admitted send. It waits on the gate's | |
| 67 | + | // condition rather than ordinary effect admission, then removes only the exact | |
| 68 | + | // published snatched incarnation. Attachment-local cleanup runs without routing | |
| 69 | + | // or session locks while the terminal capability remains frozen. | |
| 70 | + | func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool { | |
| 71 | + | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 72 | + | return false | |
| 73 | + | } | |
| 74 | + | frozen := freezeRoleEffectGates(token.ac) | |
| 75 | + | defer frozen.unfreeze() | |
| 76 | + | ||
| 77 | + | d.notices.routingMu.Lock() | |
| 78 | + | token.sess.mu.Lock() | |
| 79 | + | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 80 | + | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 81 | + | token.ac.transportSnapshotCurrent(token.transport) | |
| 82 | + | token.sess.mu.Unlock() | |
| 83 | + | d.notices.routingMu.Unlock() | |
| 84 | + | if !current { | |
| 85 | + | return false | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // The frozen gate prevents a promotion from publishing between overlay | |
| 89 | + | // cleanup and terminal registry publication. | |
| 90 | + | d.clearForSnatch(token) | |
| 91 | + | ||
| 92 | + | d.notices.routingMu.Lock() | |
| 93 | + | token.sess.mu.Lock() | |
| 94 | + | current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 95 | + | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 96 | + | token.ac.transportSnapshotCurrent(token.transport) | |
| 97 | + | if current { | |
| 98 | + | delete(token.sess.snatched, token.ac) | |
| 99 | + | token.ac.roleGeneration.Add(1) | |
| 100 | + | token.ac.invalidateFrozenRoleCapability() | |
| 101 | + | } | |
| 102 | + | token.sess.mu.Unlock() | |
| 103 | + | d.notices.routingMu.Unlock() | |
| 104 | + | if !current { | |
| 105 | + | return false | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | token.ac.setSession(nil) | |
| 109 | + | if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | |
| 110 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 111 | + | token.ac.clearSnatchedInput() | |
| 112 | + | d.unregisterPreview(token.ac) | |
| 113 | + | return true | |
| 114 | + | } | |
| 115 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 116 | + | token.ac.clearSnatchedInput() | |
| 117 | + | d.unregisterPreview(token.ac) | |
| 118 | + | token.ac.clearPreviousSession() | |
| 119 | + | token.ac.clearCaptureFrames() | |
| 120 | + | return true | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // dropSnatchedAttachment removes and closes only the captured snatched link. | |
| 124 | + | func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool { | |
| 125 | + | if !d.removeSnatchedAttachment(token) { | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // deferAttachmentTransitionCleanups retires coordinator timers and accounting | |
| 133 | + | // after ownership publication. Cleanup never gates the attaching handshake. A | |
| 134 | + | // displaced render that already owns sendMu is interrupted by closing only its | |
| 135 | + | // captured transport; an idle healthy snatched transport remains connected and | |
| 136 | + | // receives a dependency-free reset panel. | |
| 137 | + | func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) { | |
| 138 | + | if token := result.displaced; token.ac != nil && token.transport.transport != nil { | |
| 139 | + | blockedRender := result.displacedInterrupted | |
| 140 | + | if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) { | |
| 141 | + | blockedRender = !token.ac.sendMu.TryLock() | |
| 142 | + | if !blockedRender { | |
| 143 | + | token.ac.sendMu.Unlock() | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | d.attachmentCleanupWg.Go(func() { | |
| 147 | + | if d.afterDisplacedCleanupStarted != nil { | |
| 148 | + | d.afterDisplacedCleanupStarted() | |
| 149 | + | } | |
| 150 | + | if blockedRender { | |
| 151 | + | // This path owns terminal cleanup independently of ordinary role | |
| 152 | + | // admission: a stale send-error detach may transiently freeze the gate. | |
| 153 | + | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 154 | + | d.cleanupInterruptedSnatchedAttachment(token) | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 158 | + | if !admitted { | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | defer ticket.End() | |
| 162 | + | if err := d.sendInitialSnatchedPanel(token, ticket); err != nil { | |
| 163 | + | ticket.End() | |
| 164 | + | d.parkOrDropSnatchedAttachment(token) | |
| 165 | + | } | |
| 166 | + | }) | |
| 167 | + | } | |
| 168 | + | for _, cleanup := range result.cleanups { | |
| 169 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 170 | + | } | |
| 171 | + | } | |
| @@ -0,0 +1,171 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | // removeSnatchedAttachment removes exactly one role generation and transport | |||
| 4 | + | // from session routing. It never touches the active attachment or closes a | |||
| 5 | + | // transport, allowing callers that already interrupted a blocked send to avoid | |||
| 6 | + | // closing the same link twice. | |||
| 7 | + | func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool { | |||
| 8 | + | return d.unrouteSnatchedAttachment(token, true) | |||
| 9 | + | } | |||
| 10 | + | ||||
| 11 | + | func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool { | |||
| 12 | + | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |||
| 13 | + | return false | |||
| 14 | + | } | |||
| 15 | + | frozen := freezeRoleEffectGates(token.ac) | |||
| 16 | + | defer frozen.unfreeze() | |||
| 17 | + | d.notices.routingMu.Lock() | |||
| 18 | + | token.sess.mu.Lock() | |||
| 19 | + | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |||
| 20 | + | token.ac.roleGeneration.Load() == token.generation && | |||
| 21 | + | token.ac.transportSnapshotCurrent(token.transport) | |||
| 22 | + | if current { | |||
| 23 | + | delete(token.sess.snatched, token.ac) | |||
| 24 | + | token.ac.roleGeneration.Add(1) | |||
| 25 | + | token.ac.invalidateFrozenRoleCapability() | |||
| 26 | + | } | |||
| 27 | + | token.sess.mu.Unlock() | |||
| 28 | + | d.notices.routingMu.Unlock() | |||
| 29 | + | if !current { | |||
| 30 | + | return false | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | token.ac.clearSnatchedInput() | |||
| 34 | + | d.unregisterPreview(token.ac) | |||
| 35 | + | if terminal { | |||
| 36 | + | token.ac.clearPreviousSession() | |||
| 37 | + | } | |||
| 38 | + | token.ac.setSession(nil) | |||
| 39 | + | token.ac.clearCaptureFrames() | |||
| 40 | + | return true | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // parkSnatchedAttachment removes only the exact lost snatched incarnation and | |||
| 44 | + | // retains its resume identity for a role-preserving reconnect. | |||
| 45 | + | func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool { | |||
| 46 | + | if token.ac == nil || !token.ac.resumeCapable || !d.unrouteSnatchedAttachment(token, false) { | |||
| 47 | + | return false | |||
| 48 | + | } | |||
| 49 | + | if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | |||
| 50 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |||
| 51 | + | return true | |||
| 52 | + | } | |||
| 53 | + | token.ac.clearPreviousSession() | |||
| 54 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |||
| 55 | + | return false | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool { | |||
| 59 | + | if d.parkSnatchedAttachment(token) { | |||
| 60 | + | return true | |||
| 61 | + | } | |||
| 62 | + | return d.dropSnatchedAttachment(token) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced | |||
| 66 | + | // transport that was closed to drain an admitted send. It waits on the gate's | |||
| 67 | + | // condition rather than ordinary effect admission, then removes only the exact | |||
| 68 | + | // published snatched incarnation. Attachment-local cleanup runs without routing | |||
| 69 | + | // or session locks while the terminal capability remains frozen. | |||
| 70 | + | func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool { | |||
| 71 | + | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |||
| 72 | + | return false | |||
| 73 | + | } | |||
| 74 | + | frozen := freezeRoleEffectGates(token.ac) | |||
| 75 | + | defer frozen.unfreeze() | |||
| 76 | + | ||||
| 77 | + | d.notices.routingMu.Lock() | |||
| 78 | + | token.sess.mu.Lock() | |||
| 79 | + | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |||
| 80 | + | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |||
| 81 | + | token.ac.transportSnapshotCurrent(token.transport) | |||
| 82 | + | token.sess.mu.Unlock() | |||
| 83 | + | d.notices.routingMu.Unlock() | |||
| 84 | + | if !current { | |||
| 85 | + | return false | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // The frozen gate prevents a promotion from publishing between overlay | |||
| 89 | + | // cleanup and terminal registry publication. | |||
| 90 | + | d.clearForSnatch(token) | |||
| 91 | + | ||||
| 92 | + | d.notices.routingMu.Lock() | |||
| 93 | + | token.sess.mu.Lock() | |||
| 94 | + | current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |||
| 95 | + | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |||
| 96 | + | token.ac.transportSnapshotCurrent(token.transport) | |||
| 97 | + | if current { | |||
| 98 | + | delete(token.sess.snatched, token.ac) | |||
| 99 | + | token.ac.roleGeneration.Add(1) | |||
| 100 | + | token.ac.invalidateFrozenRoleCapability() | |||
| 101 | + | } | |||
| 102 | + | token.sess.mu.Unlock() | |||
| 103 | + | d.notices.routingMu.Unlock() | |||
| 104 | + | if !current { | |||
| 105 | + | return false | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | token.ac.setSession(nil) | |||
| 109 | + | if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | |||
| 110 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |||
| 111 | + | token.ac.clearSnatchedInput() | |||
| 112 | + | d.unregisterPreview(token.ac) | |||
| 113 | + | return true | |||
| 114 | + | } | |||
| 115 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |||
| 116 | + | token.ac.clearSnatchedInput() | |||
| 117 | + | d.unregisterPreview(token.ac) | |||
| 118 | + | token.ac.clearPreviousSession() | |||
| 119 | + | token.ac.clearCaptureFrames() | |||
| 120 | + | return true | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // dropSnatchedAttachment removes and closes only the captured snatched link. | |||
| 124 | + | func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool { | |||
| 125 | + | if !d.removeSnatchedAttachment(token) { | |||
| 126 | + | return false | |||
| 127 | + | } | |||
| 128 | + | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |||
| 129 | + | return true | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // deferAttachmentTransitionCleanups retires coordinator timers and accounting | |||
| 133 | + | // after ownership publication. Cleanup never gates the attaching handshake. A | |||
| 134 | + | // displaced render that already owns sendMu is interrupted by closing only its | |||
| 135 | + | // captured transport; an idle healthy snatched transport remains connected and | |||
| 136 | + | // receives a dependency-free reset panel. | |||
| 137 | + | func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) { | |||
| 138 | + | if token := result.displaced; token.ac != nil && token.transport.transport != nil { | |||
| 139 | + | blockedRender := result.displacedInterrupted | |||
| 140 | + | if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) { | |||
| 141 | + | blockedRender = !token.ac.sendMu.TryLock() | |||
| 142 | + | if !blockedRender { | |||
| 143 | + | token.ac.sendMu.Unlock() | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | d.attachmentCleanupWg.Go(func() { | |||
| 147 | + | if d.afterDisplacedCleanupStarted != nil { | |||
| 148 | + | d.afterDisplacedCleanupStarted() | |||
| 149 | + | } | |||
| 150 | + | if blockedRender { | |||
| 151 | + | // This path owns terminal cleanup independently of ordinary role | |||
| 152 | + | // admission: a stale send-error detach may transiently freeze the gate. | |||
| 153 | + | _ = token.ac.closeCapturedTransport(token.transport.transport) | |||
| 154 | + | d.cleanupInterruptedSnatchedAttachment(token) | |||
| 155 | + | return | |||
| 156 | + | } | |||
| 157 | + | ticket, admitted := token.ac.beginRoleEffect(token) | |||
| 158 | + | if !admitted { | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | defer ticket.End() | |||
| 162 | + | if err := d.sendInitialSnatchedPanel(token, ticket); err != nil { | |||
| 163 | + | ticket.End() | |||
| 164 | + | d.parkOrDropSnatchedAttachment(token) | |||
| 165 | + | } | |||
| 166 | + | }) | |||
| 167 | + | } | |||
| 168 | + | for _, cleanup := range result.cleanups { | |||
| 169 | + | d.attachmentCleanupWg.Go(cleanup.finish) | |||
| 170 | + | } | |||
| 171 | + | } | |||
M internal/usecase/daemon/snatched_client.go +0 -792
| @@ -6,14 +6,6 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/internal/ports" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | type attachmentRole uint8 | |
| 10 | - | ||
| 11 | - | const ( | |
| 12 | - | attachmentDetached attachmentRole = iota | |
| 13 | - | attachmentActive | |
| 14 | - | attachmentSnatched | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | 9 | type initialSnatchedPanelAttempt struct { | |
| 18 | 10 | done chan struct{} | |
| 19 | 11 | err error | |
| @@ -66,560 +58,6 @@ func (d *Daemon) sendInitialSnatchedPanel(token attachmentRoleToken, ticket *rol | |||
| 66 | 58 | return err | |
| 67 | 59 | } | |
| 68 | 60 | ||
| 69 | - | // attachmentRole derives the attachment's role from the session-owned | |
| 70 | - | // registries. There is deliberately no second role field that could drift. | |
| 71 | - | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | |
| 72 | - | if s == nil || ac == nil { | |
| 73 | - | return attachmentDetached | |
| 74 | - | } | |
| 75 | - | s.mu.Lock() | |
| 76 | - | defer s.mu.Unlock() | |
| 77 | - | return s.attachmentRoleLocked(ac) | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | // attachmentRoleLocked requires s.mu. Active membership wins if an invalid | |
| 81 | - | // intermediate state contains the same attachment in both registries. | |
| 82 | - | func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole { | |
| 83 | - | if ac == nil { | |
| 84 | - | return attachmentDetached | |
| 85 | - | } | |
| 86 | - | if s.client == ac { | |
| 87 | - | return attachmentActive | |
| 88 | - | } | |
| 89 | - | if _, ok := s.snatched[ac]; ok { | |
| 90 | - | return attachmentSnatched | |
| 91 | - | } | |
| 92 | - | return attachmentDetached | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | func (s *session) addSnatchedLocked(ac *attachedClient) { | |
| 96 | - | if ac == nil { | |
| 97 | - | return | |
| 98 | - | } | |
| 99 | - | if s.snatched == nil { | |
| 100 | - | s.snatched = make(map[*attachedClient]struct{}) | |
| 101 | - | } | |
| 102 | - | s.snatched[ac] = struct{}{} | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | var errAttachmentTransition = errors.New("attachment transition is no longer valid") | |
| 106 | - | ||
| 107 | - | type attachmentTransitionRequest struct { | |
| 108 | - | source *session | |
| 109 | - | target *session | |
| 110 | - | next *attachedClient | |
| 111 | - | expectedRole attachmentRole | |
| 112 | - | targetRole attachmentRole | |
| 113 | - | expectedTransport transportSnapshot | |
| 114 | - | sourceToken *attachmentRoleToken | |
| 115 | - | action string | |
| 116 | - | activateTargetTab bool | |
| 117 | - | targetTabIndex int | |
| 118 | - | copySourceEnvironment bool | |
| 119 | - | // preserveRole commits navigation state without replacing attachment | |
| 120 | - | // ownership. It is used for same-session picker selections after the exact | |
| 121 | - | // initiating capability has been frozen and revalidated. | |
| 122 | - | preserveRole bool | |
| 123 | - | expectedSourceTab *tab | |
| 124 | - | createTargetLocked func() (*session, error) | |
| 125 | - | ready bool | |
| 126 | - | expectedTargetCurrent *attachedClient | |
| 127 | - | expectedTargetTransport transportSnapshot | |
| 128 | - | preflighted bool | |
| 129 | - | roleEffectsFrozen bool | |
| 130 | - | expectedTargetTransportInterrupted bool | |
| 131 | - | // activationBarrier is used when promoting an already connected snatched | |
| 132 | - | // attachment. Its role gate is deadline-drained before sendMu is acquired, | |
| 133 | - | // preserving the gate -> sendMu -> daemon/routing/session publication order. | |
| 134 | - | activationBarrier bool | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool { | |
| 138 | - | return token.sess == source && token.ac == req.next && token.role == req.expectedRole && | |
| 139 | - | token.transport.transport == req.expectedTransport.transport && | |
| 140 | - | token.transport.incarnation == req.expectedTransport.incarnation | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | // transitionSourceTokenCurrentLocked requires source.mu and, for an active | |
| 144 | - | // source, sourceCoordinator.mu. This is the canonical handoff preflight: every | |
| 145 | - | // client-originated navigation, delete, or create intent is bound to one exact | |
| 146 | - | // role generation, transport incarnation, and coordinator lease. | |
| 147 | - | func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { | |
| 148 | - | if token.sess != source || token.ac != req.next || token.role != req.expectedRole || | |
| 149 | - | token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role || | |
| 150 | - | req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) { | |
| 151 | - | return false | |
| 152 | - | } | |
| 153 | - | if token.role != attachmentActive { | |
| 154 | - | return true | |
| 155 | - | } | |
| 156 | - | return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next && | |
| 157 | - | sourceCoordinator.leaseCurrentLocked(token.lease, true) | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | type attachmentRoleToken struct { | |
| 161 | - | sess *session | |
| 162 | - | ac *attachedClient | |
| 163 | - | role attachmentRole | |
| 164 | - | generation uint64 | |
| 165 | - | transport transportSnapshot | |
| 166 | - | lease *attachmentLease | |
| 167 | - | effect *roleEffectTicket | |
| 168 | - | // rebase marks a cross-session ownership move whose first paint must start a | |
| 169 | - | // dependency-free output chain. The rebase is deliberately deferred until | |
| 170 | - | // after transition publication releases all architecture locks. | |
| 171 | - | rebase bool | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |
| 175 | - | if s == nil || ac == nil || tr == nil { | |
| 176 | - | return attachmentRoleToken{} | |
| 177 | - | } | |
| 178 | - | s.mu.Lock() | |
| 179 | - | transport := ac.transportSnapshot() | |
| 180 | - | if transport.transport != tr { | |
| 181 | - | s.mu.Unlock() | |
| 182 | - | return attachmentRoleToken{} | |
| 183 | - | } | |
| 184 | - | token := attachmentRoleToken{ | |
| 185 | - | sess: s, | |
| 186 | - | ac: ac, | |
| 187 | - | role: s.attachmentRoleLocked(ac), | |
| 188 | - | generation: ac.roleGeneration.Load(), | |
| 189 | - | transport: transport, | |
| 190 | - | } | |
| 191 | - | s.mu.Unlock() | |
| 192 | - | if token.role == attachmentActive { | |
| 193 | - | if rc := s.renderCoordinator(); rc != nil { | |
| 194 | - | token.lease = rc.attachmentLease(ac) | |
| 195 | - | } | |
| 196 | - | } | |
| 197 | - | ac.bootstrapRoleCapability(token) | |
| 198 | - | return token | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | func (t attachmentRoleToken) current() bool { | |
| 202 | - | return t.sess != nil && t.ac != nil && | |
| 203 | - | t.ac.roleGeneration.Load() == t.generation && | |
| 204 | - | t.ac.transportSnapshotCurrent(t.transport) && | |
| 205 | - | t.sess.attachmentRole(t.ac) == t.role | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | // activeCurrent admits post-transition effects only for the exact published | |
| 209 | - | // role, transport incarnation, current-session link, and coordinator lease. | |
| 210 | - | func (t attachmentRoleToken) activeCurrent() bool { | |
| 211 | - | if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess { | |
| 212 | - | return false | |
| 213 | - | } | |
| 214 | - | rc := t.sess.renderCoordinator() | |
| 215 | - | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | // activeEffect is the canonical admission check for a client-frame mutation. | |
| 219 | - | // Tokens bind role generation, transport incarnation, session ownership, and | |
| 220 | - | // coordinator lease, so handlers never rediscover authority from mutable state. | |
| 221 | - | func (t attachmentRoleToken) activeEffect() bool { | |
| 222 | - | if t.effect != nil && !t.effect.ended.Load() { | |
| 223 | - | return true | |
| 224 | - | } | |
| 225 | - | return t.activeCurrent() | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) { | |
| 229 | - | if sess == nil || ac == nil || lease == nil { | |
| 230 | - | return nil, false | |
| 231 | - | } | |
| 232 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 233 | - | token.lease = lease | |
| 234 | - | return ac.beginRoleEffect(token) | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | func (t *attachmentRoleToken) endRoleEffect() { | |
| 238 | - | if t == nil || t.effect == nil { | |
| 239 | - | return | |
| 240 | - | } | |
| 241 | - | t.effect.End() | |
| 242 | - | t.effect = nil | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | // activeEffectSessionLocked is the same admission check while t.sess.mu is | |
| 246 | - | // already held at a session-owned mutation boundary. | |
| 247 | - | func (t attachmentRoleToken) activeEffectSessionLocked() bool { | |
| 248 | - | if t.effect != nil && !t.effect.ended.Load() { | |
| 249 | - | return true | |
| 250 | - | } | |
| 251 | - | if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil || | |
| 252 | - | t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 253 | - | t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive { | |
| 254 | - | return false | |
| 255 | - | } | |
| 256 | - | rc := t.sess.renderCoordinator() | |
| 257 | - | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error { | |
| 261 | - | if t.ac == nil { | |
| 262 | - | return errAttachmentTransition | |
| 263 | - | } | |
| 264 | - | t.ac.sendMu.Lock() | |
| 265 | - | defer t.ac.sendMu.Unlock() | |
| 266 | - | if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 267 | - | !t.effect.beginTransportSend(t.transport) { | |
| 268 | - | return errAttachmentTransition | |
| 269 | - | } | |
| 270 | - | err := t.transport.transport.Send(frame) | |
| 271 | - | if err != nil { | |
| 272 | - | t.effect.reportTransportFailure(t.transport) | |
| 273 | - | } | |
| 274 | - | t.effect.endTransportSend() | |
| 275 | - | return err | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | type attachmentTransitionResult struct { | |
| 279 | - | published attachmentRoleToken | |
| 280 | - | displaced attachmentRoleToken | |
| 281 | - | displacedInterrupted bool | |
| 282 | - | cleanups []renderLifecycleCleanup | |
| 283 | - | } | |
| 284 | - | ||
| 285 | - | func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 286 | - | // Preflight identifies every gate the publication can affect. No gate is | |
| 287 | - | // frozen while an architecture lock is held. | |
| 288 | - | d.mu.Lock() | |
| 289 | - | source := req.source | |
| 290 | - | if source == nil { | |
| 291 | - | source = req.target | |
| 292 | - | } | |
| 293 | - | if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing || | |
| 294 | - | d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target || | |
| 295 | - | req.activationBarrier && (req.sourceToken == nil || source != req.target || | |
| 296 | - | req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) { | |
| 297 | - | d.mu.Unlock() | |
| 298 | - | return attachmentTransitionResult{}, errAttachmentTransition | |
| 299 | - | } | |
| 300 | - | if req.target != nil { | |
| 301 | - | req.target.mu.Lock() | |
| 302 | - | req.expectedTargetCurrent = req.target.client | |
| 303 | - | if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive { | |
| 304 | - | req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot() | |
| 305 | - | } | |
| 306 | - | req.target.mu.Unlock() | |
| 307 | - | } | |
| 308 | - | if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) { | |
| 309 | - | d.mu.Unlock() | |
| 310 | - | return attachmentTransitionResult{}, errAttachmentTransition | |
| 311 | - | } | |
| 312 | - | req.preflighted = true | |
| 313 | - | d.mu.Unlock() | |
| 314 | - | ||
| 315 | - | if req.sourceToken != nil { | |
| 316 | - | d.endActionRoleEffect(req.sourceToken.effect, req.action) | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | participants := []*attachedClient{req.next, req.expectedTargetCurrent} | |
| 320 | - | if d.afterRoleEffectParticipantsSnapshotted != nil { | |
| 321 | - | d.afterRoleEffectParticipantsSnapshotted(req.action, participants) | |
| 322 | - | } | |
| 323 | - | interrupts := []roleTransportInterrupt{{ | |
| 324 | - | ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport, | |
| 325 | - | }} | |
| 326 | - | var drainDeadline *roleEffectDrainDeadline | |
| 327 | - | var drainDone func() <-chan struct{} | |
| 328 | - | if req.activationBarrier { | |
| 329 | - | drainDeadline = newRoleEffectDrainDeadline(d.clock) | |
| 330 | - | drainDone = drainDeadline.Done | |
| 331 | - | } | |
| 332 | - | frozen := freezeRoleEffectGatesInterruptingObservedUntil(interrupts, drainDone, func(ac *attachedClient) { | |
| 333 | - | if d.afterRoleEffectGateFrozen != nil { | |
| 334 | - | d.afterRoleEffectGateFrozen(req.action, ac) | |
| 335 | - | } | |
| 336 | - | }, participants...) | |
| 337 | - | if drainDeadline != nil { | |
| 338 | - | drainDeadline.stop() | |
| 339 | - | } | |
| 340 | - | defer frozen.unfreeze() | |
| 341 | - | if !frozen.acquired || !frozen.drained { | |
| 342 | - | if req.activationBarrier { | |
| 343 | - | _ = req.next.closeCapturedTransport(req.expectedTransport.transport) | |
| 344 | - | return attachmentTransitionResult{}, errSendTimedOut | |
| 345 | - | } | |
| 346 | - | return attachmentTransitionResult{}, errAttachmentTransition | |
| 347 | - | } | |
| 348 | - | req.expectedTargetTransportInterrupted = frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport) | |
| 349 | - | if d.afterRoleEffectsFrozen != nil { | |
| 350 | - | d.afterRoleEffectsFrozen() | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | var releaseActivation func() | |
| 354 | - | if req.activationBarrier { | |
| 355 | - | var err error | |
| 356 | - | releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken) | |
| 357 | - | if err != nil { | |
| 358 | - | return attachmentTransitionResult{}, err | |
| 359 | - | } | |
| 360 | - | defer func() { | |
| 361 | - | if releaseActivation != nil { | |
| 362 | - | releaseActivation() | |
| 363 | - | } | |
| 364 | - | }() | |
| 365 | - | } | |
| 366 | - | ||
| 367 | - | d.mu.Lock() | |
| 368 | - | req.roleEffectsFrozen = true | |
| 369 | - | if req.target == nil { | |
| 370 | - | if !d.transitionSourcePreflightLocked(req) { | |
| 371 | - | d.mu.Unlock() | |
| 372 | - | return attachmentTransitionResult{}, errAttachmentTransition | |
| 373 | - | } | |
| 374 | - | var err error | |
| 375 | - | req.target, err = req.createTargetLocked() | |
| 376 | - | if err != nil { | |
| 377 | - | d.mu.Unlock() | |
| 378 | - | return attachmentTransitionResult{}, err | |
| 379 | - | } | |
| 380 | - | req.expectedTargetCurrent = nil | |
| 381 | - | } | |
| 382 | - | result, err := d.transitionAttachmentLocked(req) | |
| 383 | - | d.mu.Unlock() | |
| 384 | - | if err == nil && req.activationBarrier && result.published.role == attachmentActive { | |
| 385 | - | ac := result.published.ac | |
| 386 | - | if ac.output != nil { | |
| 387 | - | ac.output.rebase() | |
| 388 | - | } | |
| 389 | - | ac.captureFrames = nil | |
| 390 | - | d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false) | |
| 391 | - | // The first paint still requests a reset, but the old panel dependency | |
| 392 | - | // chain has already been rebased atomically under the activation barrier. | |
| 393 | - | result.published.rebase = false | |
| 394 | - | } | |
| 395 | - | if releaseActivation != nil { | |
| 396 | - | releaseActivation() | |
| 397 | - | releaseActivation = nil | |
| 398 | - | } | |
| 399 | - | if err == nil && result.published.role == attachmentActive { | |
| 400 | - | result.published.ac.clearSnatchedInput() | |
| 401 | - | } | |
| 402 | - | return result, err | |
| 403 | - | } | |
| 404 | - | ||
| 405 | - | // transitionSourcePreflightLocked validates an initiating role while its gate | |
| 406 | - | // is frozen and d.mu is held. No caller may create or delete a target before | |
| 407 | - | // this succeeds. | |
| 408 | - | func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool { | |
| 409 | - | if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source { | |
| 410 | - | return false | |
| 411 | - | } | |
| 412 | - | d.notices.routingMu.Lock() | |
| 413 | - | defer d.notices.routingMu.Unlock() | |
| 414 | - | req.source.mu.Lock() | |
| 415 | - | defer req.source.mu.Unlock() | |
| 416 | - | coordinator := req.source.renderCoordinator() | |
| 417 | - | if coordinator == nil { | |
| 418 | - | return false | |
| 419 | - | } | |
| 420 | - | coordinator.mu.Lock() | |
| 421 | - | defer coordinator.mu.Unlock() | |
| 422 | - | if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) { | |
| 423 | - | return false | |
| 424 | - | } | |
| 425 | - | return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab) | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |
| 429 | - | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | |
| 430 | - | return false | |
| 431 | - | } | |
| 432 | - | d.mu.Lock() | |
| 433 | - | defer d.mu.Unlock() | |
| 434 | - | if d.closing || d.sessions[token.sess.id] != token.sess { | |
| 435 | - | return false | |
| 436 | - | } | |
| 437 | - | d.notices.routingMu.Lock() | |
| 438 | - | defer d.notices.routingMu.Unlock() | |
| 439 | - | token.sess.mu.Lock() | |
| 440 | - | defer token.sess.mu.Unlock() | |
| 441 | - | coordinator := token.sess.renderCoordinator() | |
| 442 | - | if coordinator == nil { | |
| 443 | - | return false | |
| 444 | - | } | |
| 445 | - | coordinator.mu.Lock() | |
| 446 | - | defer coordinator.mu.Unlock() | |
| 447 | - | req := attachmentTransitionRequest{ | |
| 448 | - | source: token.sess, next: token.ac, expectedRole: token.role, | |
| 449 | - | expectedTransport: token.transport, | |
| 450 | - | } | |
| 451 | - | return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 452 | - | } | |
| 453 | - | ||
| 454 | - | func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | |
| 455 | - | if expected == nil { | |
| 456 | - | return true | |
| 457 | - | } | |
| 458 | - | if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected { | |
| 459 | - | return false | |
| 460 | - | } | |
| 461 | - | expected.mu.Lock() | |
| 462 | - | defer expected.mu.Unlock() | |
| 463 | - | return expected.floating.state != floatingVisible | |
| 464 | - | } | |
| 465 | - | ||
| 466 | - | // transitionAttachmentLocked is the publication half of transitionAttachment. | |
| 467 | - | // Caller holds d.mu after the lock-free gate freeze/drain has completed. | |
| 468 | - | func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 469 | - | d.notices.routingMu.Lock() | |
| 470 | - | defer d.notices.routingMu.Unlock() | |
| 471 | - | return d.transitionAttachmentRoutedLocked(req) | |
| 472 | - | } | |
| 473 | - | ||
| 474 | - | // transitionAttachmentRoutedLocked performs the centralized role publication. | |
| 475 | - | // Caller holds d.mu and notices.routingMu. Every fallible lifecycle, role, and | |
| 476 | - | // transport check occurs before session.client is changed. Coordinator | |
| 477 | - | // finalization after that commit point cannot roll ownership back. | |
| 478 | - | func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| 479 | - | source := req.source | |
| 480 | - | if source == nil { | |
| 481 | - | source = req.target | |
| 482 | - | } | |
| 483 | - | if !req.preflighted || !req.roleEffectsFrozen || | |
| 484 | - | !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) || | |
| 485 | - | source == nil || req.target == nil || req.next == nil || d.closing || | |
| 486 | - | d.sessions[source.id] != source || d.sessions[req.target.id] != req.target || | |
| 487 | - | req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) { | |
| 488 | - | return attachmentTransitionResult{}, errAttachmentTransition | |
| 489 | - | } | |
| 490 | - | // Install the target coordinator object before taking both session locks; | |
| 491 | - | // lease binding itself remains part of the atomic publication below. | |
| 492 | - | var targetCoordinator *renderCoordinator | |
| 493 | - | if req.targetRole == attachmentActive { | |
| 494 | - | targetCoordinator = d.ensureRenderCoordinator(req.target) | |
| 495 | - | } | |
| 496 | - | sourceCoordinator := source.renderCoordinator() | |
| 497 | - | ||
| 498 | - | unlockSessions := lockAttachmentSessions(source, req.target) | |
| 499 | - | if source.attachmentRoleLocked(req.next) != req.expectedRole || | |
| 500 | - | !req.next.transportSnapshotCurrent(req.expectedTransport) { | |
| 501 | - | unlockSessions() | |
| 502 | - | return attachmentTransitionResult{}, errAttachmentTransition | |
| 503 | - | } | |
| 504 | - | ||
| 505 | - | old := req.target.client | |
| 506 | - | if req.preflighted && old != req.expectedTargetCurrent { | |
| 507 | - | unlockSessions() | |
| 508 | - | return attachmentTransitionResult{}, errAttachmentTransition | |
| 509 | - | } | |
| 510 | - | if source != req.target && old == req.next { | |
| 511 | - | unlockSessions() | |
| 512 | - | return attachmentTransitionResult{}, errAttachmentTransition | |
| 513 | - | } | |
| 514 | - | unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator) | |
| 515 | - | if req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | |
| 516 | - | !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) { | |
| 517 | - | unlockCoordinators() | |
| 518 | - | unlockSessions() | |
| 519 | - | return attachmentTransitionResult{}, errAttachmentTransition | |
| 520 | - | } | |
| 521 | - | if targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) { | |
| 522 | - | unlockCoordinators() | |
| 523 | - | unlockSessions() | |
| 524 | - | return attachmentTransitionResult{}, errAttachmentTransition | |
| 525 | - | } | |
| 526 | - | if req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | |
| 527 | - | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) { | |
| 528 | - | unlockCoordinators() | |
| 529 | - | unlockSessions() | |
| 530 | - | return attachmentTransitionResult{}, errAttachmentTransition | |
| 531 | - | } | |
| 532 | - | if req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) { | |
| 533 | - | unlockCoordinators() | |
| 534 | - | unlockSessions() | |
| 535 | - | return attachmentTransitionResult{}, errAttachmentTransition | |
| 536 | - | } | |
| 537 | - | ||
| 538 | - | if req.targetRole == attachmentActive { | |
| 539 | - | d.demoteParkedActiveForSessionLocked(req.target) | |
| 540 | - | } | |
| 541 | - | if req.copySourceEnvironment { | |
| 542 | - | req.target.terminal = source.terminal | |
| 543 | - | req.target.env = copyEnvironment(source.env) | |
| 544 | - | } | |
| 545 | - | if req.activateTargetTab { | |
| 546 | - | req.target.active = req.targetTabIndex | |
| 547 | - | } | |
| 548 | - | if req.preserveRole { | |
| 549 | - | unlockCoordinators() | |
| 550 | - | unlockSessions() | |
| 551 | - | return attachmentTransitionResult{published: *req.sourceToken}, nil | |
| 552 | - | } | |
| 553 | - | ||
| 554 | - | var displacedTransport transportSnapshot | |
| 555 | - | if req.targetRole == attachmentActive && old != nil && old != req.next { | |
| 556 | - | displacedTransport = old.transportSnapshot() | |
| 557 | - | } | |
| 558 | - | if source != req.target { | |
| 559 | - | if source.client == req.next { | |
| 560 | - | source.client = nil | |
| 561 | - | } | |
| 562 | - | delete(source.snatched, req.next) | |
| 563 | - | } | |
| 564 | - | if req.targetRole == attachmentActive { | |
| 565 | - | delete(req.target.snatched, req.next) | |
| 566 | - | if old != nil && old != req.next { | |
| 567 | - | req.target.addSnatchedLocked(old) | |
| 568 | - | } | |
| 569 | - | req.target.client = req.next | |
| 570 | - | } else { | |
| 571 | - | req.target.addSnatchedLocked(req.next) | |
| 572 | - | } | |
| 573 | - | nextGeneration := req.next.roleGeneration.Add(1) | |
| 574 | - | var displacedGeneration uint64 | |
| 575 | - | if old != nil && old != req.next && req.targetRole == attachmentActive { | |
| 576 | - | displacedGeneration = old.roleGeneration.Add(1) | |
| 577 | - | } | |
| 578 | - | req.next.setSession(req.target) | |
| 579 | - | ||
| 580 | - | result := attachmentTransitionResult{} | |
| 581 | - | if source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil { | |
| 582 | - | result.cleanups = append(result.cleanups, sourceCoordinator.beginDetachLocked(req.next)) | |
| 583 | - | } | |
| 584 | - | var lease *attachmentLease | |
| 585 | - | if targetCoordinator != nil { | |
| 586 | - | var cleanup renderLifecycleCleanup | |
| 587 | - | cleanup, lease = targetCoordinator.beginReplaceLocked(old, req.next, req.ready) | |
| 588 | - | result.cleanups = append(result.cleanups, cleanup) | |
| 589 | - | } | |
| 590 | - | result.published = attachmentRoleToken{ | |
| 591 | - | sess: req.target, | |
| 592 | - | ac: req.next, | |
| 593 | - | role: req.targetRole, | |
| 594 | - | generation: nextGeneration, | |
| 595 | - | transport: req.expectedTransport, | |
| 596 | - | lease: lease, | |
| 597 | - | rebase: source != req.target && req.expectedRole == attachmentActive, | |
| 598 | - | } | |
| 599 | - | if displacedGeneration != 0 { | |
| 600 | - | result.displaced = attachmentRoleToken{ | |
| 601 | - | sess: req.target, | |
| 602 | - | ac: old, | |
| 603 | - | role: attachmentSnatched, | |
| 604 | - | generation: displacedGeneration, | |
| 605 | - | transport: displacedTransport, | |
| 606 | - | } | |
| 607 | - | result.displacedInterrupted = req.expectedTargetTransportInterrupted | |
| 608 | - | } | |
| 609 | - | // Membership, currentSession, lease, generation, and both exact admission | |
| 610 | - | // capabilities become visible as one frozen publication. The gates remain | |
| 611 | - | // frozen until every architecture lock below has been released. | |
| 612 | - | if req.roleEffectsFrozen { | |
| 613 | - | req.next.publishFrozenRoleCapability(result.published) | |
| 614 | - | if result.displaced.ac != nil { | |
| 615 | - | result.displaced.ac.publishFrozenRoleCapability(result.displaced) | |
| 616 | - | } | |
| 617 | - | } | |
| 618 | - | unlockCoordinators() | |
| 619 | - | unlockSessions() | |
| 620 | - | return result, nil | |
| 621 | - | } | |
| 622 | - | ||
| 623 | 61 | func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool { | |
| 624 | 62 | ac := token.ac | |
| 625 | 63 | ac.sendMu.Lock() | |
| @@ -667,233 +105,3 @@ func (d *Daemon) boundedSendSnatchedControl(token attachmentRoleToken, frame por | |||
| 667 | 105 | } | |
| 668 | 106 | return err | |
| 669 | 107 | } | |
| 670 | - | ||
| 671 | - | // removeSnatchedAttachment removes exactly one role generation and transport | |
| 672 | - | // from session routing. It never touches the active attachment or closes a | |
| 673 | - | // transport, allowing callers that already interrupted a blocked send to avoid | |
| 674 | - | // closing the same link twice. | |
| 675 | - | func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool { | |
| 676 | - | return d.unrouteSnatchedAttachment(token, true) | |
| 677 | - | } | |
| 678 | - | ||
| 679 | - | func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool { | |
| 680 | - | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 681 | - | return false | |
| 682 | - | } | |
| 683 | - | frozen := freezeRoleEffectGates(token.ac) | |
| 684 | - | defer frozen.unfreeze() | |
| 685 | - | d.notices.routingMu.Lock() | |
| 686 | - | token.sess.mu.Lock() | |
| 687 | - | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 688 | - | token.ac.roleGeneration.Load() == token.generation && | |
| 689 | - | token.ac.transportSnapshotCurrent(token.transport) | |
| 690 | - | if current { | |
| 691 | - | delete(token.sess.snatched, token.ac) | |
| 692 | - | token.ac.roleGeneration.Add(1) | |
| 693 | - | token.ac.invalidateFrozenRoleCapability() | |
| 694 | - | } | |
| 695 | - | token.sess.mu.Unlock() | |
| 696 | - | d.notices.routingMu.Unlock() | |
| 697 | - | if !current { | |
| 698 | - | return false | |
| 699 | - | } | |
| 700 | - | ||
| 701 | - | token.ac.clearSnatchedInput() | |
| 702 | - | d.unregisterPreview(token.ac) | |
| 703 | - | if terminal { | |
| 704 | - | token.ac.clearPreviousSession() | |
| 705 | - | } | |
| 706 | - | token.ac.setSession(nil) | |
| 707 | - | token.ac.clearCaptureFrames() | |
| 708 | - | return true | |
| 709 | - | } | |
| 710 | - | ||
| 711 | - | // parkSnatchedAttachment removes only the exact lost snatched incarnation and | |
| 712 | - | // retains its resume identity for a role-preserving reconnect. | |
| 713 | - | func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool { | |
| 714 | - | if token.ac == nil || !token.ac.resumeCapable || !d.unrouteSnatchedAttachment(token, false) { | |
| 715 | - | return false | |
| 716 | - | } | |
| 717 | - | if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | |
| 718 | - | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 719 | - | return true | |
| 720 | - | } | |
| 721 | - | token.ac.clearPreviousSession() | |
| 722 | - | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 723 | - | return false | |
| 724 | - | } | |
| 725 | - | ||
| 726 | - | func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool { | |
| 727 | - | if d.parkSnatchedAttachment(token) { | |
| 728 | - | return true | |
| 729 | - | } | |
| 730 | - | return d.dropSnatchedAttachment(token) | |
| 731 | - | } | |
| 732 | - | ||
| 733 | - | // cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced | |
| 734 | - | // transport that was closed to drain an admitted send. It waits on the gate's | |
| 735 | - | // condition rather than ordinary effect admission, then removes only the exact | |
| 736 | - | // published snatched incarnation. Attachment-local cleanup runs without routing | |
| 737 | - | // or session locks while the terminal capability remains frozen. | |
| 738 | - | func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool { | |
| 739 | - | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 740 | - | return false | |
| 741 | - | } | |
| 742 | - | frozen := freezeRoleEffectGates(token.ac) | |
| 743 | - | defer frozen.unfreeze() | |
| 744 | - | ||
| 745 | - | d.notices.routingMu.Lock() | |
| 746 | - | token.sess.mu.Lock() | |
| 747 | - | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 748 | - | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 749 | - | token.ac.transportSnapshotCurrent(token.transport) | |
| 750 | - | token.sess.mu.Unlock() | |
| 751 | - | d.notices.routingMu.Unlock() | |
| 752 | - | if !current { | |
| 753 | - | return false | |
| 754 | - | } | |
| 755 | - | ||
| 756 | - | // The frozen gate prevents a promotion from publishing between overlay | |
| 757 | - | // cleanup and terminal registry publication. | |
| 758 | - | d.clearForSnatch(token) | |
| 759 | - | ||
| 760 | - | d.notices.routingMu.Lock() | |
| 761 | - | token.sess.mu.Lock() | |
| 762 | - | current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 763 | - | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 764 | - | token.ac.transportSnapshotCurrent(token.transport) | |
| 765 | - | if current { | |
| 766 | - | delete(token.sess.snatched, token.ac) | |
| 767 | - | token.ac.roleGeneration.Add(1) | |
| 768 | - | token.ac.invalidateFrozenRoleCapability() | |
| 769 | - | } | |
| 770 | - | token.sess.mu.Unlock() | |
| 771 | - | d.notices.routingMu.Unlock() | |
| 772 | - | if !current { | |
| 773 | - | return false | |
| 774 | - | } | |
| 775 | - | ||
| 776 | - | token.ac.setSession(nil) | |
| 777 | - | if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | |
| 778 | - | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 779 | - | token.ac.clearSnatchedInput() | |
| 780 | - | d.unregisterPreview(token.ac) | |
| 781 | - | return true | |
| 782 | - | } | |
| 783 | - | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 784 | - | token.ac.clearSnatchedInput() | |
| 785 | - | d.unregisterPreview(token.ac) | |
| 786 | - | token.ac.clearPreviousSession() | |
| 787 | - | token.ac.clearCaptureFrames() | |
| 788 | - | return true | |
| 789 | - | } | |
| 790 | - | ||
| 791 | - | // dropSnatchedAttachment removes and closes only the captured snatched link. | |
| 792 | - | func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool { | |
| 793 | - | if !d.removeSnatchedAttachment(token) { | |
| 794 | - | return false | |
| 795 | - | } | |
| 796 | - | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |
| 797 | - | return true | |
| 798 | - | } | |
| 799 | - | ||
| 800 | - | func validAttachmentTransitionRole(role attachmentRole, expected bool) bool { | |
| 801 | - | if role == attachmentActive || role == attachmentSnatched { | |
| 802 | - | return true | |
| 803 | - | } | |
| 804 | - | return expected && role == attachmentDetached | |
| 805 | - | } | |
| 806 | - | ||
| 807 | - | // lockAttachmentSessions gives every two-session transition one stable order. | |
| 808 | - | // Session IDs are immutable and unique while their lifecycles are registered. | |
| 809 | - | func lockAttachmentSessions(a, b *session) func() { | |
| 810 | - | if a == b { | |
| 811 | - | a.mu.Lock() | |
| 812 | - | return a.mu.Unlock | |
| 813 | - | } | |
| 814 | - | first, second := a, b | |
| 815 | - | if first.id > second.id { | |
| 816 | - | first, second = second, first | |
| 817 | - | } | |
| 818 | - | first.mu.Lock() | |
| 819 | - | second.mu.Lock() | |
| 820 | - | return func() { | |
| 821 | - | second.mu.Unlock() | |
| 822 | - | first.mu.Unlock() | |
| 823 | - | } | |
| 824 | - | } | |
| 825 | - | ||
| 826 | - | // lockAttachmentCoordinators follows the same immutable session-ID order as | |
| 827 | - | // lockAttachmentSessions. Callers already hold the ordered session locks. | |
| 828 | - | func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() { | |
| 829 | - | if aCoordinator == nil && bCoordinator == nil { | |
| 830 | - | return func() {} | |
| 831 | - | } | |
| 832 | - | if a == b || aCoordinator == bCoordinator { | |
| 833 | - | coordinator := aCoordinator | |
| 834 | - | if coordinator == nil { | |
| 835 | - | coordinator = bCoordinator | |
| 836 | - | } | |
| 837 | - | coordinator.mu.Lock() | |
| 838 | - | return coordinator.mu.Unlock | |
| 839 | - | } | |
| 840 | - | first, second := aCoordinator, bCoordinator | |
| 841 | - | if a.id > b.id { | |
| 842 | - | first, second = second, first | |
| 843 | - | } | |
| 844 | - | if first != nil { | |
| 845 | - | first.mu.Lock() | |
| 846 | - | } | |
| 847 | - | if second != nil { | |
| 848 | - | second.mu.Lock() | |
| 849 | - | } | |
| 850 | - | return func() { | |
| 851 | - | if second != nil { | |
| 852 | - | second.mu.Unlock() | |
| 853 | - | } | |
| 854 | - | if first != nil { | |
| 855 | - | first.mu.Unlock() | |
| 856 | - | } | |
| 857 | - | } | |
| 858 | - | } | |
| 859 | - | ||
| 860 | - | // deferAttachmentTransitionCleanups retires coordinator timers and accounting | |
| 861 | - | // after ownership publication. Cleanup never gates the attaching handshake. A | |
| 862 | - | // displaced render that already owns sendMu is interrupted by closing only its | |
| 863 | - | // captured transport; an idle healthy snatched transport remains connected and | |
| 864 | - | // receives a dependency-free reset panel. | |
| 865 | - | func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) { | |
| 866 | - | if token := result.displaced; token.ac != nil && token.transport.transport != nil { | |
| 867 | - | blockedRender := result.displacedInterrupted | |
| 868 | - | if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) { | |
| 869 | - | blockedRender = !token.ac.sendMu.TryLock() | |
| 870 | - | if !blockedRender { | |
| 871 | - | token.ac.sendMu.Unlock() | |
| 872 | - | } | |
| 873 | - | } | |
| 874 | - | d.attachmentCleanupWg.Go(func() { | |
| 875 | - | if d.afterDisplacedCleanupStarted != nil { | |
| 876 | - | d.afterDisplacedCleanupStarted() | |
| 877 | - | } | |
| 878 | - | if blockedRender { | |
| 879 | - | // This path owns terminal cleanup independently of ordinary role | |
| 880 | - | // admission: a stale send-error detach may transiently freeze the gate. | |
| 881 | - | _ = token.ac.closeCapturedTransport(token.transport.transport) | |
| 882 | - | d.cleanupInterruptedSnatchedAttachment(token) | |
| 883 | - | return | |
| 884 | - | } | |
| 885 | - | ticket, admitted := token.ac.beginRoleEffect(token) | |
| 886 | - | if !admitted { | |
| 887 | - | return | |
| 888 | - | } | |
| 889 | - | defer ticket.End() | |
| 890 | - | if err := d.sendInitialSnatchedPanel(token, ticket); err != nil { | |
| 891 | - | ticket.End() | |
| 892 | - | d.parkOrDropSnatchedAttachment(token) | |
| 893 | - | } | |
| 894 | - | }) | |
| 895 | - | } | |
| 896 | - | for _, cleanup := range result.cleanups { | |
| 897 | - | d.attachmentCleanupWg.Go(cleanup.finish) | |
| 898 | - | } | |
| 899 | - | } | |
| @@ -6,14 +6,6 @@ import ( | |||||
| 6 | "github.com/bnema/vev/internal/ports" | 6 | "github.com/bnema/vev/internal/ports" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | type attachmentRole uint8 | |||
| 10 | - | ||||
| 11 | - | const ( | |||
| 12 | - | attachmentDetached attachmentRole = iota | |||
| 13 | - | attachmentActive | |||
| 14 | - | attachmentSnatched | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | type initialSnatchedPanelAttempt struct { | 9 | type initialSnatchedPanelAttempt struct { | ||
| 18 | done chan struct{} | 10 | done chan struct{} | ||
| 19 | err error | 11 | err error | ||
| @@ -66,560 +58,6 @@ func (d *Daemon) sendInitialSnatchedPanel(token attachmentRoleToken, ticket *rol | |||||
| 66 | return err | 58 | return err | ||
| 67 | } | 59 | } | ||
| 68 | 60 | ||||
| 69 | - | // attachmentRole derives the attachment's role from the session-owned | |||
| 70 | - | // registries. There is deliberately no second role field that could drift. | |||
| 71 | - | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | |||
| 72 | - | if s == nil || ac == nil { | |||
| 73 | - | return attachmentDetached | |||
| 74 | - | } | |||
| 75 | - | s.mu.Lock() | |||
| 76 | - | defer s.mu.Unlock() | |||
| 77 | - | return s.attachmentRoleLocked(ac) | |||
| 78 | - | } | |||
| 79 | - | ||||
| 80 | - | // attachmentRoleLocked requires s.mu. Active membership wins if an invalid | |||
| 81 | - | // intermediate state contains the same attachment in both registries. | |||
| 82 | - | func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole { | |||
| 83 | - | if ac == nil { | |||
| 84 | - | return attachmentDetached | |||
| 85 | - | } | |||
| 86 | - | if s.client == ac { | |||
| 87 | - | return attachmentActive | |||
| 88 | - | } | |||
| 89 | - | if _, ok := s.snatched[ac]; ok { | |||
| 90 | - | return attachmentSnatched | |||
| 91 | - | } | |||
| 92 | - | return attachmentDetached | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | func (s *session) addSnatchedLocked(ac *attachedClient) { | |||
| 96 | - | if ac == nil { | |||
| 97 | - | return | |||
| 98 | - | } | |||
| 99 | - | if s.snatched == nil { | |||
| 100 | - | s.snatched = make(map[*attachedClient]struct{}) | |||
| 101 | - | } | |||
| 102 | - | s.snatched[ac] = struct{}{} | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | var errAttachmentTransition = errors.New("attachment transition is no longer valid") | |||
| 106 | - | ||||
| 107 | - | type attachmentTransitionRequest struct { | |||
| 108 | - | source *session | |||
| 109 | - | target *session | |||
| 110 | - | next *attachedClient | |||
| 111 | - | expectedRole attachmentRole | |||
| 112 | - | targetRole attachmentRole | |||
| 113 | - | expectedTransport transportSnapshot | |||
| 114 | - | sourceToken *attachmentRoleToken | |||
| 115 | - | action string | |||
| 116 | - | activateTargetTab bool | |||
| 117 | - | targetTabIndex int | |||
| 118 | - | copySourceEnvironment bool | |||
| 119 | - | // preserveRole commits navigation state without replacing attachment | |||
| 120 | - | // ownership. It is used for same-session picker selections after the exact | |||
| 121 | - | // initiating capability has been frozen and revalidated. | |||
| 122 | - | preserveRole bool | |||
| 123 | - | expectedSourceTab *tab | |||
| 124 | - | createTargetLocked func() (*session, error) | |||
| 125 | - | ready bool | |||
| 126 | - | expectedTargetCurrent *attachedClient | |||
| 127 | - | expectedTargetTransport transportSnapshot | |||
| 128 | - | preflighted bool | |||
| 129 | - | roleEffectsFrozen bool | |||
| 130 | - | expectedTargetTransportInterrupted bool | |||
| 131 | - | // activationBarrier is used when promoting an already connected snatched | |||
| 132 | - | // attachment. Its role gate is deadline-drained before sendMu is acquired, | |||
| 133 | - | // preserving the gate -> sendMu -> daemon/routing/session publication order. | |||
| 134 | - | activationBarrier bool | |||
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool { | |||
| 138 | - | return token.sess == source && token.ac == req.next && token.role == req.expectedRole && | |||
| 139 | - | token.transport.transport == req.expectedTransport.transport && | |||
| 140 | - | token.transport.incarnation == req.expectedTransport.incarnation | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | // transitionSourceTokenCurrentLocked requires source.mu and, for an active | |||
| 144 | - | // source, sourceCoordinator.mu. This is the canonical handoff preflight: every | |||
| 145 | - | // client-originated navigation, delete, or create intent is bound to one exact | |||
| 146 | - | // role generation, transport incarnation, and coordinator lease. | |||
| 147 | - | func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { | |||
| 148 | - | if token.sess != source || token.ac != req.next || token.role != req.expectedRole || | |||
| 149 | - | token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role || | |||
| 150 | - | req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) { | |||
| 151 | - | return false | |||
| 152 | - | } | |||
| 153 | - | if token.role != attachmentActive { | |||
| 154 | - | return true | |||
| 155 | - | } | |||
| 156 | - | return sourceCoordinator != nil && token.lease != nil && token.lease.attachment == req.next && | |||
| 157 | - | sourceCoordinator.leaseCurrentLocked(token.lease, true) | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | type attachmentRoleToken struct { | |||
| 161 | - | sess *session | |||
| 162 | - | ac *attachedClient | |||
| 163 | - | role attachmentRole | |||
| 164 | - | generation uint64 | |||
| 165 | - | transport transportSnapshot | |||
| 166 | - | lease *attachmentLease | |||
| 167 | - | effect *roleEffectTicket | |||
| 168 | - | // rebase marks a cross-session ownership move whose first paint must start a | |||
| 169 | - | // dependency-free output chain. The rebase is deliberately deferred until | |||
| 170 | - | // after transition publication releases all architecture locks. | |||
| 171 | - | rebase bool | |||
| 172 | - | } | |||
| 173 | - | ||||
| 174 | - | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |||
| 175 | - | if s == nil || ac == nil || tr == nil { | |||
| 176 | - | return attachmentRoleToken{} | |||
| 177 | - | } | |||
| 178 | - | s.mu.Lock() | |||
| 179 | - | transport := ac.transportSnapshot() | |||
| 180 | - | if transport.transport != tr { | |||
| 181 | - | s.mu.Unlock() | |||
| 182 | - | return attachmentRoleToken{} | |||
| 183 | - | } | |||
| 184 | - | token := attachmentRoleToken{ | |||
| 185 | - | sess: s, | |||
| 186 | - | ac: ac, | |||
| 187 | - | role: s.attachmentRoleLocked(ac), | |||
| 188 | - | generation: ac.roleGeneration.Load(), | |||
| 189 | - | transport: transport, | |||
| 190 | - | } | |||
| 191 | - | s.mu.Unlock() | |||
| 192 | - | if token.role == attachmentActive { | |||
| 193 | - | if rc := s.renderCoordinator(); rc != nil { | |||
| 194 | - | token.lease = rc.attachmentLease(ac) | |||
| 195 | - | } | |||
| 196 | - | } | |||
| 197 | - | ac.bootstrapRoleCapability(token) | |||
| 198 | - | return token | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | func (t attachmentRoleToken) current() bool { | |||
| 202 | - | return t.sess != nil && t.ac != nil && | |||
| 203 | - | t.ac.roleGeneration.Load() == t.generation && | |||
| 204 | - | t.ac.transportSnapshotCurrent(t.transport) && | |||
| 205 | - | t.sess.attachmentRole(t.ac) == t.role | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | // activeCurrent admits post-transition effects only for the exact published | |||
| 209 | - | // role, transport incarnation, current-session link, and coordinator lease. | |||
| 210 | - | func (t attachmentRoleToken) activeCurrent() bool { | |||
| 211 | - | if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess { | |||
| 212 | - | return false | |||
| 213 | - | } | |||
| 214 | - | rc := t.sess.renderCoordinator() | |||
| 215 | - | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |||
| 216 | - | } | |||
| 217 | - | ||||
| 218 | - | // activeEffect is the canonical admission check for a client-frame mutation. | |||
| 219 | - | // Tokens bind role generation, transport incarnation, session ownership, and | |||
| 220 | - | // coordinator lease, so handlers never rediscover authority from mutable state. | |||
| 221 | - | func (t attachmentRoleToken) activeEffect() bool { | |||
| 222 | - | if t.effect != nil && !t.effect.ended.Load() { | |||
| 223 | - | return true | |||
| 224 | - | } | |||
| 225 | - | return t.activeCurrent() | |||
| 226 | - | } | |||
| 227 | - | ||||
| 228 | - | func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) { | |||
| 229 | - | if sess == nil || ac == nil || lease == nil { | |||
| 230 | - | return nil, false | |||
| 231 | - | } | |||
| 232 | - | token := sess.attachmentToken(ac, ac.transport()) | |||
| 233 | - | token.lease = lease | |||
| 234 | - | return ac.beginRoleEffect(token) | |||
| 235 | - | } | |||
| 236 | - | ||||
| 237 | - | func (t *attachmentRoleToken) endRoleEffect() { | |||
| 238 | - | if t == nil || t.effect == nil { | |||
| 239 | - | return | |||
| 240 | - | } | |||
| 241 | - | t.effect.End() | |||
| 242 | - | t.effect = nil | |||
| 243 | - | } | |||
| 244 | - | ||||
| 245 | - | // activeEffectSessionLocked is the same admission check while t.sess.mu is | |||
| 246 | - | // already held at a session-owned mutation boundary. | |||
| 247 | - | func (t attachmentRoleToken) activeEffectSessionLocked() bool { | |||
| 248 | - | if t.effect != nil && !t.effect.ended.Load() { | |||
| 249 | - | return true | |||
| 250 | - | } | |||
| 251 | - | if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil || | |||
| 252 | - | t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) || | |||
| 253 | - | t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive { | |||
| 254 | - | return false | |||
| 255 | - | } | |||
| 256 | - | rc := t.sess.renderCoordinator() | |||
| 257 | - | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |||
| 258 | - | } | |||
| 259 | - | ||||
| 260 | - | func (t attachmentRoleToken) sendActiveControl(frame ports.Frame) error { | |||
| 261 | - | if t.ac == nil { | |||
| 262 | - | return errAttachmentTransition | |||
| 263 | - | } | |||
| 264 | - | t.ac.sendMu.Lock() | |||
| 265 | - | defer t.ac.sendMu.Unlock() | |||
| 266 | - | if t.effect == nil || t.effect.ended.Load() || !t.ac.transportSnapshotCurrent(t.transport) || | |||
| 267 | - | !t.effect.beginTransportSend(t.transport) { | |||
| 268 | - | return errAttachmentTransition | |||
| 269 | - | } | |||
| 270 | - | err := t.transport.transport.Send(frame) | |||
| 271 | - | if err != nil { | |||
| 272 | - | t.effect.reportTransportFailure(t.transport) | |||
| 273 | - | } | |||
| 274 | - | t.effect.endTransportSend() | |||
| 275 | - | return err | |||
| 276 | - | } | |||
| 277 | - | ||||
| 278 | - | type attachmentTransitionResult struct { | |||
| 279 | - | published attachmentRoleToken | |||
| 280 | - | displaced attachmentRoleToken | |||
| 281 | - | displacedInterrupted bool | |||
| 282 | - | cleanups []renderLifecycleCleanup | |||
| 283 | - | } | |||
| 284 | - | ||||
| 285 | - | func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |||
| 286 | - | // Preflight identifies every gate the publication can affect. No gate is | |||
| 287 | - | // frozen while an architecture lock is held. | |||
| 288 | - | d.mu.Lock() | |||
| 289 | - | source := req.source | |||
| 290 | - | if source == nil { | |||
| 291 | - | source = req.target | |||
| 292 | - | } | |||
| 293 | - | if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing || | |||
| 294 | - | d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target || | |||
| 295 | - | req.activationBarrier && (req.sourceToken == nil || source != req.target || | |||
| 296 | - | req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) { | |||
| 297 | - | d.mu.Unlock() | |||
| 298 | - | return attachmentTransitionResult{}, errAttachmentTransition | |||
| 299 | - | } | |||
| 300 | - | if req.target != nil { | |||
| 301 | - | req.target.mu.Lock() | |||
| 302 | - | req.expectedTargetCurrent = req.target.client | |||
| 303 | - | if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive { | |||
| 304 | - | req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot() | |||
| 305 | - | } | |||
| 306 | - | req.target.mu.Unlock() | |||
| 307 | - | } | |||
| 308 | - | if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) { | |||
| 309 | - | d.mu.Unlock() | |||
| 310 | - | return attachmentTransitionResult{}, errAttachmentTransition | |||
| 311 | - | } | |||
| 312 | - | req.preflighted = true | |||
| 313 | - | d.mu.Unlock() | |||
| 314 | - | ||||
| 315 | - | if req.sourceToken != nil { | |||
| 316 | - | d.endActionRoleEffect(req.sourceToken.effect, req.action) | |||
| 317 | - | } | |||
| 318 | - | ||||
| 319 | - | participants := []*attachedClient{req.next, req.expectedTargetCurrent} | |||
| 320 | - | if d.afterRoleEffectParticipantsSnapshotted != nil { | |||
| 321 | - | d.afterRoleEffectParticipantsSnapshotted(req.action, participants) | |||
| 322 | - | } | |||
| 323 | - | interrupts := []roleTransportInterrupt{{ | |||
| 324 | - | ac: req.expectedTargetCurrent, transport: req.expectedTargetTransport, | |||
| 325 | - | }} | |||
| 326 | - | var drainDeadline *roleEffectDrainDeadline | |||
| 327 | - | var drainDone func() <-chan struct{} | |||
| 328 | - | if req.activationBarrier { | |||
| 329 | - | drainDeadline = newRoleEffectDrainDeadline(d.clock) | |||
| 330 | - | drainDone = drainDeadline.Done | |||
| 331 | - | } | |||
| 332 | - | frozen := freezeRoleEffectGatesInterruptingObservedUntil(interrupts, drainDone, func(ac *attachedClient) { | |||
| 333 | - | if d.afterRoleEffectGateFrozen != nil { | |||
| 334 | - | d.afterRoleEffectGateFrozen(req.action, ac) | |||
| 335 | - | } | |||
| 336 | - | }, participants...) | |||
| 337 | - | if drainDeadline != nil { | |||
| 338 | - | drainDeadline.stop() | |||
| 339 | - | } | |||
| 340 | - | defer frozen.unfreeze() | |||
| 341 | - | if !frozen.acquired || !frozen.drained { | |||
| 342 | - | if req.activationBarrier { | |||
| 343 | - | _ = req.next.closeCapturedTransport(req.expectedTransport.transport) | |||
| 344 | - | return attachmentTransitionResult{}, errSendTimedOut | |||
| 345 | - | } | |||
| 346 | - | return attachmentTransitionResult{}, errAttachmentTransition | |||
| 347 | - | } | |||
| 348 | - | req.expectedTargetTransportInterrupted = frozen.interrupted(req.expectedTargetCurrent, req.expectedTargetTransport) | |||
| 349 | - | if d.afterRoleEffectsFrozen != nil { | |||
| 350 | - | d.afterRoleEffectsFrozen() | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | - | var releaseActivation func() | |||
| 354 | - | if req.activationBarrier { | |||
| 355 | - | var err error | |||
| 356 | - | releaseActivation, err = d.acquireActivationBarrier(*req.sourceToken) | |||
| 357 | - | if err != nil { | |||
| 358 | - | return attachmentTransitionResult{}, err | |||
| 359 | - | } | |||
| 360 | - | defer func() { | |||
| 361 | - | if releaseActivation != nil { | |||
| 362 | - | releaseActivation() | |||
| 363 | - | } | |||
| 364 | - | }() | |||
| 365 | - | } | |||
| 366 | - | ||||
| 367 | - | d.mu.Lock() | |||
| 368 | - | req.roleEffectsFrozen = true | |||
| 369 | - | if req.target == nil { | |||
| 370 | - | if !d.transitionSourcePreflightLocked(req) { | |||
| 371 | - | d.mu.Unlock() | |||
| 372 | - | return attachmentTransitionResult{}, errAttachmentTransition | |||
| 373 | - | } | |||
| 374 | - | var err error | |||
| 375 | - | req.target, err = req.createTargetLocked() | |||
| 376 | - | if err != nil { | |||
| 377 | - | d.mu.Unlock() | |||
| 378 | - | return attachmentTransitionResult{}, err | |||
| 379 | - | } | |||
| 380 | - | req.expectedTargetCurrent = nil | |||
| 381 | - | } | |||
| 382 | - | result, err := d.transitionAttachmentLocked(req) | |||
| 383 | - | d.mu.Unlock() | |||
| 384 | - | if err == nil && req.activationBarrier && result.published.role == attachmentActive { | |||
| 385 | - | ac := result.published.ac | |||
| 386 | - | if ac.output != nil { | |||
| 387 | - | ac.output.rebase() | |||
| 388 | - | } | |||
| 389 | - | ac.captureFrames = nil | |||
| 390 | - | d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false) | |||
| 391 | - | // The first paint still requests a reset, but the old panel dependency | |||
| 392 | - | // chain has already been rebased atomically under the activation barrier. | |||
| 393 | - | result.published.rebase = false | |||
| 394 | - | } | |||
| 395 | - | if releaseActivation != nil { | |||
| 396 | - | releaseActivation() | |||
| 397 | - | releaseActivation = nil | |||
| 398 | - | } | |||
| 399 | - | if err == nil && result.published.role == attachmentActive { | |||
| 400 | - | result.published.ac.clearSnatchedInput() | |||
| 401 | - | } | |||
| 402 | - | return result, err | |||
| 403 | - | } | |||
| 404 | - | ||||
| 405 | - | // transitionSourcePreflightLocked validates an initiating role while its gate | |||
| 406 | - | // is frozen and d.mu is held. No caller may create or delete a target before | |||
| 407 | - | // this succeeds. | |||
| 408 | - | func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool { | |||
| 409 | - | if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source { | |||
| 410 | - | return false | |||
| 411 | - | } | |||
| 412 | - | d.notices.routingMu.Lock() | |||
| 413 | - | defer d.notices.routingMu.Unlock() | |||
| 414 | - | req.source.mu.Lock() | |||
| 415 | - | defer req.source.mu.Unlock() | |||
| 416 | - | coordinator := req.source.renderCoordinator() | |||
| 417 | - | if coordinator == nil { | |||
| 418 | - | return false | |||
| 419 | - | } | |||
| 420 | - | coordinator.mu.Lock() | |||
| 421 | - | defer coordinator.mu.Unlock() | |||
| 422 | - | if !transitionSourceTokenCurrentLocked(*req.sourceToken, req.source, coordinator, req) { | |||
| 423 | - | return false | |||
| 424 | - | } | |||
| 425 | - | return transitionSourceTabCurrentLocked(req.source, req.expectedSourceTab) | |||
| 426 | - | } | |||
| 427 | - | ||||
| 428 | - | func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |||
| 429 | - | if token.sess == nil || token.ac == nil || token.role != attachmentActive { | |||
| 430 | - | return false | |||
| 431 | - | } | |||
| 432 | - | d.mu.Lock() | |||
| 433 | - | defer d.mu.Unlock() | |||
| 434 | - | if d.closing || d.sessions[token.sess.id] != token.sess { | |||
| 435 | - | return false | |||
| 436 | - | } | |||
| 437 | - | d.notices.routingMu.Lock() | |||
| 438 | - | defer d.notices.routingMu.Unlock() | |||
| 439 | - | token.sess.mu.Lock() | |||
| 440 | - | defer token.sess.mu.Unlock() | |||
| 441 | - | coordinator := token.sess.renderCoordinator() | |||
| 442 | - | if coordinator == nil { | |||
| 443 | - | return false | |||
| 444 | - | } | |||
| 445 | - | coordinator.mu.Lock() | |||
| 446 | - | defer coordinator.mu.Unlock() | |||
| 447 | - | req := attachmentTransitionRequest{ | |||
| 448 | - | source: token.sess, next: token.ac, expectedRole: token.role, | |||
| 449 | - | expectedTransport: token.transport, | |||
| 450 | - | } | |||
| 451 | - | return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |||
| 452 | - | } | |||
| 453 | - | ||||
| 454 | - | func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | |||
| 455 | - | if expected == nil { | |||
| 456 | - | return true | |||
| 457 | - | } | |||
| 458 | - | if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected { | |||
| 459 | - | return false | |||
| 460 | - | } | |||
| 461 | - | expected.mu.Lock() | |||
| 462 | - | defer expected.mu.Unlock() | |||
| 463 | - | return expected.floating.state != floatingVisible | |||
| 464 | - | } | |||
| 465 | - | ||||
| 466 | - | // transitionAttachmentLocked is the publication half of transitionAttachment. | |||
| 467 | - | // Caller holds d.mu after the lock-free gate freeze/drain has completed. | |||
| 468 | - | func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |||
| 469 | - | d.notices.routingMu.Lock() | |||
| 470 | - | defer d.notices.routingMu.Unlock() | |||
| 471 | - | return d.transitionAttachmentRoutedLocked(req) | |||
| 472 | - | } | |||
| 473 | - | ||||
| 474 | - | // transitionAttachmentRoutedLocked performs the centralized role publication. | |||
| 475 | - | // Caller holds d.mu and notices.routingMu. Every fallible lifecycle, role, and | |||
| 476 | - | // transport check occurs before session.client is changed. Coordinator | |||
| 477 | - | // finalization after that commit point cannot roll ownership back. | |||
| 478 | - | func (d *Daemon) transitionAttachmentRoutedLocked(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |||
| 479 | - | source := req.source | |||
| 480 | - | if source == nil { | |||
| 481 | - | source = req.target | |||
| 482 | - | } | |||
| 483 | - | if !req.preflighted || !req.roleEffectsFrozen || | |||
| 484 | - | !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) || | |||
| 485 | - | source == nil || req.target == nil || req.next == nil || d.closing || | |||
| 486 | - | d.sessions[source.id] != source || d.sessions[req.target.id] != req.target || | |||
| 487 | - | req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) { | |||
| 488 | - | return attachmentTransitionResult{}, errAttachmentTransition | |||
| 489 | - | } | |||
| 490 | - | // Install the target coordinator object before taking both session locks; | |||
| 491 | - | // lease binding itself remains part of the atomic publication below. | |||
| 492 | - | var targetCoordinator *renderCoordinator | |||
| 493 | - | if req.targetRole == attachmentActive { | |||
| 494 | - | targetCoordinator = d.ensureRenderCoordinator(req.target) | |||
| 495 | - | } | |||
| 496 | - | sourceCoordinator := source.renderCoordinator() | |||
| 497 | - | ||||
| 498 | - | unlockSessions := lockAttachmentSessions(source, req.target) | |||
| 499 | - | if source.attachmentRoleLocked(req.next) != req.expectedRole || | |||
| 500 | - | !req.next.transportSnapshotCurrent(req.expectedTransport) { | |||
| 501 | - | unlockSessions() | |||
| 502 | - | return attachmentTransitionResult{}, errAttachmentTransition | |||
| 503 | - | } | |||
| 504 | - | ||||
| 505 | - | old := req.target.client | |||
| 506 | - | if req.preflighted && old != req.expectedTargetCurrent { | |||
| 507 | - | unlockSessions() | |||
| 508 | - | return attachmentTransitionResult{}, errAttachmentTransition | |||
| 509 | - | } | |||
| 510 | - | if source != req.target && old == req.next { | |||
| 511 | - | unlockSessions() | |||
| 512 | - | return attachmentTransitionResult{}, errAttachmentTransition | |||
| 513 | - | } | |||
| 514 | - | unlockCoordinators := lockAttachmentCoordinators(source, sourceCoordinator, req.target, targetCoordinator) | |||
| 515 | - | if req.sourceToken != nil && (!transitionSourceTokenCurrentLocked(*req.sourceToken, source, sourceCoordinator, req) || | |||
| 516 | - | !transitionSourceTabCurrentLocked(source, req.expectedSourceTab)) { | |||
| 517 | - | unlockCoordinators() | |||
| 518 | - | unlockSessions() | |||
| 519 | - | return attachmentTransitionResult{}, errAttachmentTransition | |||
| 520 | - | } | |||
| 521 | - | if targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) { | |||
| 522 | - | unlockCoordinators() | |||
| 523 | - | unlockSessions() | |||
| 524 | - | return attachmentTransitionResult{}, errAttachmentTransition | |||
| 525 | - | } | |||
| 526 | - | if req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | |||
| 527 | - | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) { | |||
| 528 | - | unlockCoordinators() | |||
| 529 | - | unlockSessions() | |||
| 530 | - | return attachmentTransitionResult{}, errAttachmentTransition | |||
| 531 | - | } | |||
| 532 | - | if req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) { | |||
| 533 | - | unlockCoordinators() | |||
| 534 | - | unlockSessions() | |||
| 535 | - | return attachmentTransitionResult{}, errAttachmentTransition | |||
| 536 | - | } | |||
| 537 | - | ||||
| 538 | - | if req.targetRole == attachmentActive { | |||
| 539 | - | d.demoteParkedActiveForSessionLocked(req.target) | |||
| 540 | - | } | |||
| 541 | - | if req.copySourceEnvironment { | |||
| 542 | - | req.target.terminal = source.terminal | |||
| 543 | - | req.target.env = copyEnvironment(source.env) | |||
| 544 | - | } | |||
| 545 | - | if req.activateTargetTab { | |||
| 546 | - | req.target.active = req.targetTabIndex | |||
| 547 | - | } | |||
| 548 | - | if req.preserveRole { | |||
| 549 | - | unlockCoordinators() | |||
| 550 | - | unlockSessions() | |||
| 551 | - | return attachmentTransitionResult{published: *req.sourceToken}, nil | |||
| 552 | - | } | |||
| 553 | - | ||||
| 554 | - | var displacedTransport transportSnapshot | |||
| 555 | - | if req.targetRole == attachmentActive && old != nil && old != req.next { | |||
| 556 | - | displacedTransport = old.transportSnapshot() | |||
| 557 | - | } | |||
| 558 | - | if source != req.target { | |||
| 559 | - | if source.client == req.next { | |||
| 560 | - | source.client = nil | |||
| 561 | - | } | |||
| 562 | - | delete(source.snatched, req.next) | |||
| 563 | - | } | |||
| 564 | - | if req.targetRole == attachmentActive { | |||
| 565 | - | delete(req.target.snatched, req.next) | |||
| 566 | - | if old != nil && old != req.next { | |||
| 567 | - | req.target.addSnatchedLocked(old) | |||
| 568 | - | } | |||
| 569 | - | req.target.client = req.next | |||
| 570 | - | } else { | |||
| 571 | - | req.target.addSnatchedLocked(req.next) | |||
| 572 | - | } | |||
| 573 | - | nextGeneration := req.next.roleGeneration.Add(1) | |||
| 574 | - | var displacedGeneration uint64 | |||
| 575 | - | if old != nil && old != req.next && req.targetRole == attachmentActive { | |||
| 576 | - | displacedGeneration = old.roleGeneration.Add(1) | |||
| 577 | - | } | |||
| 578 | - | req.next.setSession(req.target) | |||
| 579 | - | ||||
| 580 | - | result := attachmentTransitionResult{} | |||
| 581 | - | if source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil { | |||
| 582 | - | result.cleanups = append(result.cleanups, sourceCoordinator.beginDetachLocked(req.next)) | |||
| 583 | - | } | |||
| 584 | - | var lease *attachmentLease | |||
| 585 | - | if targetCoordinator != nil { | |||
| 586 | - | var cleanup renderLifecycleCleanup | |||
| 587 | - | cleanup, lease = targetCoordinator.beginReplaceLocked(old, req.next, req.ready) | |||
| 588 | - | result.cleanups = append(result.cleanups, cleanup) | |||
| 589 | - | } | |||
| 590 | - | result.published = attachmentRoleToken{ | |||
| 591 | - | sess: req.target, | |||
| 592 | - | ac: req.next, | |||
| 593 | - | role: req.targetRole, | |||
| 594 | - | generation: nextGeneration, | |||
| 595 | - | transport: req.expectedTransport, | |||
| 596 | - | lease: lease, | |||
| 597 | - | rebase: source != req.target && req.expectedRole == attachmentActive, | |||
| 598 | - | } | |||
| 599 | - | if displacedGeneration != 0 { | |||
| 600 | - | result.displaced = attachmentRoleToken{ | |||
| 601 | - | sess: req.target, | |||
| 602 | - | ac: old, | |||
| 603 | - | role: attachmentSnatched, | |||
| 604 | - | generation: displacedGeneration, | |||
| 605 | - | transport: displacedTransport, | |||
| 606 | - | } | |||
| 607 | - | result.displacedInterrupted = req.expectedTargetTransportInterrupted | |||
| 608 | - | } | |||
| 609 | - | // Membership, currentSession, lease, generation, and both exact admission | |||
| 610 | - | // capabilities become visible as one frozen publication. The gates remain | |||
| 611 | - | // frozen until every architecture lock below has been released. | |||
| 612 | - | if req.roleEffectsFrozen { | |||
| 613 | - | req.next.publishFrozenRoleCapability(result.published) | |||
| 614 | - | if result.displaced.ac != nil { | |||
| 615 | - | result.displaced.ac.publishFrozenRoleCapability(result.displaced) | |||
| 616 | - | } | |||
| 617 | - | } | |||
| 618 | - | unlockCoordinators() | |||
| 619 | - | unlockSessions() | |||
| 620 | - | return result, nil | |||
| 621 | - | } | |||
| 622 | - | ||||
| 623 | func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool { | 61 | func (d *Daemon) clearCaptureFramesForSnatch(token attachmentRoleToken) bool { | ||
| 624 | ac := token.ac | 62 | ac := token.ac | ||
| 625 | ac.sendMu.Lock() | 63 | ac.sendMu.Lock() | ||
| @@ -667,233 +105,3 @@ func (d *Daemon) boundedSendSnatchedControl(token attachmentRoleToken, frame por | |||||
| 667 | } | 105 | } | ||
| 668 | return err | 106 | return err | ||
| 669 | } | 107 | } | ||
| 670 | - | ||||
| 671 | - | // removeSnatchedAttachment removes exactly one role generation and transport | |||
| 672 | - | // from session routing. It never touches the active attachment or closes a | |||
| 673 | - | // transport, allowing callers that already interrupted a blocked send to avoid | |||
| 674 | - | // closing the same link twice. | |||
| 675 | - | func (d *Daemon) removeSnatchedAttachment(token attachmentRoleToken) bool { | |||
| 676 | - | return d.unrouteSnatchedAttachment(token, true) | |||
| 677 | - | } | |||
| 678 | - | ||||
| 679 | - | func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal bool) bool { | |||
| 680 | - | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |||
| 681 | - | return false | |||
| 682 | - | } | |||
| 683 | - | frozen := freezeRoleEffectGates(token.ac) | |||
| 684 | - | defer frozen.unfreeze() | |||
| 685 | - | d.notices.routingMu.Lock() | |||
| 686 | - | token.sess.mu.Lock() | |||
| 687 | - | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |||
| 688 | - | token.ac.roleGeneration.Load() == token.generation && | |||
| 689 | - | token.ac.transportSnapshotCurrent(token.transport) | |||
| 690 | - | if current { | |||
| 691 | - | delete(token.sess.snatched, token.ac) | |||
| 692 | - | token.ac.roleGeneration.Add(1) | |||
| 693 | - | token.ac.invalidateFrozenRoleCapability() | |||
| 694 | - | } | |||
| 695 | - | token.sess.mu.Unlock() | |||
| 696 | - | d.notices.routingMu.Unlock() | |||
| 697 | - | if !current { | |||
| 698 | - | return false | |||
| 699 | - | } | |||
| 700 | - | ||||
| 701 | - | token.ac.clearSnatchedInput() | |||
| 702 | - | d.unregisterPreview(token.ac) | |||
| 703 | - | if terminal { | |||
| 704 | - | token.ac.clearPreviousSession() | |||
| 705 | - | } | |||
| 706 | - | token.ac.setSession(nil) | |||
| 707 | - | token.ac.clearCaptureFrames() | |||
| 708 | - | return true | |||
| 709 | - | } | |||
| 710 | - | ||||
| 711 | - | // parkSnatchedAttachment removes only the exact lost snatched incarnation and | |||
| 712 | - | // retains its resume identity for a role-preserving reconnect. | |||
| 713 | - | func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool { | |||
| 714 | - | if token.ac == nil || !token.ac.resumeCapable || !d.unrouteSnatchedAttachment(token, false) { | |||
| 715 | - | return false | |||
| 716 | - | } | |||
| 717 | - | if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | |||
| 718 | - | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |||
| 719 | - | return true | |||
| 720 | - | } | |||
| 721 | - | token.ac.clearPreviousSession() | |||
| 722 | - | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |||
| 723 | - | return false | |||
| 724 | - | } | |||
| 725 | - | ||||
| 726 | - | func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool { | |||
| 727 | - | if d.parkSnatchedAttachment(token) { | |||
| 728 | - | return true | |||
| 729 | - | } | |||
| 730 | - | return d.dropSnatchedAttachment(token) | |||
| 731 | - | } | |||
| 732 | - | ||||
| 733 | - | // cleanupInterruptedSnatchedAttachment owns terminal cleanup for a displaced | |||
| 734 | - | // transport that was closed to drain an admitted send. It waits on the gate's | |||
| 735 | - | // condition rather than ordinary effect admission, then removes only the exact | |||
| 736 | - | // published snatched incarnation. Attachment-local cleanup runs without routing | |||
| 737 | - | // or session locks while the terminal capability remains frozen. | |||
| 738 | - | func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool { | |||
| 739 | - | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |||
| 740 | - | return false | |||
| 741 | - | } | |||
| 742 | - | frozen := freezeRoleEffectGates(token.ac) | |||
| 743 | - | defer frozen.unfreeze() | |||
| 744 | - | ||||
| 745 | - | d.notices.routingMu.Lock() | |||
| 746 | - | token.sess.mu.Lock() | |||
| 747 | - | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |||
| 748 | - | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |||
| 749 | - | token.ac.transportSnapshotCurrent(token.transport) | |||
| 750 | - | token.sess.mu.Unlock() | |||
| 751 | - | d.notices.routingMu.Unlock() | |||
| 752 | - | if !current { | |||
| 753 | - | return false | |||
| 754 | - | } | |||
| 755 | - | ||||
| 756 | - | // The frozen gate prevents a promotion from publishing between overlay | |||
| 757 | - | // cleanup and terminal registry publication. | |||
| 758 | - | d.clearForSnatch(token) | |||
| 759 | - | ||||
| 760 | - | d.notices.routingMu.Lock() | |||
| 761 | - | token.sess.mu.Lock() | |||
| 762 | - | current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |||
| 763 | - | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |||
| 764 | - | token.ac.transportSnapshotCurrent(token.transport) | |||
| 765 | - | if current { | |||
| 766 | - | delete(token.sess.snatched, token.ac) | |||
| 767 | - | token.ac.roleGeneration.Add(1) | |||
| 768 | - | token.ac.invalidateFrozenRoleCapability() | |||
| 769 | - | } | |||
| 770 | - | token.sess.mu.Unlock() | |||
| 771 | - | d.notices.routingMu.Unlock() | |||
| 772 | - | if !current { | |||
| 773 | - | return false | |||
| 774 | - | } | |||
| 775 | - | ||||
| 776 | - | token.ac.setSession(nil) | |||
| 777 | - | if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | |||
| 778 | - | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |||
| 779 | - | token.ac.clearSnatchedInput() | |||
| 780 | - | d.unregisterPreview(token.ac) | |||
| 781 | - | return true | |||
| 782 | - | } | |||
| 783 | - | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |||
| 784 | - | token.ac.clearSnatchedInput() | |||
| 785 | - | d.unregisterPreview(token.ac) | |||
| 786 | - | token.ac.clearPreviousSession() | |||
| 787 | - | token.ac.clearCaptureFrames() | |||
| 788 | - | return true | |||
| 789 | - | } | |||
| 790 | - | ||||
| 791 | - | // dropSnatchedAttachment removes and closes only the captured snatched link. | |||
| 792 | - | func (d *Daemon) dropSnatchedAttachment(token attachmentRoleToken) bool { | |||
| 793 | - | if !d.removeSnatchedAttachment(token) { | |||
| 794 | - | return false | |||
| 795 | - | } | |||
| 796 | - | _ = token.ac.closeCapturedTransport(token.ac.revokeTransport(token.transport.transport)) | |||
| 797 | - | return true | |||
| 798 | - | } | |||
| 799 | - | ||||
| 800 | - | func validAttachmentTransitionRole(role attachmentRole, expected bool) bool { | |||
| 801 | - | if role == attachmentActive || role == attachmentSnatched { | |||
| 802 | - | return true | |||
| 803 | - | } | |||
| 804 | - | return expected && role == attachmentDetached | |||
| 805 | - | } | |||
| 806 | - | ||||
| 807 | - | // lockAttachmentSessions gives every two-session transition one stable order. | |||
| 808 | - | // Session IDs are immutable and unique while their lifecycles are registered. | |||
| 809 | - | func lockAttachmentSessions(a, b *session) func() { | |||
| 810 | - | if a == b { | |||
| 811 | - | a.mu.Lock() | |||
| 812 | - | return a.mu.Unlock | |||
| 813 | - | } | |||
| 814 | - | first, second := a, b | |||
| 815 | - | if first.id > second.id { | |||
| 816 | - | first, second = second, first | |||
| 817 | - | } | |||
| 818 | - | first.mu.Lock() | |||
| 819 | - | second.mu.Lock() | |||
| 820 | - | return func() { | |||
| 821 | - | second.mu.Unlock() | |||
| 822 | - | first.mu.Unlock() | |||
| 823 | - | } | |||
| 824 | - | } | |||
| 825 | - | ||||
| 826 | - | // lockAttachmentCoordinators follows the same immutable session-ID order as | |||
| 827 | - | // lockAttachmentSessions. Callers already hold the ordered session locks. | |||
| 828 | - | func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() { | |||
| 829 | - | if aCoordinator == nil && bCoordinator == nil { | |||
| 830 | - | return func() {} | |||
| 831 | - | } | |||
| 832 | - | if a == b || aCoordinator == bCoordinator { | |||
| 833 | - | coordinator := aCoordinator | |||
| 834 | - | if coordinator == nil { | |||
| 835 | - | coordinator = bCoordinator | |||
| 836 | - | } | |||
| 837 | - | coordinator.mu.Lock() | |||
| 838 | - | return coordinator.mu.Unlock | |||
| 839 | - | } | |||
| 840 | - | first, second := aCoordinator, bCoordinator | |||
| 841 | - | if a.id > b.id { | |||
| 842 | - | first, second = second, first | |||
| 843 | - | } | |||
| 844 | - | if first != nil { | |||
| 845 | - | first.mu.Lock() | |||
| 846 | - | } | |||
| 847 | - | if second != nil { | |||
| 848 | - | second.mu.Lock() | |||
| 849 | - | } | |||
| 850 | - | return func() { | |||
| 851 | - | if second != nil { | |||
| 852 | - | second.mu.Unlock() | |||
| 853 | - | } | |||
| 854 | - | if first != nil { | |||
| 855 | - | first.mu.Unlock() | |||
| 856 | - | } | |||
| 857 | - | } | |||
| 858 | - | } | |||
| 859 | - | ||||
| 860 | - | // deferAttachmentTransitionCleanups retires coordinator timers and accounting | |||
| 861 | - | // after ownership publication. Cleanup never gates the attaching handshake. A | |||
| 862 | - | // displaced render that already owns sendMu is interrupted by closing only its | |||
| 863 | - | // captured transport; an idle healthy snatched transport remains connected and | |||
| 864 | - | // receives a dependency-free reset panel. | |||
| 865 | - | func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionResult) { | |||
| 866 | - | if token := result.displaced; token.ac != nil && token.transport.transport != nil { | |||
| 867 | - | blockedRender := result.displacedInterrupted | |||
| 868 | - | if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) { | |||
| 869 | - | blockedRender = !token.ac.sendMu.TryLock() | |||
| 870 | - | if !blockedRender { | |||
| 871 | - | token.ac.sendMu.Unlock() | |||
| 872 | - | } | |||
| 873 | - | } | |||
| 874 | - | d.attachmentCleanupWg.Go(func() { | |||
| 875 | - | if d.afterDisplacedCleanupStarted != nil { | |||
| 876 | - | d.afterDisplacedCleanupStarted() | |||
| 877 | - | } | |||
| 878 | - | if blockedRender { | |||
| 879 | - | // This path owns terminal cleanup independently of ordinary role | |||
| 880 | - | // admission: a stale send-error detach may transiently freeze the gate. | |||
| 881 | - | _ = token.ac.closeCapturedTransport(token.transport.transport) | |||
| 882 | - | d.cleanupInterruptedSnatchedAttachment(token) | |||
| 883 | - | return | |||
| 884 | - | } | |||
| 885 | - | ticket, admitted := token.ac.beginRoleEffect(token) | |||
| 886 | - | if !admitted { | |||
| 887 | - | return | |||
| 888 | - | } | |||
| 889 | - | defer ticket.End() | |||
| 890 | - | if err := d.sendInitialSnatchedPanel(token, ticket); err != nil { | |||
| 891 | - | ticket.End() | |||
| 892 | - | d.parkOrDropSnatchedAttachment(token) | |||
| 893 | - | } | |||
| 894 | - | }) | |||
| 895 | - | } | |||
| 896 | - | for _, cleanup := range result.cleanups { | |||
| 897 | - | d.attachmentCleanupWg.Go(cleanup.finish) | |||
| 898 | - | } | |||
| 899 | - | } | |||
M internal/usecase/daemon/snatched_client_test.go +183 -2
| @@ -248,7 +248,7 @@ func TestActiveFrameRevalidatesExactRoleBeforeEveryEffect(t *testing.T) { | |||
| 248 | 248 | } | |
| 249 | 249 | } | |
| 250 | 250 | ||
| 251 | - | func TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(t *testing.T) { | |
| 251 | + | func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) { | |
| 252 | 252 | pty := &transactionalResizePTY{} | |
| 253 | 253 | d, sess, first, _ := newManualSessionWithPTYs(t, pty) | |
| 254 | 254 | firstTransport := newDatagramTestTransport() | |
| @@ -334,6 +334,8 @@ func TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(t * | |||
| 334 | 334 | message, err := ports.UnmarshalDetached(detached.Payload) | |
| 335 | 335 | require.NoError(t, err) | |
| 336 | 336 | require.Equal(t, ports.ReasonSessionKilled, message.Reason) | |
| 337 | + | _, recvErr := transport.Recv() | |
| 338 | + | require.ErrorIs(t, recvErr, io.EOF) | |
| 337 | 339 | } | |
| 338 | 340 | require.Same(t, otherClient, other.client) | |
| 339 | 341 | require.Same(t, other, otherClient.currentSession()) | |
| @@ -348,6 +350,146 @@ func TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(t * | |||
| 348 | 350 | } | |
| 349 | 351 | } | |
| 350 | 352 | ||
| 353 | + | func TestKillSessionRetiresParkedSnatchedAttachment(t *testing.T) { | |
| 354 | + | pty, release := newBlockingPTY(t) | |
| 355 | + | defer release() | |
| 356 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 357 | + | ||
| 358 | + | parkedTransport := &closeTrackingTransport{} | |
| 359 | + | sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport) | |
| 360 | + | require.NoError(t, err) | |
| 361 | + | parkedToken := parkedClient.resumeToken | |
| 362 | + | d.clientGone(sess, parkedClient, parkedTransport, false) | |
| 363 | + | ||
| 364 | + | activeTransport := &closeTrackingTransport{} | |
| 365 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 366 | + | require.NoError(t, err) | |
| 367 | + | d.mu.Lock() | |
| 368 | + | parked := d.parked[parkedToken] | |
| 369 | + | d.mu.Unlock() | |
| 370 | + | require.NotNil(t, parked) | |
| 371 | + | require.Equal(t, attachmentSnatched, parked.role) | |
| 372 | + | parkedGeneration := parkedClient.roleGeneration.Load() | |
| 373 | + | ||
| 374 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 375 | + | d.waitNotifies() | |
| 376 | + | ||
| 377 | + | require.Nil(t, parkedClient.transport(), "terminal teardown must revoke the parked link ownership") | |
| 378 | + | require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration, "terminal teardown must invalidate the parked role generation") | |
| 379 | + | require.Zero(t, parkedClient.resumeToken) | |
| 380 | + | require.False(t, parkedClient.parked) | |
| 381 | + | select { | |
| 382 | + | case <-parked.done: | |
| 383 | + | default: | |
| 384 | + | t.Fatal("terminal teardown left the parked expiry goroutine live") | |
| 385 | + | } | |
| 386 | + | require.True(t, parkedTransport.Closed()) | |
| 387 | + | require.Nil(t, parkedClient.currentSession()) | |
| 388 | + | require.Nil(t, active.currentSession()) | |
| 389 | + | require.True(t, activeTransport.Closed()) | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func TestDaemonShutdownClosesSnatchedClients(t *testing.T) { | |
| 393 | + | pty, release := newBlockingPTY(t) | |
| 394 | + | defer release() | |
| 395 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 396 | + | ||
| 397 | + | parkedTransport := &closeTrackingTransport{} | |
| 398 | + | sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport) | |
| 399 | + | require.NoError(t, err) | |
| 400 | + | parkedToken := parkedClient.resumeToken | |
| 401 | + | d.clientGone(sess, parkedClient, parkedTransport, false) | |
| 402 | + | ||
| 403 | + | waitingTransport := &closeTrackingTransport{} | |
| 404 | + | _, waiting, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), waitingTransport) | |
| 405 | + | require.NoError(t, err) | |
| 406 | + | activeTransport := &closeTrackingTransport{} | |
| 407 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |
| 408 | + | require.NoError(t, err) | |
| 409 | + | d.attachmentCleanupWg.Wait() | |
| 410 | + | ||
| 411 | + | d.mu.Lock() | |
| 412 | + | parked := d.parked[parkedToken] | |
| 413 | + | d.mu.Unlock() | |
| 414 | + | require.NotNil(t, parked) | |
| 415 | + | require.Equal(t, attachmentSnatched, parked.role) | |
| 416 | + | parkedGeneration := parkedClient.roleGeneration.Load() | |
| 417 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| 418 | + | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 419 | + | ||
| 420 | + | require.False(t, d.shutdownAll(ports.ReasonServerShutdown)) | |
| 421 | + | d.waitNotifies() | |
| 422 | + | d.attachmentCleanupWg.Wait() | |
| 423 | + | ||
| 424 | + | require.Nil(t, parkedClient.transport(), "shutdown must revoke parked attachment link ownership") | |
| 425 | + | require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration) | |
| 426 | + | select { | |
| 427 | + | case <-parked.done: | |
| 428 | + | default: | |
| 429 | + | t.Fatal("shutdown left the parked expiry goroutine live") | |
| 430 | + | } | |
| 431 | + | for _, client := range []*attachedClient{parkedClient, waiting, active} { | |
| 432 | + | require.Nil(t, client.currentSession()) | |
| 433 | + | } | |
| 434 | + | for _, transport := range []*closeTrackingTransport{parkedTransport, waitingTransport, activeTransport} { | |
| 435 | + | require.True(t, transport.Closed()) | |
| 436 | + | } | |
| 437 | + | for _, transport := range []*closeTrackingTransport{waitingTransport, activeTransport} { | |
| 438 | + | frames := transport.Sends() | |
| 439 | + | require.NotEmpty(t, frames) | |
| 440 | + | detached, err := ports.UnmarshalDetached(frames[len(frames)-1].Payload) | |
| 441 | + | require.NoError(t, err) | |
| 442 | + | require.Equal(t, ports.ReasonServerShutdown, detached.Reason) | |
| 443 | + | } | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | func TestSnatchedQuitDoesNotResetActivePaneThemeOrSize(t *testing.T) { | |
| 447 | + | pty := &transactionalResizePTY{} | |
| 448 | + | d, sess, waiting, _ := newManualSessionWithPTYs(t, pty) | |
| 449 | + | waitingTransport := &closeTrackingTransport{} | |
| 450 | + | waiting.replaceTransport(waitingTransport) | |
| 451 | + | ||
| 452 | + | activeTransport := &closeTrackingTransport{} | |
| 453 | + | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |
| 454 | + | active.initOverlays() | |
| 455 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 456 | + | target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 457 | + | expectedTransport: active.transportSnapshot(), ready: true, | |
| 458 | + | }) | |
| 459 | + | require.NoError(t, err) | |
| 460 | + | for _, cleanup := range transition.cleanups { | |
| 461 | + | cleanup.finish() | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | activeTheme := ports.Theme{ | |
| 465 | + | HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56}, | |
| 466 | + | HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21}, | |
| 467 | + | TrueColor: true, | |
| 468 | + | } | |
| 469 | + | d.applyTheme(sess, active, activeTheme) | |
| 470 | + | activeSize := domain.Size{Cols: 101, Rows: 37} | |
| 471 | + | d.resize(sess, active, activeSize) | |
| 472 | + | require.Equal(t, activeSize, active.size) | |
| 473 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 474 | + | requestedBeforeQuit := pty.requested() | |
| 475 | + | ||
| 476 | + | token := sess.attachmentToken(waiting, waitingTransport) | |
| 477 | + | require.True(t, d.handleSnatchedClientFrame(token, ports.Frame{ | |
| 478 | + | Type: ports.MsgInput, | |
| 479 | + | Payload: ports.MarshalInput(ports.Input{ | |
| 480 | + | Data: []byte{'q'}, | |
| 481 | + | }), | |
| 482 | + | })) | |
| 483 | + | ||
| 484 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 485 | + | require.Same(t, active, sess.client) | |
| 486 | + | require.False(t, activeTransport.Closed()) | |
| 487 | + | require.True(t, waitingTransport.Closed()) | |
| 488 | + | require.Equal(t, activeSize, active.size) | |
| 489 | + | require.Equal(t, requestedBeforeQuit, pty.requested(), "snatched quit must not resize active panes") | |
| 490 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 491 | + | } | |
| 492 | + | ||
| 351 | 493 | func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) { | |
| 352 | 494 | writes := make(chan []byte, 1) | |
| 353 | 495 | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | |
| @@ -607,7 +749,7 @@ func (t *failingSnatchedTransport) Closed() bool { | |||
| 607 | 749 | return t.closed | |
| 608 | 750 | } | |
| 609 | 751 | ||
| 610 | - | func TestSnatchedPanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | |
| 752 | + | func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) { | |
| 611 | 753 | d := newTestDaemon(t, nil, stubClock{}) | |
| 612 | 754 | oldTransport := &failingSnatchedTransport{} | |
| 613 | 755 | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| @@ -637,6 +779,45 @@ func TestSnatchedPanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | |||
| 637 | 779 | require.False(t, activeTransport.Closed()) | |
| 638 | 780 | } | |
| 639 | 781 | ||
| 782 | + | func TestSnatchedPanelFailurePreservesActivePaneThemeAndSize(t *testing.T) { | |
| 783 | + | pty := &transactionalResizePTY{} | |
| 784 | + | d, sess, waiting, _ := newManualSessionWithPTYs(t, pty) | |
| 785 | + | failed := &failingSnatchedTransport{} | |
| 786 | + | waiting.replaceTransport(failed) | |
| 787 | + | ||
| 788 | + | activeTransport := &closeTrackingTransport{} | |
| 789 | + | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |
| 790 | + | active.initOverlays() | |
| 791 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 792 | + | target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive, | |
| 793 | + | expectedTransport: active.transportSnapshot(), ready: true, | |
| 794 | + | }) | |
| 795 | + | require.NoError(t, err) | |
| 796 | + | ||
| 797 | + | activeTheme := ports.Theme{ | |
| 798 | + | HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56}, | |
| 799 | + | HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21}, | |
| 800 | + | TrueColor: true, | |
| 801 | + | } | |
| 802 | + | d.applyTheme(sess, active, activeTheme) | |
| 803 | + | activeSize := domain.Size{Cols: 103, Rows: 39} | |
| 804 | + | d.resize(sess, active, activeSize) | |
| 805 | + | require.Equal(t, activeSize, active.size) | |
| 806 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 807 | + | requestedBeforeFailure := pty.requested() | |
| 808 | + | ||
| 809 | + | d.deferAttachmentTransitionCleanups(transition) | |
| 810 | + | d.attachmentCleanupWg.Wait() | |
| 811 | + | ||
| 812 | + | require.True(t, failed.Closed()) | |
| 813 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |
| 814 | + | require.Same(t, active, sess.client) | |
| 815 | + | require.False(t, activeTransport.Closed()) | |
| 816 | + | require.Equal(t, activeSize, active.size) | |
| 817 | + | require.Equal(t, requestedBeforeFailure, pty.requested(), "snatched panel failure must not resize active panes") | |
| 818 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |
| 819 | + | } | |
| 820 | + | ||
| 640 | 821 | func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | |
| 641 | 822 | d := newTestDaemon(t, nil, stubClock{}) | |
| 642 | 823 | oldTransport := &failingSnatchedTransport{} | |
| @@ -248,7 +248,7 @@ func TestActiveFrameRevalidatesExactRoleBeforeEveryEffect(t *testing.T) { | |||||
| 248 | } | 248 | } | ||
| 249 | } | 249 | } | ||
| 250 | 250 | ||||
| 251 | - | func TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(t *testing.T) { | 251 | + | func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) { |
| 252 | pty := &transactionalResizePTY{} | 252 | pty := &transactionalResizePTY{} | ||
| 253 | d, sess, first, _ := newManualSessionWithPTYs(t, pty) | 253 | d, sess, first, _ := newManualSessionWithPTYs(t, pty) | ||
| 254 | firstTransport := newDatagramTestTransport() | 254 | firstTransport := newDatagramTestTransport() | ||
| @@ -334,6 +334,8 @@ func TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(t * | |||||
| 334 | message, err := ports.UnmarshalDetached(detached.Payload) | 334 | message, err := ports.UnmarshalDetached(detached.Payload) | ||
| 335 | require.NoError(t, err) | 335 | require.NoError(t, err) | ||
| 336 | require.Equal(t, ports.ReasonSessionKilled, message.Reason) | 336 | require.Equal(t, ports.ReasonSessionKilled, message.Reason) | ||
| 337 | + | _, recvErr := transport.Recv() | |||
| 338 | + | require.ErrorIs(t, recvErr, io.EOF) | |||
| 337 | } | 339 | } | ||
| 338 | require.Same(t, otherClient, other.client) | 340 | require.Same(t, otherClient, other.client) | ||
| 339 | require.Same(t, other, otherClient.currentSession()) | 341 | require.Same(t, other, otherClient.currentSession()) | ||
| @@ -348,6 +350,146 @@ func TestKillSessionRetiresEverySnatchedConnectionWithoutCrossSessionEffects(t * | |||||
| 348 | } | 350 | } | ||
| 349 | } | 351 | } | ||
| 350 | 352 | ||||
| 353 | + | func TestKillSessionRetiresParkedSnatchedAttachment(t *testing.T) { | |||
| 354 | + | pty, release := newBlockingPTY(t) | |||
| 355 | + | defer release() | |||
| 356 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 357 | + | ||||
| 358 | + | parkedTransport := &closeTrackingTransport{} | |||
| 359 | + | sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport) | |||
| 360 | + | require.NoError(t, err) | |||
| 361 | + | parkedToken := parkedClient.resumeToken | |||
| 362 | + | d.clientGone(sess, parkedClient, parkedTransport, false) | |||
| 363 | + | ||||
| 364 | + | activeTransport := &closeTrackingTransport{} | |||
| 365 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |||
| 366 | + | require.NoError(t, err) | |||
| 367 | + | d.mu.Lock() | |||
| 368 | + | parked := d.parked[parkedToken] | |||
| 369 | + | d.mu.Unlock() | |||
| 370 | + | require.NotNil(t, parked) | |||
| 371 | + | require.Equal(t, attachmentSnatched, parked.role) | |||
| 372 | + | parkedGeneration := parkedClient.roleGeneration.Load() | |||
| 373 | + | ||||
| 374 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |||
| 375 | + | d.waitNotifies() | |||
| 376 | + | ||||
| 377 | + | require.Nil(t, parkedClient.transport(), "terminal teardown must revoke the parked link ownership") | |||
| 378 | + | require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration, "terminal teardown must invalidate the parked role generation") | |||
| 379 | + | require.Zero(t, parkedClient.resumeToken) | |||
| 380 | + | require.False(t, parkedClient.parked) | |||
| 381 | + | select { | |||
| 382 | + | case <-parked.done: | |||
| 383 | + | default: | |||
| 384 | + | t.Fatal("terminal teardown left the parked expiry goroutine live") | |||
| 385 | + | } | |||
| 386 | + | require.True(t, parkedTransport.Closed()) | |||
| 387 | + | require.Nil(t, parkedClient.currentSession()) | |||
| 388 | + | require.Nil(t, active.currentSession()) | |||
| 389 | + | require.True(t, activeTransport.Closed()) | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | func TestDaemonShutdownClosesSnatchedClients(t *testing.T) { | |||
| 393 | + | pty, release := newBlockingPTY(t) | |||
| 394 | + | defer release() | |||
| 395 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 396 | + | ||||
| 397 | + | parkedTransport := &closeTrackingTransport{} | |||
| 398 | + | sess, parkedClient, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), parkedTransport) | |||
| 399 | + | require.NoError(t, err) | |||
| 400 | + | parkedToken := parkedClient.resumeToken | |||
| 401 | + | d.clientGone(sess, parkedClient, parkedTransport, false) | |||
| 402 | + | ||||
| 403 | + | waitingTransport := &closeTrackingTransport{} | |||
| 404 | + | _, waiting, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), waitingTransport) | |||
| 405 | + | require.NoError(t, err) | |||
| 406 | + | activeTransport := &closeTrackingTransport{} | |||
| 407 | + | _, active, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTransport) | |||
| 408 | + | require.NoError(t, err) | |||
| 409 | + | d.attachmentCleanupWg.Wait() | |||
| 410 | + | ||||
| 411 | + | d.mu.Lock() | |||
| 412 | + | parked := d.parked[parkedToken] | |||
| 413 | + | d.mu.Unlock() | |||
| 414 | + | require.NotNil(t, parked) | |||
| 415 | + | require.Equal(t, attachmentSnatched, parked.role) | |||
| 416 | + | parkedGeneration := parkedClient.roleGeneration.Load() | |||
| 417 | + | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |||
| 418 | + | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |||
| 419 | + | ||||
| 420 | + | require.False(t, d.shutdownAll(ports.ReasonServerShutdown)) | |||
| 421 | + | d.waitNotifies() | |||
| 422 | + | d.attachmentCleanupWg.Wait() | |||
| 423 | + | ||||
| 424 | + | require.Nil(t, parkedClient.transport(), "shutdown must revoke parked attachment link ownership") | |||
| 425 | + | require.Greater(t, parkedClient.roleGeneration.Load(), parkedGeneration) | |||
| 426 | + | select { | |||
| 427 | + | case <-parked.done: | |||
| 428 | + | default: | |||
| 429 | + | t.Fatal("shutdown left the parked expiry goroutine live") | |||
| 430 | + | } | |||
| 431 | + | for _, client := range []*attachedClient{parkedClient, waiting, active} { | |||
| 432 | + | require.Nil(t, client.currentSession()) | |||
| 433 | + | } | |||
| 434 | + | for _, transport := range []*closeTrackingTransport{parkedTransport, waitingTransport, activeTransport} { | |||
| 435 | + | require.True(t, transport.Closed()) | |||
| 436 | + | } | |||
| 437 | + | for _, transport := range []*closeTrackingTransport{waitingTransport, activeTransport} { | |||
| 438 | + | frames := transport.Sends() | |||
| 439 | + | require.NotEmpty(t, frames) | |||
| 440 | + | detached, err := ports.UnmarshalDetached(frames[len(frames)-1].Payload) | |||
| 441 | + | require.NoError(t, err) | |||
| 442 | + | require.Equal(t, ports.ReasonServerShutdown, detached.Reason) | |||
| 443 | + | } | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | func TestSnatchedQuitDoesNotResetActivePaneThemeOrSize(t *testing.T) { | |||
| 447 | + | pty := &transactionalResizePTY{} | |||
| 448 | + | d, sess, waiting, _ := newManualSessionWithPTYs(t, pty) | |||
| 449 | + | waitingTransport := &closeTrackingTransport{} | |||
| 450 | + | waiting.replaceTransport(waitingTransport) | |||
| 451 | + | ||||
| 452 | + | activeTransport := &closeTrackingTransport{} | |||
| 453 | + | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |||
| 454 | + | active.initOverlays() | |||
| 455 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |||
| 456 | + | target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive, | |||
| 457 | + | expectedTransport: active.transportSnapshot(), ready: true, | |||
| 458 | + | }) | |||
| 459 | + | require.NoError(t, err) | |||
| 460 | + | for _, cleanup := range transition.cleanups { | |||
| 461 | + | cleanup.finish() | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | activeTheme := ports.Theme{ | |||
| 465 | + | HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56}, | |||
| 466 | + | HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21}, | |||
| 467 | + | TrueColor: true, | |||
| 468 | + | } | |||
| 469 | + | d.applyTheme(sess, active, activeTheme) | |||
| 470 | + | activeSize := domain.Size{Cols: 101, Rows: 37} | |||
| 471 | + | d.resize(sess, active, activeSize) | |||
| 472 | + | require.Equal(t, activeSize, active.size) | |||
| 473 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |||
| 474 | + | requestedBeforeQuit := pty.requested() | |||
| 475 | + | ||||
| 476 | + | token := sess.attachmentToken(waiting, waitingTransport) | |||
| 477 | + | require.True(t, d.handleSnatchedClientFrame(token, ports.Frame{ | |||
| 478 | + | Type: ports.MsgInput, | |||
| 479 | + | Payload: ports.MarshalInput(ports.Input{ | |||
| 480 | + | Data: []byte{'q'}, | |||
| 481 | + | }), | |||
| 482 | + | })) | |||
| 483 | + | ||||
| 484 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |||
| 485 | + | require.Same(t, active, sess.client) | |||
| 486 | + | require.False(t, activeTransport.Closed()) | |||
| 487 | + | require.True(t, waitingTransport.Closed()) | |||
| 488 | + | require.Equal(t, activeSize, active.size) | |||
| 489 | + | require.Equal(t, requestedBeforeQuit, pty.requested(), "snatched quit must not resize active panes") | |||
| 490 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |||
| 491 | + | } | |||
| 492 | + | ||||
| 351 | func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) { | 493 | func TestSnatchedConnectionInputCannotReachPTY(t *testing.T) { | ||
| 352 | writes := make(chan []byte, 1) | 494 | writes := make(chan []byte, 1) | ||
| 353 | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | 495 | pty := &transactionalResizePTY{onWrite: func(data []byte) { writes <- data }} | ||
| @@ -607,7 +749,7 @@ func (t *failingSnatchedTransport) Closed() bool { | |||||
| 607 | return t.closed | 749 | return t.closed | ||
| 608 | } | 750 | } | ||
| 609 | 751 | ||||
| 610 | - | func TestSnatchedPanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | 752 | + | func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) { |
| 611 | d := newTestDaemon(t, nil, stubClock{}) | 753 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 612 | oldTransport := &failingSnatchedTransport{} | 754 | oldTransport := &failingSnatchedTransport{} | ||
| 613 | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 755 | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| @@ -637,6 +779,45 @@ func TestSnatchedPanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | |||||
| 637 | require.False(t, activeTransport.Closed()) | 779 | require.False(t, activeTransport.Closed()) | ||
| 638 | } | 780 | } | ||
| 639 | 781 | ||||
| 782 | + | func TestSnatchedPanelFailurePreservesActivePaneThemeAndSize(t *testing.T) { | |||
| 783 | + | pty := &transactionalResizePTY{} | |||
| 784 | + | d, sess, waiting, _ := newManualSessionWithPTYs(t, pty) | |||
| 785 | + | failed := &failingSnatchedTransport{} | |||
| 786 | + | waiting.replaceTransport(failed) | |||
| 787 | + | ||||
| 788 | + | activeTransport := &closeTrackingTransport{} | |||
| 789 | + | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |||
| 790 | + | active.initOverlays() | |||
| 791 | + | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |||
| 792 | + | target: sess, next: active, expectedRole: attachmentDetached, targetRole: attachmentActive, | |||
| 793 | + | expectedTransport: active.transportSnapshot(), ready: true, | |||
| 794 | + | }) | |||
| 795 | + | require.NoError(t, err) | |||
| 796 | + | ||||
| 797 | + | activeTheme := ports.Theme{ | |||
| 798 | + | HasForeground: true, Foreground: renderer.RGB{R: 12, G: 34, B: 56}, | |||
| 799 | + | HasBackground: true, Background: renderer.RGB{R: 65, G: 43, B: 21}, | |||
| 800 | + | TrueColor: true, | |||
| 801 | + | } | |||
| 802 | + | d.applyTheme(sess, active, activeTheme) | |||
| 803 | + | activeSize := domain.Size{Cols: 103, Rows: 39} | |||
| 804 | + | d.resize(sess, active, activeSize) | |||
| 805 | + | require.Equal(t, activeSize, active.size) | |||
| 806 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |||
| 807 | + | requestedBeforeFailure := pty.requested() | |||
| 808 | + | ||||
| 809 | + | d.deferAttachmentTransitionCleanups(transition) | |||
| 810 | + | d.attachmentCleanupWg.Wait() | |||
| 811 | + | ||||
| 812 | + | require.True(t, failed.Closed()) | |||
| 813 | + | require.Equal(t, attachmentDetached, sess.attachmentRole(waiting)) | |||
| 814 | + | require.Same(t, active, sess.client) | |||
| 815 | + | require.False(t, activeTransport.Closed()) | |||
| 816 | + | require.Equal(t, activeSize, active.size) | |||
| 817 | + | require.Equal(t, requestedBeforeFailure, pty.requested(), "snatched panel failure must not resize active panes") | |||
| 818 | + | assertSessionDefaultColors(t, sess, activeTheme.Foreground, activeTheme.Background) | |||
| 819 | + | } | |||
| 820 | + | ||||
| 640 | func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | 821 | func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | ||
| 641 | d := newTestDaemon(t, nil, stubClock{}) | 822 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 642 | oldTransport := &failingSnatchedTransport{} | 823 | oldTransport := &failingSnatchedTransport{} | ||