refactor(daemon): generalize attachment session core
- commit
- 951a71d01e9026c87eb4d13878bdd2cfc59e6494
- author
- brice · · 2026-07-30 14:32 UTC
- committer
- brice · · 2026-07-30 23:38 UTC
- parent
- 4d54d83
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
83 files · +1083 -620
M internal/usecase/daemon/attachment_role.go +60 -37
| @@ -12,42 +12,56 @@ const ( | |||
| 12 | 12 | ||
| 13 | 13 | // attachmentRole derives the attachment's role from the session-owned | |
| 14 | 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 { | |
| 15 | + | func attachmentSessionRole(entry attachmentSession, ac *attachedClient) attachmentRole { | |
| 16 | + | if entry == nil || ac == nil || entry.core() == nil { | |
| 17 | 17 | return attachmentDetached | |
| 18 | 18 | } | |
| 19 | - | s.mu.Lock() | |
| 20 | - | defer s.mu.Unlock() | |
| 21 | - | return s.attachmentRoleLocked(ac) | |
| 19 | + | core := entry.core() | |
| 20 | + | core.mu.Lock() | |
| 21 | + | defer core.mu.Unlock() | |
| 22 | + | return attachmentSessionRoleLocked(entry, ac) | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 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 { | |
| 25 | + | // attachmentSessionRoleLocked requires entry.core().mu. Active membership wins | |
| 26 | + | // if an invalid intermediate state contains the same attachment in both | |
| 27 | + | // registries. | |
| 28 | + | func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) attachmentRole { | |
| 29 | + | if entry == nil || ac == nil || entry.core() == nil { | |
| 28 | 30 | return attachmentDetached | |
| 29 | 31 | } | |
| 30 | - | if s.client == ac { | |
| 32 | + | core := entry.core() | |
| 33 | + | if core.client == ac { | |
| 31 | 34 | return attachmentActive | |
| 32 | 35 | } | |
| 33 | - | if _, ok := s.snatched[ac]; ok { | |
| 36 | + | if _, ok := core.snatched[ac]; ok { | |
| 34 | 37 | return attachmentSnatched | |
| 35 | 38 | } | |
| 36 | 39 | return attachmentDetached | |
| 37 | 40 | } | |
| 38 | 41 | ||
| 39 | - | func (s *session) addSnatchedLocked(ac *attachedClient) { | |
| 40 | - | if ac == nil { | |
| 42 | + | func addSnatchedLocked(entry attachmentSession, ac *attachedClient) { | |
| 43 | + | if entry == nil || ac == nil || entry.core() == nil { | |
| 41 | 44 | return | |
| 42 | 45 | } | |
| 43 | - | if s.snatched == nil { | |
| 44 | - | s.snatched = make(map[*attachedClient]struct{}) | |
| 46 | + | core := entry.core() | |
| 47 | + | if core.snatched == nil { | |
| 48 | + | core.snatched = make(map[*attachedClient]struct{}) | |
| 45 | 49 | } | |
| 46 | - | s.snatched[ac] = struct{}{} | |
| 50 | + | core.snatched[ac] = struct{}{} | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | |
| 54 | + | return attachmentSessionRole(s, ac) | |
| 47 | 55 | } | |
| 48 | 56 | ||
| 57 | + | func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole { | |
| 58 | + | return attachmentSessionRoleLocked(s, ac) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (s *session) addSnatchedLocked(ac *attachedClient) { addSnatchedLocked(s, ac) } | |
| 62 | + | ||
| 49 | 63 | type attachmentRoleToken struct { | |
| 50 | - | sess *session | |
| 64 | + | sess attachmentSession | |
| 51 | 65 | ac *attachedClient | |
| 52 | 66 | role attachmentRole | |
| 53 | 67 | generation uint64 | |
| @@ -64,8 +78,8 @@ type attachmentRoleToken struct { | |||
| 64 | 78 | // active coordinator lease at an architecture commit point. The caller must | |
| 65 | 79 | // hold s.mu; coordinator acquisition follows the canonical session -> | |
| 66 | 80 | // coordinator order. | |
| 67 | - | func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken { | |
| 68 | - | if s == nil || ac == nil { | |
| 81 | + | func attachmentTokenLocked(entry attachmentSession, ac *attachedClient) attachmentRoleToken { | |
| 82 | + | if entry == nil || entry.core() == nil || ac == nil { | |
| 69 | 83 | return attachmentRoleToken{} | |
| 70 | 84 | } | |
| 71 | 85 | transport := ac.transportSnapshot() | |
| @@ -73,40 +87,45 @@ func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken | |||
| 73 | 87 | return attachmentRoleToken{} | |
| 74 | 88 | } | |
| 75 | 89 | token := attachmentRoleToken{ | |
| 76 | - | sess: s, | |
| 90 | + | sess: entry, | |
| 77 | 91 | ac: ac, | |
| 78 | - | role: s.attachmentRoleLocked(ac), | |
| 92 | + | role: attachmentSessionRoleLocked(entry, ac), | |
| 79 | 93 | generation: ac.roleGeneration.Load(), | |
| 80 | 94 | transport: transport, | |
| 81 | 95 | } | |
| 82 | 96 | if token.role == attachmentActive { | |
| 83 | - | if rc := s.renderCoordinator(); rc != nil { | |
| 97 | + | if rc := entry.core().coordinator.Load(); rc != nil { | |
| 84 | 98 | token.lease = rc.attachmentLease(ac) | |
| 85 | 99 | } | |
| 86 | 100 | } | |
| 87 | 101 | return token | |
| 88 | 102 | } | |
| 89 | 103 | ||
| 90 | - | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |
| 91 | - | if s == nil || ac == nil || tr == nil { | |
| 104 | + | func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken { | |
| 105 | + | return attachmentTokenLocked(s, ac) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |
| 109 | + | if entry == nil || entry.core() == nil || ac == nil || tr == nil { | |
| 92 | 110 | return attachmentRoleToken{} | |
| 93 | 111 | } | |
| 94 | - | s.mu.Lock() | |
| 112 | + | core := entry.core() | |
| 113 | + | core.mu.Lock() | |
| 95 | 114 | transport := ac.transportSnapshot() | |
| 96 | 115 | if transport.transport != tr { | |
| 97 | - | s.mu.Unlock() | |
| 116 | + | core.mu.Unlock() | |
| 98 | 117 | return attachmentRoleToken{} | |
| 99 | 118 | } | |
| 100 | 119 | token := attachmentRoleToken{ | |
| 101 | - | sess: s, | |
| 120 | + | sess: entry, | |
| 102 | 121 | ac: ac, | |
| 103 | - | role: s.attachmentRoleLocked(ac), | |
| 122 | + | role: attachmentSessionRoleLocked(entry, ac), | |
| 104 | 123 | generation: ac.roleGeneration.Load(), | |
| 105 | 124 | transport: transport, | |
| 106 | 125 | } | |
| 107 | - | s.mu.Unlock() | |
| 126 | + | core.mu.Unlock() | |
| 108 | 127 | if token.role == attachmentActive { | |
| 109 | - | if rc := s.renderCoordinator(); rc != nil { | |
| 128 | + | if rc := core.coordinator.Load(); rc != nil { | |
| 110 | 129 | token.lease = rc.attachmentLease(ac) | |
| 111 | 130 | } | |
| 112 | 131 | } | |
| @@ -114,20 +133,24 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach | |||
| 114 | 133 | return token | |
| 115 | 134 | } | |
| 116 | 135 | ||
| 136 | + | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |
| 137 | + | return attachmentToken(s, ac, tr) | |
| 138 | + | } | |
| 139 | + | ||
| 117 | 140 | func (t attachmentRoleToken) current() bool { | |
| 118 | 141 | return t.sess != nil && t.ac != nil && | |
| 119 | 142 | t.ac.roleGeneration.Load() == t.generation && | |
| 120 | 143 | t.ac.transportSnapshotCurrent(t.transport) && | |
| 121 | - | t.sess.attachmentRole(t.ac) == t.role | |
| 144 | + | attachmentSessionRole(t.sess, t.ac) == t.role | |
| 122 | 145 | } | |
| 123 | 146 | ||
| 124 | 147 | // activeCurrent admits post-transition effects only for the exact published | |
| 125 | 148 | // role, transport incarnation, current-session link, and coordinator lease. | |
| 126 | 149 | func (t attachmentRoleToken) activeCurrent() bool { | |
| 127 | - | if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess { | |
| 150 | + | if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentAttachmentSession() != t.sess { | |
| 128 | 151 | return false | |
| 129 | 152 | } | |
| 130 | - | rc := t.sess.renderCoordinator() | |
| 153 | + | rc := t.sess.core().coordinator.Load() | |
| 131 | 154 | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 132 | 155 | } | |
| 133 | 156 | ||
| @@ -141,11 +164,11 @@ func (t attachmentRoleToken) activeEffect() bool { | |||
| 141 | 164 | return t.activeCurrent() | |
| 142 | 165 | } | |
| 143 | 166 | ||
| 144 | - | func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) { | |
| 167 | + | func beginActiveLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) { | |
| 145 | 168 | if sess == nil || ac == nil || lease == nil { | |
| 146 | 169 | return nil, false | |
| 147 | 170 | } | |
| 148 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 171 | + | token := attachmentToken(sess, ac, ac.transport()) | |
| 149 | 172 | token.lease = lease | |
| 150 | 173 | return ac.beginRoleEffect(token) | |
| 151 | 174 | } | |
| @@ -166,10 +189,10 @@ func (t attachmentRoleToken) activeEffectSessionLocked() bool { | |||
| 166 | 189 | } | |
| 167 | 190 | if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil || | |
| 168 | 191 | t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) || | |
| 169 | - | t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive { | |
| 192 | + | t.ac.currentAttachmentSession() != t.sess || attachmentSessionRoleLocked(t.sess, t.ac) != attachmentActive { | |
| 170 | 193 | return false | |
| 171 | 194 | } | |
| 172 | - | rc := t.sess.renderCoordinator() | |
| 195 | + | rc := t.sess.core().coordinator.Load() | |
| 173 | 196 | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | |
| 174 | 197 | } | |
| 175 | 198 | ||
| @@ -12,42 +12,56 @@ const ( | |||||
| 12 | 12 | ||||
| 13 | // attachmentRole derives the attachment's role from the session-owned | 13 | // attachmentRole derives the attachment's role from the session-owned | ||
| 14 | // registries. There is deliberately no second role field that could drift. | 14 | // registries. There is deliberately no second role field that could drift. | ||
| 15 | - | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | 15 | + | func attachmentSessionRole(entry attachmentSession, ac *attachedClient) attachmentRole { |
| 16 | - | if s == nil || ac == nil { | 16 | + | if entry == nil || ac == nil || entry.core() == nil { |
| 17 | return attachmentDetached | 17 | return attachmentDetached | ||
| 18 | } | 18 | } | ||
| 19 | - | s.mu.Lock() | 19 | + | core := entry.core() |
| 20 | - | defer s.mu.Unlock() | 20 | + | core.mu.Lock() |
| 21 | - | return s.attachmentRoleLocked(ac) | 21 | + | defer core.mu.Unlock() |
| 22 | + | return attachmentSessionRoleLocked(entry, ac) | |||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 24 | - | // attachmentRoleLocked requires s.mu. Active membership wins if an invalid | 25 | + | // attachmentSessionRoleLocked requires entry.core().mu. Active membership wins |
| 25 | - | // intermediate state contains the same attachment in both registries. | 26 | + | // if an invalid intermediate state contains the same attachment in both |
| 26 | - | func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole { | 27 | + | // registries. |
| 27 | - | if ac == nil { | 28 | + | func attachmentSessionRoleLocked(entry attachmentSession, ac *attachedClient) attachmentRole { |
| 29 | + | if entry == nil || ac == nil || entry.core() == nil { | |||
| 28 | return attachmentDetached | 30 | return attachmentDetached | ||
| 29 | } | 31 | } | ||
| 30 | - | if s.client == ac { | 32 | + | core := entry.core() |
| 33 | + | if core.client == ac { | |||
| 31 | return attachmentActive | 34 | return attachmentActive | ||
| 32 | } | 35 | } | ||
| 33 | - | if _, ok := s.snatched[ac]; ok { | 36 | + | if _, ok := core.snatched[ac]; ok { |
| 34 | return attachmentSnatched | 37 | return attachmentSnatched | ||
| 35 | } | 38 | } | ||
| 36 | return attachmentDetached | 39 | return attachmentDetached | ||
| 37 | } | 40 | } | ||
| 38 | 41 | ||||
| 39 | - | func (s *session) addSnatchedLocked(ac *attachedClient) { | 42 | + | func addSnatchedLocked(entry attachmentSession, ac *attachedClient) { |
| 40 | - | if ac == nil { | 43 | + | if entry == nil || ac == nil || entry.core() == nil { |
| 41 | return | 44 | return | ||
| 42 | } | 45 | } | ||
| 43 | - | if s.snatched == nil { | 46 | + | core := entry.core() |
| 44 | - | s.snatched = make(map[*attachedClient]struct{}) | 47 | + | if core.snatched == nil { |
| 48 | + | core.snatched = make(map[*attachedClient]struct{}) | |||
| 45 | } | 49 | } | ||
| 46 | - | s.snatched[ac] = struct{}{} | 50 | + | core.snatched[ac] = struct{}{} |
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func (s *session) attachmentRole(ac *attachedClient) attachmentRole { | |||
| 54 | + | return attachmentSessionRole(s, ac) | |||
| 47 | } | 55 | } | ||
| 48 | 56 | ||||
| 57 | + | func (s *session) attachmentRoleLocked(ac *attachedClient) attachmentRole { | |||
| 58 | + | return attachmentSessionRoleLocked(s, ac) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (s *session) addSnatchedLocked(ac *attachedClient) { addSnatchedLocked(s, ac) } | |||
| 62 | + | ||||
| 49 | type attachmentRoleToken struct { | 63 | type attachmentRoleToken struct { | ||
| 50 | - | sess *session | 64 | + | sess attachmentSession |
| 51 | ac *attachedClient | 65 | ac *attachedClient | ||
| 52 | role attachmentRole | 66 | role attachmentRole | ||
| 53 | generation uint64 | 67 | generation uint64 | ||
| @@ -64,8 +78,8 @@ type attachmentRoleToken struct { | |||||
| 64 | // active coordinator lease at an architecture commit point. The caller must | 78 | // active coordinator lease at an architecture commit point. The caller must | ||
| 65 | // hold s.mu; coordinator acquisition follows the canonical session -> | 79 | // hold s.mu; coordinator acquisition follows the canonical session -> | ||
| 66 | // coordinator order. | 80 | // coordinator order. | ||
| 67 | - | func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken { | 81 | + | func attachmentTokenLocked(entry attachmentSession, ac *attachedClient) attachmentRoleToken { |
| 68 | - | if s == nil || ac == nil { | 82 | + | if entry == nil || entry.core() == nil || ac == nil { |
| 69 | return attachmentRoleToken{} | 83 | return attachmentRoleToken{} | ||
| 70 | } | 84 | } | ||
| 71 | transport := ac.transportSnapshot() | 85 | transport := ac.transportSnapshot() | ||
| @@ -73,40 +87,45 @@ func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken | |||||
| 73 | return attachmentRoleToken{} | 87 | return attachmentRoleToken{} | ||
| 74 | } | 88 | } | ||
| 75 | token := attachmentRoleToken{ | 89 | token := attachmentRoleToken{ | ||
| 76 | - | sess: s, | 90 | + | sess: entry, |
| 77 | ac: ac, | 91 | ac: ac, | ||
| 78 | - | role: s.attachmentRoleLocked(ac), | 92 | + | role: attachmentSessionRoleLocked(entry, ac), |
| 79 | generation: ac.roleGeneration.Load(), | 93 | generation: ac.roleGeneration.Load(), | ||
| 80 | transport: transport, | 94 | transport: transport, | ||
| 81 | } | 95 | } | ||
| 82 | if token.role == attachmentActive { | 96 | if token.role == attachmentActive { | ||
| 83 | - | if rc := s.renderCoordinator(); rc != nil { | 97 | + | if rc := entry.core().coordinator.Load(); rc != nil { |
| 84 | token.lease = rc.attachmentLease(ac) | 98 | token.lease = rc.attachmentLease(ac) | ||
| 85 | } | 99 | } | ||
| 86 | } | 100 | } | ||
| 87 | return token | 101 | return token | ||
| 88 | } | 102 | } | ||
| 89 | 103 | ||||
| 90 | - | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken { | 104 | + | func (s *session) attachmentTokenLocked(ac *attachedClient) attachmentRoleToken { |
| 91 | - | if s == nil || ac == nil || tr == nil { | 105 | + | return attachmentTokenLocked(s, ac) |
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func attachmentToken(entry attachmentSession, ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |||
| 109 | + | if entry == nil || entry.core() == nil || ac == nil || tr == nil { | |||
| 92 | return attachmentRoleToken{} | 110 | return attachmentRoleToken{} | ||
| 93 | } | 111 | } | ||
| 94 | - | s.mu.Lock() | 112 | + | core := entry.core() |
| 113 | + | core.mu.Lock() | |||
| 95 | transport := ac.transportSnapshot() | 114 | transport := ac.transportSnapshot() | ||
| 96 | if transport.transport != tr { | 115 | if transport.transport != tr { | ||
| 97 | - | s.mu.Unlock() | 116 | + | core.mu.Unlock() |
| 98 | return attachmentRoleToken{} | 117 | return attachmentRoleToken{} | ||
| 99 | } | 118 | } | ||
| 100 | token := attachmentRoleToken{ | 119 | token := attachmentRoleToken{ | ||
| 101 | - | sess: s, | 120 | + | sess: entry, |
| 102 | ac: ac, | 121 | ac: ac, | ||
| 103 | - | role: s.attachmentRoleLocked(ac), | 122 | + | role: attachmentSessionRoleLocked(entry, ac), |
| 104 | generation: ac.roleGeneration.Load(), | 123 | generation: ac.roleGeneration.Load(), | ||
| 105 | transport: transport, | 124 | transport: transport, | ||
| 106 | } | 125 | } | ||
| 107 | - | s.mu.Unlock() | 126 | + | core.mu.Unlock() |
| 108 | if token.role == attachmentActive { | 127 | if token.role == attachmentActive { | ||
| 109 | - | if rc := s.renderCoordinator(); rc != nil { | 128 | + | if rc := core.coordinator.Load(); rc != nil { |
| 110 | token.lease = rc.attachmentLease(ac) | 129 | token.lease = rc.attachmentLease(ac) | ||
| 111 | } | 130 | } | ||
| 112 | } | 131 | } | ||
| @@ -114,20 +133,24 @@ func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attach | |||||
| 114 | return token | 133 | return token | ||
| 115 | } | 134 | } | ||
| 116 | 135 | ||||
| 136 | + | func (s *session) attachmentToken(ac *attachedClient, tr ports.Transport) attachmentRoleToken { | |||
| 137 | + | return attachmentToken(s, ac, tr) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 117 | func (t attachmentRoleToken) current() bool { | 140 | func (t attachmentRoleToken) current() bool { | ||
| 118 | return t.sess != nil && t.ac != nil && | 141 | return t.sess != nil && t.ac != nil && | ||
| 119 | t.ac.roleGeneration.Load() == t.generation && | 142 | t.ac.roleGeneration.Load() == t.generation && | ||
| 120 | t.ac.transportSnapshotCurrent(t.transport) && | 143 | t.ac.transportSnapshotCurrent(t.transport) && | ||
| 121 | - | t.sess.attachmentRole(t.ac) == t.role | 144 | + | attachmentSessionRole(t.sess, t.ac) == t.role |
| 122 | } | 145 | } | ||
| 123 | 146 | ||||
| 124 | // activeCurrent admits post-transition effects only for the exact published | 147 | // activeCurrent admits post-transition effects only for the exact published | ||
| 125 | // role, transport incarnation, current-session link, and coordinator lease. | 148 | // role, transport incarnation, current-session link, and coordinator lease. | ||
| 126 | func (t attachmentRoleToken) activeCurrent() bool { | 149 | func (t attachmentRoleToken) activeCurrent() bool { | ||
| 127 | - | if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentSession() != t.sess { | 150 | + | if t.role != attachmentActive || t.lease == nil || !t.current() || t.ac.currentAttachmentSession() != t.sess { |
| 128 | return false | 151 | return false | ||
| 129 | } | 152 | } | ||
| 130 | - | rc := t.sess.renderCoordinator() | 153 | + | rc := t.sess.core().coordinator.Load() |
| 131 | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | 154 | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | ||
| 132 | } | 155 | } | ||
| 133 | 156 | ||||
| @@ -141,11 +164,11 @@ func (t attachmentRoleToken) activeEffect() bool { | |||||
| 141 | return t.activeCurrent() | 164 | return t.activeCurrent() | ||
| 142 | } | 165 | } | ||
| 143 | 166 | ||||
| 144 | - | func beginActiveLeaseEffect(sess *session, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) { | 167 | + | func beginActiveLeaseEffect(sess attachmentSession, ac *attachedClient, lease *attachmentLease) (*roleEffectTicket, bool) { |
| 145 | if sess == nil || ac == nil || lease == nil { | 168 | if sess == nil || ac == nil || lease == nil { | ||
| 146 | return nil, false | 169 | return nil, false | ||
| 147 | } | 170 | } | ||
| 148 | - | token := sess.attachmentToken(ac, ac.transport()) | 171 | + | token := attachmentToken(sess, ac, ac.transport()) |
| 149 | token.lease = lease | 172 | token.lease = lease | ||
| 150 | return ac.beginRoleEffect(token) | 173 | return ac.beginRoleEffect(token) | ||
| 151 | } | 174 | } | ||
| @@ -166,10 +189,10 @@ func (t attachmentRoleToken) activeEffectSessionLocked() bool { | |||||
| 166 | } | 189 | } | ||
| 167 | if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil || | 190 | if t.role != attachmentActive || t.lease == nil || t.sess == nil || t.ac == nil || | ||
| 168 | t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) || | 191 | t.ac.roleGeneration.Load() != t.generation || !t.ac.transportSnapshotCurrent(t.transport) || | ||
| 169 | - | t.ac.currentSession() != t.sess || t.sess.attachmentRoleLocked(t.ac) != attachmentActive { | 192 | + | t.ac.currentAttachmentSession() != t.sess || attachmentSessionRoleLocked(t.sess, t.ac) != attachmentActive { |
| 170 | return false | 193 | return false | ||
| 171 | } | 194 | } | ||
| 172 | - | rc := t.sess.renderCoordinator() | 195 | + | rc := t.sess.core().coordinator.Load() |
| 173 | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | 196 | return rc != nil && t.lease.attachment == t.ac && rc.leaseCurrent(t.lease, true) | ||
| 174 | } | 197 | } | ||
| 175 | 198 | ||||
M internal/usecase/daemon/attachment_transition.go +13 -11
| @@ -5,8 +5,8 @@ import "errors" | |||
| 5 | 5 | var errAttachmentTransition = errors.New("attachment transition is no longer valid") | |
| 6 | 6 | ||
| 7 | 7 | type attachmentTransitionRequest struct { | |
| 8 | - | source *session | |
| 9 | - | target *session | |
| 8 | + | source attachmentSession | |
| 9 | + | target attachmentSession | |
| 10 | 10 | next *attachedClient | |
| 11 | 11 | expectedRole attachmentRole | |
| 12 | 12 | targetRole attachmentRole | |
| @@ -38,7 +38,7 @@ type attachmentTransitionRequest struct { | |||
| 38 | 38 | activationBarrier bool | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | - | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool { | |
| 41 | + | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source attachmentSession, req attachmentTransitionRequest) bool { | |
| 42 | 42 | return token.sess == source && token.ac == req.next && token.role == req.expectedRole && | |
| 43 | 43 | token.transport.transport == req.expectedTransport.transport && | |
| 44 | 44 | token.transport.incarnation == req.expectedTransport.incarnation | |
| @@ -48,10 +48,10 @@ func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *sess | |||
| 48 | 48 | // source, sourceCoordinator.mu. This is the canonical handoff preflight: every | |
| 49 | 49 | // client-originated navigation, delete, or create intent is bound to one exact | |
| 50 | 50 | // role generation, transport incarnation, and coordinator lease. | |
| 51 | - | func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { | |
| 51 | + | func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { | |
| 52 | 52 | if token.sess != source || token.ac != req.next || token.role != req.expectedRole || | |
| 53 | - | token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role || | |
| 54 | - | req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) { | |
| 53 | + | token.generation != req.next.roleGeneration.Load() || attachmentSessionRoleLocked(source, req.next) != token.role || | |
| 54 | + | req.next.currentAttachmentSession() != source || !req.next.transportSnapshotCurrent(token.transport) { | |
| 55 | 55 | return false | |
| 56 | 56 | } | |
| 57 | 57 | if token.role != attachmentActive { | |
| @@ -85,18 +85,18 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) ( | |||
| 85 | 85 | source = req.target | |
| 86 | 86 | } | |
| 87 | 87 | if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing || | |
| 88 | - | d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target || | |
| 88 | + | d.sessions[source.core().id] != source || req.target != nil && d.sessions[req.target.core().id] != req.target || | |
| 89 | 89 | req.activationBarrier && (req.sourceToken == nil || source != req.target || | |
| 90 | 90 | req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) { | |
| 91 | 91 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |
| 92 | 92 | } | |
| 93 | 93 | if req.target != nil { | |
| 94 | - | req.target.mu.Lock() | |
| 95 | - | req.expectedTargetCurrent = req.target.client | |
| 94 | + | req.target.core().mu.Lock() | |
| 95 | + | req.expectedTargetCurrent = req.target.core().client | |
| 96 | 96 | if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive { | |
| 97 | 97 | req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot() | |
| 98 | 98 | } | |
| 99 | - | req.target.mu.Unlock() | |
| 99 | + | req.target.core().mu.Unlock() | |
| 100 | 100 | } | |
| 101 | 101 | if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) { | |
| 102 | 102 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | |
| @@ -174,7 +174,9 @@ func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) | |||
| 174 | 174 | ac.output.rebase() | |
| 175 | 175 | } | |
| 176 | 176 | ac.captureFrames = nil | |
| 177 | - | d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false) | |
| 177 | + | if sess, ok := localSession(result.published.sess); ok { | |
| 178 | + | d.applyHostThemeSendLocked(sess, ac, ac.getClientTheme(), false) | |
| 179 | + | } | |
| 178 | 180 | // The first paint still requests a reset, but the old panel dependency chain | |
| 179 | 181 | // has already been rebased atomically under the activation barrier. | |
| 180 | 182 | result.published.rebase = false | |
| @@ -5,8 +5,8 @@ import "errors" | |||||
| 5 | var errAttachmentTransition = errors.New("attachment transition is no longer valid") | 5 | var errAttachmentTransition = errors.New("attachment transition is no longer valid") | ||
| 6 | 6 | ||||
| 7 | type attachmentTransitionRequest struct { | 7 | type attachmentTransitionRequest struct { | ||
| 8 | - | source *session | 8 | + | source attachmentSession |
| 9 | - | target *session | 9 | + | target attachmentSession |
| 10 | next *attachedClient | 10 | next *attachedClient | ||
| 11 | expectedRole attachmentRole | 11 | expectedRole attachmentRole | ||
| 12 | targetRole attachmentRole | 12 | targetRole attachmentRole | ||
| @@ -38,7 +38,7 @@ type attachmentTransitionRequest struct { | |||||
| 38 | activationBarrier bool | 38 | activationBarrier bool | ||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | - | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *session, req attachmentTransitionRequest) bool { | 41 | + | func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source attachmentSession, req attachmentTransitionRequest) bool { |
| 42 | return token.sess == source && token.ac == req.next && token.role == req.expectedRole && | 42 | return token.sess == source && token.ac == req.next && token.role == req.expectedRole && | ||
| 43 | token.transport.transport == req.expectedTransport.transport && | 43 | token.transport.transport == req.expectedTransport.transport && | ||
| 44 | token.transport.incarnation == req.expectedTransport.incarnation | 44 | token.transport.incarnation == req.expectedTransport.incarnation | ||
| @@ -48,10 +48,10 @@ func transitionSourceTokenMatchesRequest(token attachmentRoleToken, source *sess | |||||
| 48 | // source, sourceCoordinator.mu. This is the canonical handoff preflight: every | 48 | // source, sourceCoordinator.mu. This is the canonical handoff preflight: every | ||
| 49 | // client-originated navigation, delete, or create intent is bound to one exact | 49 | // client-originated navigation, delete, or create intent is bound to one exact | ||
| 50 | // role generation, transport incarnation, and coordinator lease. | 50 | // role generation, transport incarnation, and coordinator lease. | ||
| 51 | - | func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source *session, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { | 51 | + | func transitionSourceTokenCurrentLocked(token attachmentRoleToken, source attachmentSession, sourceCoordinator *renderCoordinator, req attachmentTransitionRequest) bool { |
| 52 | if token.sess != source || token.ac != req.next || token.role != req.expectedRole || | 52 | if token.sess != source || token.ac != req.next || token.role != req.expectedRole || | ||
| 53 | - | token.generation != req.next.roleGeneration.Load() || source.attachmentRoleLocked(req.next) != token.role || | 53 | + | token.generation != req.next.roleGeneration.Load() || attachmentSessionRoleLocked(source, req.next) != token.role || |
| 54 | - | req.next.currentSession() != source || !req.next.transportSnapshotCurrent(token.transport) { | 54 | + | req.next.currentAttachmentSession() != source || !req.next.transportSnapshotCurrent(token.transport) { |
| 55 | return false | 55 | return false | ||
| 56 | } | 56 | } | ||
| 57 | if token.role != attachmentActive { | 57 | if token.role != attachmentActive { | ||
| @@ -85,18 +85,18 @@ func (d *Daemon) snapshotAttachmentTransition(req attachmentTransitionRequest) ( | |||||
| 85 | source = req.target | 85 | source = req.target | ||
| 86 | } | 86 | } | ||
| 87 | if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing || | 87 | if source == nil || (req.target == nil && req.createTargetLocked == nil) || req.next == nil || d.closing || | ||
| 88 | - | d.sessions[source.id] != source || req.target != nil && d.sessions[req.target.id] != req.target || | 88 | + | d.sessions[source.core().id] != source || req.target != nil && d.sessions[req.target.core().id] != req.target || |
| 89 | req.activationBarrier && (req.sourceToken == nil || source != req.target || | 89 | req.activationBarrier && (req.sourceToken == nil || source != req.target || | ||
| 90 | req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) { | 90 | req.expectedRole != attachmentSnatched || req.targetRole != attachmentActive) { | ||
| 91 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | 91 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | ||
| 92 | } | 92 | } | ||
| 93 | if req.target != nil { | 93 | if req.target != nil { | ||
| 94 | - | req.target.mu.Lock() | 94 | + | req.target.core().mu.Lock() |
| 95 | - | req.expectedTargetCurrent = req.target.client | 95 | + | req.expectedTargetCurrent = req.target.core().client |
| 96 | if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive { | 96 | if req.expectedTargetCurrent != nil && req.expectedTargetCurrent != req.next && req.targetRole == attachmentActive { | ||
| 97 | req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot() | 97 | req.expectedTargetTransport = req.expectedTargetCurrent.transportSnapshot() | ||
| 98 | } | 98 | } | ||
| 99 | - | req.target.mu.Unlock() | 99 | + | req.target.core().mu.Unlock() |
| 100 | } | 100 | } | ||
| 101 | if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) { | 101 | if req.sourceToken != nil && !transitionSourceTokenMatchesRequest(*req.sourceToken, source, req) { | ||
| 102 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | 102 | return attachmentTransitionRequest{}, attachmentTransitionParticipants{}, errAttachmentTransition | ||
| @@ -174,7 +174,9 @@ func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) | |||||
| 174 | ac.output.rebase() | 174 | ac.output.rebase() | ||
| 175 | } | 175 | } | ||
| 176 | ac.captureFrames = nil | 176 | ac.captureFrames = nil | ||
| 177 | - | d.applyHostThemeSendLocked(result.published.sess, ac, ac.getClientTheme(), false) | 177 | + | if sess, ok := localSession(result.published.sess); ok { |
| 178 | + | d.applyHostThemeSendLocked(sess, ac, ac.getClientTheme(), false) | |||
| 179 | + | } | |||
| 178 | // The first paint still requests a reset, but the old panel dependency chain | 180 | // The first paint still requests a reset, but the old panel dependency chain | ||
| 179 | // has already been rebased atomically under the activation barrier. | 181 | // has already been rebased atomically under the activation barrier. | ||
| 180 | result.published.rebase = false | 182 | result.published.rebase = false | ||
M internal/usecase/daemon/attachment_transition_prelocked_test.go +3 -3
| @@ -25,7 +25,7 @@ func TestAttachmentTransitionPrelockedMatchesWrapper(t *testing.T) { | |||
| 25 | 25 | setup := func(t *testing.T) (*Daemon, *session, *attachedClient, *attachedClient) { | |
| 26 | 26 | t.Helper() | |
| 27 | 27 | d := newTestDaemon(t, nil, stubClock{}) | |
| 28 | - | sess := &session{id: domain.SessionID("work")} | |
| 28 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} | |
| 29 | 29 | old := &attachedClient{tr: &closeTrackingTransport{}} | |
| 30 | 30 | old.setSession(sess) | |
| 31 | 31 | sess.client = old | |
| @@ -120,8 +120,8 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure | |||
| 120 | 120 | t.Parallel() | |
| 121 | 121 | ||
| 122 | 122 | d := newTestDaemon(t, nil, stubClock{}) | |
| 123 | - | source := &session{id: domain.SessionID("source")} | |
| 124 | - | target := &session{id: domain.SessionID("target"), active: 0} | |
| 123 | + | source := &session{sessionCore: sessionCore{id: domain.SessionID("source")}} | |
| 124 | + | target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}, active: 0} | |
| 125 | 125 | next := &attachedClient{tr: &closeTrackingTransport{}} | |
| 126 | 126 | next.setSession(source) | |
| 127 | 127 | source.addSnatchedLocked(next) | |
| @@ -25,7 +25,7 @@ func TestAttachmentTransitionPrelockedMatchesWrapper(t *testing.T) { | |||||
| 25 | setup := func(t *testing.T) (*Daemon, *session, *attachedClient, *attachedClient) { | 25 | setup := func(t *testing.T) (*Daemon, *session, *attachedClient, *attachedClient) { | ||
| 26 | t.Helper() | 26 | t.Helper() | ||
| 27 | d := newTestDaemon(t, nil, stubClock{}) | 27 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 28 | - | sess := &session{id: domain.SessionID("work")} | 28 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} |
| 29 | old := &attachedClient{tr: &closeTrackingTransport{}} | 29 | old := &attachedClient{tr: &closeTrackingTransport{}} | ||
| 30 | old.setSession(sess) | 30 | old.setSession(sess) | ||
| 31 | sess.client = old | 31 | sess.client = old | ||
| @@ -120,8 +120,8 @@ func TestValidateAttachmentTransitionPrelockedLeavesMembershipUntouchedOnFailure | |||||
| 120 | t.Parallel() | 120 | t.Parallel() | ||
| 121 | 121 | ||||
| 122 | d := newTestDaemon(t, nil, stubClock{}) | 122 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 123 | - | source := &session{id: domain.SessionID("source")} | 123 | + | source := &session{sessionCore: sessionCore{id: domain.SessionID("source")}} |
| 124 | - | target := &session{id: domain.SessionID("target"), active: 0} | 124 | + | target := &session{sessionCore: sessionCore{id: domain.SessionID("target")}, active: 0} |
| 125 | next := &attachedClient{tr: &closeTrackingTransport{}} | 125 | next := &attachedClient{tr: &closeTrackingTransport{}} | ||
| 126 | next.setSession(source) | 126 | next.setSession(source) | ||
| 127 | source.addSnatchedLocked(next) | 127 | source.addSnatchedLocked(next) | ||
M internal/usecase/daemon/attachment_transition_publish.go +47 -41
| @@ -4,14 +4,14 @@ package daemon | |||
| 4 | 4 | // is frozen and d.mu is held. No caller may create or delete a target before | |
| 5 | 5 | // this succeeds. | |
| 6 | 6 | func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool { | |
| 7 | - | if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source { | |
| 7 | + | if req.sourceToken == nil || req.source == nil || req.source.core() == nil || d.sessions[req.source.core().id] != req.source { | |
| 8 | 8 | return false | |
| 9 | 9 | } | |
| 10 | 10 | d.notices.routingMu.Lock() | |
| 11 | 11 | defer d.notices.routingMu.Unlock() | |
| 12 | - | req.source.mu.Lock() | |
| 13 | - | defer req.source.mu.Unlock() | |
| 14 | - | coordinator := req.source.renderCoordinator() | |
| 12 | + | req.source.core().mu.Lock() | |
| 13 | + | defer req.source.core().mu.Unlock() | |
| 14 | + | coordinator := req.source.core().coordinator.Load() | |
| 15 | 15 | if coordinator == nil { | |
| 16 | 16 | return false | |
| 17 | 17 | } | |
| @@ -29,14 +29,14 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |||
| 29 | 29 | } | |
| 30 | 30 | d.mu.Lock() | |
| 31 | 31 | defer d.mu.Unlock() | |
| 32 | - | if d.closing || d.sessions[token.sess.id] != token.sess { | |
| 32 | + | if d.closing || token.sess.core() == nil || d.sessions[token.sess.core().id] != token.sess { | |
| 33 | 33 | return false | |
| 34 | 34 | } | |
| 35 | 35 | d.notices.routingMu.Lock() | |
| 36 | 36 | defer d.notices.routingMu.Unlock() | |
| 37 | - | token.sess.mu.Lock() | |
| 38 | - | defer token.sess.mu.Unlock() | |
| 39 | - | coordinator := token.sess.renderCoordinator() | |
| 37 | + | token.sess.core().mu.Lock() | |
| 38 | + | defer token.sess.core().mu.Unlock() | |
| 39 | + | coordinator := token.sess.core().coordinator.Load() | |
| 40 | 40 | if coordinator == nil { | |
| 41 | 41 | return false | |
| 42 | 42 | } | |
| @@ -49,15 +49,16 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |||
| 49 | 49 | return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | - | func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | |
| 52 | + | func transitionSourceTabCurrentLocked(source attachmentSession, expected *tab) bool { | |
| 53 | 53 | return transitionSourceTabCurrentForRequestLocked(source, expected, false) | |
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | - | func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool { | |
| 56 | + | func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expected *tab, transferExpected bool) bool { | |
| 57 | 57 | if expected == nil { | |
| 58 | 58 | return true | |
| 59 | 59 | } | |
| 60 | - | if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected { | |
| 60 | + | local, ok := localSession(source) | |
| 61 | + | if !ok || local.active < 0 || local.active >= len(local.tabs) || local.tabs[local.active] != expected { | |
| 61 | 62 | return false | |
| 62 | 63 | } | |
| 63 | 64 | expected.mu.Lock() | |
| @@ -78,7 +79,7 @@ func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (at | |||
| 78 | 79 | // own d.mu, notices.routingMu, and the ordered source/target session locks. | |
| 79 | 80 | type attachmentPublication struct { | |
| 80 | 81 | req attachmentTransitionRequest | |
| 81 | - | source *session | |
| 82 | + | source attachmentSession | |
| 82 | 83 | old *attachedClient | |
| 83 | 84 | sourceCoordinator *renderCoordinator | |
| 84 | 85 | targetCoordinator *renderCoordinator | |
| @@ -105,7 +106,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 105 | 106 | if !req.preflighted || !req.roleEffectsFrozen || | |
| 106 | 107 | !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) || | |
| 107 | 108 | source == nil || req.target == nil || req.next == nil || d.closing || | |
| 108 | - | d.sessions[source.id] != source || d.sessions[req.target.id] != req.target || | |
| 109 | + | d.sessions[source.core().id] != source || d.sessions[req.target.core().id] != req.target || | |
| 109 | 110 | req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) || | |
| 110 | 111 | req.preserveRole && req.sourceToken == nil { | |
| 111 | 112 | return nil, errAttachmentTransition | |
| @@ -113,15 +114,17 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 113 | 114 | ||
| 114 | 115 | var targetCoordinator *renderCoordinator | |
| 115 | 116 | if req.targetRole == attachmentActive { | |
| 116 | - | targetCoordinator = d.ensureRenderCoordinatorPrelocked(req.target) | |
| 117 | + | if target, ok := localSession(req.target); ok { | |
| 118 | + | targetCoordinator = d.ensureRenderCoordinatorPrelocked(target) | |
| 119 | + | } | |
| 117 | 120 | } | |
| 118 | - | sourceCoordinator := source.renderCoordinator() | |
| 119 | - | if source.attachmentRoleLocked(req.next) != req.expectedRole || | |
| 121 | + | sourceCoordinator := source.core().coordinator.Load() | |
| 122 | + | if attachmentSessionRoleLocked(source, req.next) != req.expectedRole || | |
| 120 | 123 | !req.next.transportSnapshotCurrent(req.expectedTransport) { | |
| 121 | 124 | return nil, errAttachmentTransition | |
| 122 | 125 | } | |
| 123 | 126 | ||
| 124 | - | old := req.target.client | |
| 127 | + | old := req.target.core().client | |
| 125 | 128 | if old != req.expectedTargetCurrent || source != req.target && old == req.next { | |
| 126 | 129 | return nil, errAttachmentTransition | |
| 127 | 130 | } | |
| @@ -135,7 +138,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 135 | 138 | invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) | |
| 136 | 139 | invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | |
| 137 | 140 | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) | |
| 138 | - | invalid = invalid || req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) | |
| 141 | + | invalid = invalid || req.activateTargetTab && !req.target.activateTargetLocked(req.targetTabIndex) | |
| 139 | 142 | if invalid { | |
| 140 | 143 | publication.unlockCoordinators() | |
| 141 | 144 | return nil, errAttachmentTransition | |
| @@ -148,14 +151,17 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||
| 148 | 151 | func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool { | |
| 149 | 152 | req := publication.req | |
| 150 | 153 | if req.targetRole == attachmentActive { | |
| 151 | - | d.demoteParkedActiveForSessionLocked(req.target) | |
| 154 | + | if target, ok := localSession(req.target); ok { | |
| 155 | + | d.demoteParkedActiveForSessionLocked(target) | |
| 156 | + | } | |
| 152 | 157 | } | |
| 153 | 158 | if req.copySourceEnvironment { | |
| 154 | - | req.target.terminal = publication.source.terminal | |
| 155 | - | req.target.env = copyEnvironment(publication.source.env) | |
| 156 | - | } | |
| 157 | - | if req.activateTargetTab { | |
| 158 | - | req.target.active = req.targetTabIndex | |
| 159 | + | source, sourceOK := localSession(publication.source) | |
| 160 | + | target, targetOK := localSession(req.target) | |
| 161 | + | if sourceOK && targetOK { | |
| 162 | + | target.terminal = source.terminal | |
| 163 | + | target.env = copyEnvironment(source.env) | |
| 164 | + | } | |
| 159 | 165 | } | |
| 160 | 166 | return req.preserveRole | |
| 161 | 167 | } | |
| @@ -168,19 +174,19 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |||
| 168 | 174 | publication.displacedTransport = publication.old.transportSnapshot() | |
| 169 | 175 | } | |
| 170 | 176 | if publication.source != req.target { | |
| 171 | - | if publication.source.client == req.next { | |
| 172 | - | publication.source.client = nil | |
| 177 | + | if publication.source.core().client == req.next { | |
| 178 | + | publication.source.core().client = nil | |
| 173 | 179 | } | |
| 174 | - | delete(publication.source.snatched, req.next) | |
| 180 | + | delete(publication.source.core().snatched, req.next) | |
| 175 | 181 | } | |
| 176 | 182 | if req.targetRole == attachmentActive { | |
| 177 | - | delete(req.target.snatched, req.next) | |
| 183 | + | delete(req.target.core().snatched, req.next) | |
| 178 | 184 | if publication.old != nil && publication.old != req.next { | |
| 179 | - | req.target.addSnatchedLocked(publication.old) | |
| 185 | + | addSnatchedLocked(req.target, publication.old) | |
| 180 | 186 | } | |
| 181 | - | req.target.client = req.next | |
| 187 | + | req.target.core().client = req.next | |
| 182 | 188 | } else { | |
| 183 | - | req.target.addSnatchedLocked(req.next) | |
| 189 | + | addSnatchedLocked(req.target, req.next) | |
| 184 | 190 | } | |
| 185 | 191 | publication.nextGeneration = req.next.roleGeneration.Add(1) | |
| 186 | 192 | if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive { | |
| @@ -276,26 +282,26 @@ func validAttachmentTransitionRole(role attachmentRole, expected bool) bool { | |||
| 276 | 282 | ||
| 277 | 283 | // lockAttachmentSessions gives every two-session transition one stable order. | |
| 278 | 284 | // Session IDs are immutable and unique while their lifecycles are registered. | |
| 279 | - | func lockAttachmentSessions(a, b *session) func() { | |
| 285 | + | func lockAttachmentSessions(a, b attachmentSession) func() { | |
| 280 | 286 | if a == b { | |
| 281 | - | a.mu.Lock() | |
| 282 | - | return a.mu.Unlock | |
| 287 | + | a.core().mu.Lock() | |
| 288 | + | return a.core().mu.Unlock | |
| 283 | 289 | } | |
| 284 | 290 | first, second := a, b | |
| 285 | - | if first.id > second.id { | |
| 291 | + | if first.core().id > second.core().id { | |
| 286 | 292 | first, second = second, first | |
| 287 | 293 | } | |
| 288 | - | first.mu.Lock() | |
| 289 | - | second.mu.Lock() | |
| 294 | + | first.core().mu.Lock() | |
| 295 | + | second.core().mu.Lock() | |
| 290 | 296 | return func() { | |
| 291 | - | second.mu.Unlock() | |
| 292 | - | first.mu.Unlock() | |
| 297 | + | second.core().mu.Unlock() | |
| 298 | + | first.core().mu.Unlock() | |
| 293 | 299 | } | |
| 294 | 300 | } | |
| 295 | 301 | ||
| 296 | 302 | // lockAttachmentCoordinators follows the same immutable session-ID order as | |
| 297 | 303 | // lockAttachmentSessions. Callers already hold the ordered session locks. | |
| 298 | - | func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() { | |
| 304 | + | func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() { | |
| 299 | 305 | if aCoordinator == nil && bCoordinator == nil { | |
| 300 | 306 | return func() {} | |
| 301 | 307 | } | |
| @@ -308,7 +314,7 @@ func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b * | |||
| 308 | 314 | return coordinator.mu.Unlock | |
| 309 | 315 | } | |
| 310 | 316 | first, second := aCoordinator, bCoordinator | |
| 311 | - | if a.id > b.id { | |
| 317 | + | if a.core().id > b.core().id { | |
| 312 | 318 | first, second = second, first | |
| 313 | 319 | } | |
| 314 | 320 | if first != nil { | |
| @@ -4,14 +4,14 @@ package daemon | |||||
| 4 | // is frozen and d.mu is held. No caller may create or delete a target before | 4 | // is frozen and d.mu is held. No caller may create or delete a target before | ||
| 5 | // this succeeds. | 5 | // this succeeds. | ||
| 6 | func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool { | 6 | func (d *Daemon) transitionSourcePreflightLocked(req attachmentTransitionRequest) bool { | ||
| 7 | - | if req.sourceToken == nil || req.source == nil || d.sessions[req.source.id] != req.source { | 7 | + | if req.sourceToken == nil || req.source == nil || req.source.core() == nil || d.sessions[req.source.core().id] != req.source { |
| 8 | return false | 8 | return false | ||
| 9 | } | 9 | } | ||
| 10 | d.notices.routingMu.Lock() | 10 | d.notices.routingMu.Lock() | ||
| 11 | defer d.notices.routingMu.Unlock() | 11 | defer d.notices.routingMu.Unlock() | ||
| 12 | - | req.source.mu.Lock() | 12 | + | req.source.core().mu.Lock() |
| 13 | - | defer req.source.mu.Unlock() | 13 | + | defer req.source.core().mu.Unlock() |
| 14 | - | coordinator := req.source.renderCoordinator() | 14 | + | coordinator := req.source.core().coordinator.Load() |
| 15 | if coordinator == nil { | 15 | if coordinator == nil { | ||
| 16 | return false | 16 | return false | ||
| 17 | } | 17 | } | ||
| @@ -29,14 +29,14 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |||||
| 29 | } | 29 | } | ||
| 30 | d.mu.Lock() | 30 | d.mu.Lock() | ||
| 31 | defer d.mu.Unlock() | 31 | defer d.mu.Unlock() | ||
| 32 | - | if d.closing || d.sessions[token.sess.id] != token.sess { | 32 | + | if d.closing || token.sess.core() == nil || d.sessions[token.sess.core().id] != token.sess { |
| 33 | return false | 33 | return false | ||
| 34 | } | 34 | } | ||
| 35 | d.notices.routingMu.Lock() | 35 | d.notices.routingMu.Lock() | ||
| 36 | defer d.notices.routingMu.Unlock() | 36 | defer d.notices.routingMu.Unlock() | ||
| 37 | - | token.sess.mu.Lock() | 37 | + | token.sess.core().mu.Lock() |
| 38 | - | defer token.sess.mu.Unlock() | 38 | + | defer token.sess.core().mu.Unlock() |
| 39 | - | coordinator := token.sess.renderCoordinator() | 39 | + | coordinator := token.sess.core().coordinator.Load() |
| 40 | if coordinator == nil { | 40 | if coordinator == nil { | ||
| 41 | return false | 41 | return false | ||
| 42 | } | 42 | } | ||
| @@ -49,15 +49,16 @@ func (d *Daemon) sourceRoleTokenCurrentFrozen(token attachmentRoleToken) bool { | |||||
| 49 | return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | 49 | return transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | - | func transitionSourceTabCurrentLocked(source *session, expected *tab) bool { | 52 | + | func transitionSourceTabCurrentLocked(source attachmentSession, expected *tab) bool { |
| 53 | return transitionSourceTabCurrentForRequestLocked(source, expected, false) | 53 | return transitionSourceTabCurrentForRequestLocked(source, expected, false) | ||
| 54 | } | 54 | } | ||
| 55 | 55 | ||||
| 56 | - | func transitionSourceTabCurrentForRequestLocked(source *session, expected *tab, transferExpected bool) bool { | 56 | + | func transitionSourceTabCurrentForRequestLocked(source attachmentSession, expected *tab, transferExpected bool) bool { |
| 57 | if expected == nil { | 57 | if expected == nil { | ||
| 58 | return true | 58 | return true | ||
| 59 | } | 59 | } | ||
| 60 | - | if source.active < 0 || source.active >= len(source.tabs) || source.tabs[source.active] != expected { | 60 | + | local, ok := localSession(source) |
| 61 | + | if !ok || local.active < 0 || local.active >= len(local.tabs) || local.tabs[local.active] != expected { | |||
| 61 | return false | 62 | return false | ||
| 62 | } | 63 | } | ||
| 63 | expected.mu.Lock() | 64 | expected.mu.Lock() | ||
| @@ -78,7 +79,7 @@ func (d *Daemon) transitionAttachmentLocked(req attachmentTransitionRequest) (at | |||||
| 78 | // own d.mu, notices.routingMu, and the ordered source/target session locks. | 79 | // own d.mu, notices.routingMu, and the ordered source/target session locks. | ||
| 79 | type attachmentPublication struct { | 80 | type attachmentPublication struct { | ||
| 80 | req attachmentTransitionRequest | 81 | req attachmentTransitionRequest | ||
| 81 | - | source *session | 82 | + | source attachmentSession |
| 82 | old *attachedClient | 83 | old *attachedClient | ||
| 83 | sourceCoordinator *renderCoordinator | 84 | sourceCoordinator *renderCoordinator | ||
| 84 | targetCoordinator *renderCoordinator | 85 | targetCoordinator *renderCoordinator | ||
| @@ -105,7 +106,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||||
| 105 | if !req.preflighted || !req.roleEffectsFrozen || | 106 | if !req.preflighted || !req.roleEffectsFrozen || | ||
| 106 | !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) || | 107 | !validAttachmentTransitionRole(req.expectedRole, true) || !validAttachmentTransitionRole(req.targetRole, false) || | ||
| 107 | source == nil || req.target == nil || req.next == nil || d.closing || | 108 | source == nil || req.target == nil || req.next == nil || d.closing || | ||
| 108 | - | d.sessions[source.id] != source || d.sessions[req.target.id] != req.target || | 109 | + | d.sessions[source.core().id] != source || d.sessions[req.target.core().id] != req.target || |
| 109 | req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) || | 110 | req.expectedTransport.transport == nil || !req.next.transportSnapshotCurrent(req.expectedTransport) || | ||
| 110 | req.preserveRole && req.sourceToken == nil { | 111 | req.preserveRole && req.sourceToken == nil { | ||
| 111 | return nil, errAttachmentTransition | 112 | return nil, errAttachmentTransition | ||
| @@ -113,15 +114,17 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||||
| 113 | 114 | ||||
| 114 | var targetCoordinator *renderCoordinator | 115 | var targetCoordinator *renderCoordinator | ||
| 115 | if req.targetRole == attachmentActive { | 116 | if req.targetRole == attachmentActive { | ||
| 116 | - | targetCoordinator = d.ensureRenderCoordinatorPrelocked(req.target) | 117 | + | if target, ok := localSession(req.target); ok { |
| 118 | + | targetCoordinator = d.ensureRenderCoordinatorPrelocked(target) | |||
| 119 | + | } | |||
| 117 | } | 120 | } | ||
| 118 | - | sourceCoordinator := source.renderCoordinator() | 121 | + | sourceCoordinator := source.core().coordinator.Load() |
| 119 | - | if source.attachmentRoleLocked(req.next) != req.expectedRole || | 122 | + | if attachmentSessionRoleLocked(source, req.next) != req.expectedRole || |
| 120 | !req.next.transportSnapshotCurrent(req.expectedTransport) { | 123 | !req.next.transportSnapshotCurrent(req.expectedTransport) { | ||
| 121 | return nil, errAttachmentTransition | 124 | return nil, errAttachmentTransition | ||
| 122 | } | 125 | } | ||
| 123 | 126 | ||||
| 124 | - | old := req.target.client | 127 | + | old := req.target.core().client |
| 125 | if old != req.expectedTargetCurrent || source != req.target && old == req.next { | 128 | if old != req.expectedTargetCurrent || source != req.target && old == req.next { | ||
| 126 | return nil, errAttachmentTransition | 129 | return nil, errAttachmentTransition | ||
| 127 | } | 130 | } | ||
| @@ -135,7 +138,7 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||||
| 135 | invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) | 138 | invalid = invalid || targetCoordinator != nil && !req.preserveRole && !targetCoordinator.canReplaceLocked(old, req.next) | ||
| 136 | invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | 139 | invalid = invalid || req.sourceToken == nil && source != req.target && req.expectedRole == attachmentActive && sourceCoordinator != nil && | ||
| 137 | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) | 140 | (sourceCoordinator.lease == nil || !sourceCoordinator.lease.active || sourceCoordinator.lease.attachment != req.next) | ||
| 138 | - | invalid = invalid || req.activateTargetTab && (req.targetTabIndex < 0 || req.targetTabIndex >= len(req.target.tabs)) | 141 | + | invalid = invalid || req.activateTargetTab && !req.target.activateTargetLocked(req.targetTabIndex) |
| 139 | if invalid { | 142 | if invalid { | ||
| 140 | publication.unlockCoordinators() | 143 | publication.unlockCoordinators() | ||
| 141 | return nil, errAttachmentTransition | 144 | return nil, errAttachmentTransition | ||
| @@ -148,14 +151,17 @@ func (d *Daemon) validateAttachmentTransitionPrelocked(req attachmentTransitionR | |||||
| 148 | func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool { | 151 | func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool { | ||
| 149 | req := publication.req | 152 | req := publication.req | ||
| 150 | if req.targetRole == attachmentActive { | 153 | if req.targetRole == attachmentActive { | ||
| 151 | - | d.demoteParkedActiveForSessionLocked(req.target) | 154 | + | if target, ok := localSession(req.target); ok { |
| 155 | + | d.demoteParkedActiveForSessionLocked(target) | |||
| 156 | + | } | |||
| 152 | } | 157 | } | ||
| 153 | if req.copySourceEnvironment { | 158 | if req.copySourceEnvironment { | ||
| 154 | - | req.target.terminal = publication.source.terminal | 159 | + | source, sourceOK := localSession(publication.source) |
| 155 | - | req.target.env = copyEnvironment(publication.source.env) | 160 | + | target, targetOK := localSession(req.target) |
| 156 | - | } | 161 | + | if sourceOK && targetOK { |
| 157 | - | if req.activateTargetTab { | 162 | + | target.terminal = source.terminal |
| 158 | - | req.target.active = req.targetTabIndex | 163 | + | target.env = copyEnvironment(source.env) |
| 164 | + | } | |||
| 159 | } | 165 | } | ||
| 160 | return req.preserveRole | 166 | return req.preserveRole | ||
| 161 | } | 167 | } | ||
| @@ -168,19 +174,19 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |||||
| 168 | publication.displacedTransport = publication.old.transportSnapshot() | 174 | publication.displacedTransport = publication.old.transportSnapshot() | ||
| 169 | } | 175 | } | ||
| 170 | if publication.source != req.target { | 176 | if publication.source != req.target { | ||
| 171 | - | if publication.source.client == req.next { | 177 | + | if publication.source.core().client == req.next { |
| 172 | - | publication.source.client = nil | 178 | + | publication.source.core().client = nil |
| 173 | } | 179 | } | ||
| 174 | - | delete(publication.source.snatched, req.next) | 180 | + | delete(publication.source.core().snatched, req.next) |
| 175 | } | 181 | } | ||
| 176 | if req.targetRole == attachmentActive { | 182 | if req.targetRole == attachmentActive { | ||
| 177 | - | delete(req.target.snatched, req.next) | 183 | + | delete(req.target.core().snatched, req.next) |
| 178 | if publication.old != nil && publication.old != req.next { | 184 | if publication.old != nil && publication.old != req.next { | ||
| 179 | - | req.target.addSnatchedLocked(publication.old) | 185 | + | addSnatchedLocked(req.target, publication.old) |
| 180 | } | 186 | } | ||
| 181 | - | req.target.client = req.next | 187 | + | req.target.core().client = req.next |
| 182 | } else { | 188 | } else { | ||
| 183 | - | req.target.addSnatchedLocked(req.next) | 189 | + | addSnatchedLocked(req.target, req.next) |
| 184 | } | 190 | } | ||
| 185 | publication.nextGeneration = req.next.roleGeneration.Add(1) | 191 | publication.nextGeneration = req.next.roleGeneration.Add(1) | ||
| 186 | if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive { | 192 | if publication.old != nil && publication.old != req.next && req.targetRole == attachmentActive { | ||
| @@ -276,26 +282,26 @@ func validAttachmentTransitionRole(role attachmentRole, expected bool) bool { | |||||
| 276 | 282 | ||||
| 277 | // lockAttachmentSessions gives every two-session transition one stable order. | 283 | // lockAttachmentSessions gives every two-session transition one stable order. | ||
| 278 | // Session IDs are immutable and unique while their lifecycles are registered. | 284 | // Session IDs are immutable and unique while their lifecycles are registered. | ||
| 279 | - | func lockAttachmentSessions(a, b *session) func() { | 285 | + | func lockAttachmentSessions(a, b attachmentSession) func() { |
| 280 | if a == b { | 286 | if a == b { | ||
| 281 | - | a.mu.Lock() | 287 | + | a.core().mu.Lock() |
| 282 | - | return a.mu.Unlock | 288 | + | return a.core().mu.Unlock |
| 283 | } | 289 | } | ||
| 284 | first, second := a, b | 290 | first, second := a, b | ||
| 285 | - | if first.id > second.id { | 291 | + | if first.core().id > second.core().id { |
| 286 | first, second = second, first | 292 | first, second = second, first | ||
| 287 | } | 293 | } | ||
| 288 | - | first.mu.Lock() | 294 | + | first.core().mu.Lock() |
| 289 | - | second.mu.Lock() | 295 | + | second.core().mu.Lock() |
| 290 | return func() { | 296 | return func() { | ||
| 291 | - | second.mu.Unlock() | 297 | + | second.core().mu.Unlock() |
| 292 | - | first.mu.Unlock() | 298 | + | first.core().mu.Unlock() |
| 293 | } | 299 | } | ||
| 294 | } | 300 | } | ||
| 295 | 301 | ||||
| 296 | // lockAttachmentCoordinators follows the same immutable session-ID order as | 302 | // lockAttachmentCoordinators follows the same immutable session-ID order as | ||
| 297 | // lockAttachmentSessions. Callers already hold the ordered session locks. | 303 | // lockAttachmentSessions. Callers already hold the ordered session locks. | ||
| 298 | - | func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b *session, bCoordinator *renderCoordinator) func() { | 304 | + | func lockAttachmentCoordinators(a attachmentSession, aCoordinator *renderCoordinator, b attachmentSession, bCoordinator *renderCoordinator) func() { |
| 299 | if aCoordinator == nil && bCoordinator == nil { | 305 | if aCoordinator == nil && bCoordinator == nil { | ||
| 300 | return func() {} | 306 | return func() {} | ||
| 301 | } | 307 | } | ||
| @@ -308,7 +314,7 @@ func lockAttachmentCoordinators(a *session, aCoordinator *renderCoordinator, b * | |||||
| 308 | return coordinator.mu.Unlock | 314 | return coordinator.mu.Unlock | ||
| 309 | } | 315 | } | ||
| 310 | first, second := aCoordinator, bCoordinator | 316 | first, second := aCoordinator, bCoordinator | ||
| 311 | - | if a.id > b.id { | 317 | + | if a.core().id > b.core().id { |
| 312 | first, second = second, first | 318 | first, second = second, first | ||
| 313 | } | 319 | } | ||
| 314 | if first != nil { | 320 | if first != nil { | ||
M internal/usecase/daemon/attention.go +4 -10
| @@ -87,8 +87,8 @@ type attentionTarget struct { | |||
| 87 | 87 | func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) { | |
| 88 | 88 | d.mu.Lock() | |
| 89 | 89 | sessions := make([]*session, 0, len(d.sessions)) | |
| 90 | - | for _, sess := range d.sessions { | |
| 91 | - | if sess != current { | |
| 90 | + | for _, entry := range d.sessions { | |
| 91 | + | if sess, ok := localSession(entry); ok && sess != current { | |
| 92 | 92 | sessions = append(sessions, sess) | |
| 93 | 93 | } | |
| 94 | 94 | } | |
| @@ -137,10 +137,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) { | |||
| 137 | 137 | ||
| 138 | 138 | func (d *Daemon) repaintAllAttachedClients() { | |
| 139 | 139 | d.mu.Lock() | |
| 140 | - | sessions := make([]*session, 0, len(d.sessions)) | |
| 141 | - | for _, sess := range d.sessions { | |
| 142 | - | sessions = append(sessions, sess) | |
| 143 | - | } | |
| 140 | + | sessions := localSessionsSnapshot(d.sessions) | |
| 144 | 141 | d.mu.Unlock() | |
| 145 | 142 | ||
| 146 | 143 | for _, sess := range sessions { | |
| @@ -247,10 +244,7 @@ func (d *Daemon) attentionAnimator(ctx context.Context) { | |||
| 247 | 244 | ||
| 248 | 245 | func (d *Daemon) anyAttention() bool { | |
| 249 | 246 | d.mu.Lock() | |
| 250 | - | sessions := make([]*session, 0, len(d.sessions)) | |
| 251 | - | for _, sess := range d.sessions { | |
| 252 | - | sessions = append(sessions, sess) | |
| 253 | - | } | |
| 247 | + | sessions := localSessionsSnapshot(d.sessions) | |
| 254 | 248 | d.mu.Unlock() | |
| 255 | 249 | ||
| 256 | 250 | for _, sess := range sessions { | |
| @@ -87,8 +87,8 @@ type attentionTarget struct { | |||||
| 87 | func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) { | 87 | func (d *Daemon) oldestOtherSessionAttention(current *session) (attentionTarget, bool) { | ||
| 88 | d.mu.Lock() | 88 | d.mu.Lock() | ||
| 89 | sessions := make([]*session, 0, len(d.sessions)) | 89 | sessions := make([]*session, 0, len(d.sessions)) | ||
| 90 | - | for _, sess := range d.sessions { | 90 | + | for _, entry := range d.sessions { |
| 91 | - | if sess != current { | 91 | + | if sess, ok := localSession(entry); ok && sess != current { |
| 92 | sessions = append(sessions, sess) | 92 | sessions = append(sessions, sess) | ||
| 93 | } | 93 | } | ||
| 94 | } | 94 | } | ||
| @@ -137,10 +137,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) { | |||||
| 137 | 137 | ||||
| 138 | func (d *Daemon) repaintAllAttachedClients() { | 138 | func (d *Daemon) repaintAllAttachedClients() { | ||
| 139 | d.mu.Lock() | 139 | d.mu.Lock() | ||
| 140 | - | sessions := make([]*session, 0, len(d.sessions)) | 140 | + | sessions := localSessionsSnapshot(d.sessions) |
| 141 | - | for _, sess := range d.sessions { | |||
| 142 | - | sessions = append(sessions, sess) | |||
| 143 | - | } | |||
| 144 | d.mu.Unlock() | 141 | d.mu.Unlock() | ||
| 145 | 142 | ||||
| 146 | for _, sess := range sessions { | 143 | for _, sess := range sessions { | ||
| @@ -247,10 +244,7 @@ func (d *Daemon) attentionAnimator(ctx context.Context) { | |||||
| 247 | 244 | ||||
| 248 | func (d *Daemon) anyAttention() bool { | 245 | func (d *Daemon) anyAttention() bool { | ||
| 249 | d.mu.Lock() | 246 | d.mu.Lock() | ||
| 250 | - | sessions := make([]*session, 0, len(d.sessions)) | 247 | + | sessions := localSessionsSnapshot(d.sessions) |
| 251 | - | for _, sess := range d.sessions { | |||
| 252 | - | sessions = append(sessions, sess) | |||
| 253 | - | } | |||
| 254 | d.mu.Unlock() | 248 | d.mu.Unlock() | ||
| 255 | 249 | ||||
| 256 | for _, sess := range sessions { | 250 | for _, sess := range sessions { | ||
M internal/usecase/daemon/attention_test.go +5 -7
| @@ -176,7 +176,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) { | |||
| 176 | 176 | sctxW, cancelW := context.WithCancel(d.serveCtx) | |
| 177 | 177 | t.Cleanup(cancelW) | |
| 178 | 178 | tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW) | |
| 179 | - | sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW} | |
| 179 | + | sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", client: acW}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}} | |
| 180 | 180 | acW.setSession(sessW) | |
| 181 | 181 | acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil) | |
| 182 | 182 | d.sessions[sessW.id] = sessW | |
| @@ -446,7 +446,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||
| 446 | 446 | sctxB, cancelB := context.WithCancel(d.serveCtx) | |
| 447 | 447 | t.Cleanup(cancelB) | |
| 448 | 448 | tbB := newTestTabWithContext(pB, sctxB, cancelB) | |
| 449 | - | sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB} | |
| 449 | + | sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", client: acB}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}} | |
| 450 | 450 | acB.setSession(sessB) | |
| 451 | 451 | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) | |
| 452 | 452 | d.sessions[sessB.id] = sessB | |
| @@ -502,10 +502,8 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) { | |||
| 502 | 502 | tab2a.attentionAt = time.Unix(9, 0) | |
| 503 | 503 | tab2b.attention = true | |
| 504 | 504 | tab2b.attentionAt = time.Unix(5, 0) | |
| 505 | - | sess2 := &session{ | |
| 506 | - | id: "other", | |
| 507 | - | name: "other", | |
| 508 | - | ctx: sctx2, | |
| 505 | + | sess2 := &session{sessionCore: sessionCore{id: "other", | |
| 506 | + | name: "other"}, ctx: sctx2, | |
| 509 | 507 | cancel: cancel2, | |
| 510 | 508 | tabs: []*tab{tab2a, tab2b}, | |
| 511 | 509 | } | |
| @@ -554,7 +552,7 @@ func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) { | |||
| 554 | 552 | tab2 := newTestTabWithContext(p2, sctx2, cancel2) | |
| 555 | 553 | tab2.attention = true | |
| 556 | 554 | tab2.attentionAt = time.Unix(9, 0) | |
| 557 | - | sess2 := &session{id: "other", name: "other", ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}} | |
| 555 | + | sess2 := &session{sessionCore: sessionCore{id: "other", name: "other"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}} | |
| 558 | 556 | d.sessions[sess2.id] = sess2 | |
| 559 | 557 | ||
| 560 | 558 | sess1.mu.Lock() | |
| @@ -176,7 +176,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) { | |||||
| 176 | sctxW, cancelW := context.WithCancel(d.serveCtx) | 176 | sctxW, cancelW := context.WithCancel(d.serveCtx) | ||
| 177 | t.Cleanup(cancelW) | 177 | t.Cleanup(cancelW) | ||
| 178 | tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW) | 178 | tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW) | ||
| 179 | - | sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW} | 179 | + | sessW := &session{sessionCore: sessionCore{id: "wedged", name: "wedged", client: acW}, ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}} |
| 180 | acW.setSession(sessW) | 180 | acW.setSession(sessW) | ||
| 181 | acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil) | 181 | acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW}, nil) | ||
| 182 | d.sessions[sessW.id] = sessW | 182 | d.sessions[sessW.id] = sessW | ||
| @@ -446,7 +446,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||||
| 446 | sctxB, cancelB := context.WithCancel(d.serveCtx) | 446 | sctxB, cancelB := context.WithCancel(d.serveCtx) | ||
| 447 | t.Cleanup(cancelB) | 447 | t.Cleanup(cancelB) | ||
| 448 | tbB := newTestTabWithContext(pB, sctxB, cancelB) | 448 | tbB := newTestTabWithContext(pB, sctxB, cancelB) | ||
| 449 | - | sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB} | 449 | + | sessB := &session{sessionCore: sessionCore{id: "sessB", name: "other", client: acB}, ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}} |
| 450 | acB.setSession(sessB) | 450 | acB.setSession(sessB) | ||
| 451 | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) | 451 | acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB}, nil) | ||
| 452 | d.sessions[sessB.id] = sessB | 452 | d.sessions[sessB.id] = sessB | ||
| @@ -502,10 +502,8 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) { | |||||
| 502 | tab2a.attentionAt = time.Unix(9, 0) | 502 | tab2a.attentionAt = time.Unix(9, 0) | ||
| 503 | tab2b.attention = true | 503 | tab2b.attention = true | ||
| 504 | tab2b.attentionAt = time.Unix(5, 0) | 504 | tab2b.attentionAt = time.Unix(5, 0) | ||
| 505 | - | sess2 := &session{ | 505 | + | sess2 := &session{sessionCore: sessionCore{id: "other", |
| 506 | - | id: "other", | 506 | + | name: "other"}, ctx: sctx2, |
| 507 | - | name: "other", | |||
| 508 | - | ctx: sctx2, | |||
| 509 | cancel: cancel2, | 507 | cancel: cancel2, | ||
| 510 | tabs: []*tab{tab2a, tab2b}, | 508 | tabs: []*tab{tab2a, tab2b}, | ||
| 511 | } | 509 | } | ||
| @@ -554,7 +552,7 @@ func TestJumpAttentionSwitchFailureReportsNotice(t *testing.T) { | |||||
| 554 | tab2 := newTestTabWithContext(p2, sctx2, cancel2) | 552 | tab2 := newTestTabWithContext(p2, sctx2, cancel2) | ||
| 555 | tab2.attention = true | 553 | tab2.attention = true | ||
| 556 | tab2.attentionAt = time.Unix(9, 0) | 554 | tab2.attentionAt = time.Unix(9, 0) | ||
| 557 | - | sess2 := &session{id: "other", name: "other", ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}} | 555 | + | sess2 := &session{sessionCore: sessionCore{id: "other", name: "other"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{tab2}} |
| 558 | d.sessions[sess2.id] = sess2 | 556 | d.sessions[sess2.id] = sess2 | ||
| 559 | 557 | ||||
| 560 | sess1.mu.Lock() | 558 | sess1.mu.Lock() | ||
M internal/usecase/daemon/bar_refresh.go +1 -5
| @@ -200,11 +200,7 @@ func (d *Daemon) refreshBarScriptsAllSessions() { | |||
| 200 | 200 | func (d *Daemon) sessionsSnapshot() []*session { | |
| 201 | 201 | d.mu.Lock() | |
| 202 | 202 | defer d.mu.Unlock() | |
| 203 | - | sessions := make([]*session, 0, len(d.sessions)) | |
| 204 | - | for _, sess := range d.sessions { | |
| 205 | - | sessions = append(sessions, sess) | |
| 206 | - | } | |
| 207 | - | return sessions | |
| 203 | + | return localSessionsSnapshot(d.sessions) | |
| 208 | 204 | } | |
| 209 | 205 | ||
| 210 | 206 | func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | |
| @@ -200,11 +200,7 @@ func (d *Daemon) refreshBarScriptsAllSessions() { | |||||
| 200 | func (d *Daemon) sessionsSnapshot() []*session { | 200 | func (d *Daemon) sessionsSnapshot() []*session { | ||
| 201 | d.mu.Lock() | 201 | d.mu.Lock() | ||
| 202 | defer d.mu.Unlock() | 202 | defer d.mu.Unlock() | ||
| 203 | - | sessions := make([]*session, 0, len(d.sessions)) | 203 | + | return localSessionsSnapshot(d.sessions) |
| 204 | - | for _, sess := range d.sessions { | |||
| 205 | - | sessions = append(sessions, sess) | |||
| 206 | - | } | |||
| 207 | - | return sessions | |||
| 208 | } | 204 | } | ||
| 209 | 205 | ||||
| 210 | func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | 206 | func (d *Daemon) clearBarScriptsForSession(id domain.SessionID) { | ||
M internal/usecase/daemon/bar_refresh_test.go +2 -2
| @@ -220,7 +220,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) { | |||
| 220 | 220 | d := newBarRefreshTestDaemon(r, 60*time.Second) | |
| 221 | 221 | sess := newBarRefreshTestSession() | |
| 222 | 222 | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | |
| 223 | - | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |
| 223 | + | d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess} | |
| 224 | 224 | ||
| 225 | 225 | // Verifies refreshBarScriptsAllSessions iterates every live session and | |
| 226 | 226 | // dispatches a run for each. This does not exercise force specifically: | |
| @@ -587,7 +587,7 @@ func newBarRefreshTestSession() *session { | |||
| 587 | 587 | inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22}) | |
| 588 | 588 | active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22}) | |
| 589 | 589 | active.tree.Focus = layout.PaneID("pane-1") | |
| 590 | - | return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()} | |
| 590 | + | return &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()} | |
| 591 | 591 | } | |
| 592 | 592 | ||
| 593 | 593 | func waitBarRefreshIdle(t *testing.T, d *Daemon) { | |
| @@ -220,7 +220,7 @@ func TestRefreshBarScriptsAllSessionsForcesRun(t *testing.T) { | |||||
| 220 | d := newBarRefreshTestDaemon(r, 60*time.Second) | 220 | d := newBarRefreshTestDaemon(r, 60*time.Second) | ||
| 221 | sess := newBarRefreshTestSession() | 221 | sess := newBarRefreshTestSession() | ||
| 222 | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | 222 | sess.client = &attachedClient{size: domain.Size{Cols: 80, Rows: 24}} | ||
| 223 | - | d.sessions = map[domain.SessionID]*session{sess.id: sess} | 223 | + | d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess} |
| 224 | 224 | ||||
| 225 | // Verifies refreshBarScriptsAllSessions iterates every live session and | 225 | // Verifies refreshBarScriptsAllSessions iterates every live session and | ||
| 226 | // dispatches a run for each. This does not exercise force specifically: | 226 | // dispatches a run for each. This does not exercise force specifically: | ||
| @@ -587,7 +587,7 @@ func newBarRefreshTestSession() *session { | |||||
| 587 | inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22}) | 587 | inactive := newTabWithStableID("tab-inactive", "pane-inactive", nil, domain.Size{Cols: 80, Rows: 22}) | ||
| 588 | active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22}) | 588 | active := newTabWithStableID("tab-active", "pane-active", nil, domain.Size{Cols: 80, Rows: 22}) | ||
| 589 | active.tree.Focus = layout.PaneID("pane-1") | 589 | active.tree.Focus = layout.PaneID("pane-1") | ||
| 590 | - | return &session{id: "s", name: "s", cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()} | 590 | + | return &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/tmp", tabs: []*tab{inactive, active}, active: 0, ctx: context.Background()} |
| 591 | } | 591 | } | ||
| 592 | 592 | ||||
| 593 | func waitBarRefreshIdle(t *testing.T, d *Daemon) { | 593 | func waitBarRefreshIdle(t *testing.T, d *Daemon) { | ||
M internal/usecase/daemon/client.go +26 -8
| @@ -70,7 +70,7 @@ type attachedClient struct { | |||
| 70 | 70 | initialSnatchedAttempt *initialSnatchedPanelAttempt | |
| 71 | 71 | size domain.Size | |
| 72 | 72 | keys *keys.Router | |
| 73 | - | sess Guarded[*session] | |
| 73 | + | sess Guarded[attachmentSession] | |
| 74 | 74 | mouseScan mouse.Scanner | |
| 75 | 75 | themeMu sync.Mutex | |
| 76 | 76 | clientTheme themeui.Theme | |
| @@ -79,7 +79,7 @@ type attachedClient struct { | |||
| 79 | 79 | renderStages renderStageHooks // optional render and handoff observability hooks | |
| 80 | 80 | // previousSession is guarded independently. It is retained through temporary | |
| 81 | 81 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 82 | - | previousSession Guarded[*session] | |
| 82 | + | previousSession Guarded[attachmentSession] | |
| 83 | 83 | // snatchedInputMu serializes the restricted input parser with its delayed | |
| 84 | 84 | // standalone-ESC callback. It is independent of routing and transport locks; | |
| 85 | 85 | // role activation and terminal close clear it only after releasing those locks. | |
| @@ -135,9 +135,17 @@ func (ac *attachedClient) initOverlays() { | |||
| 135 | 135 | ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) }) | |
| 136 | 136 | } | |
| 137 | 137 | ||
| 138 | - | func (ac *attachedClient) currentSession() *session { return ac.sess.Get() } | |
| 138 | + | func (ac *attachedClient) currentAttachmentSession() attachmentSession { return ac.sess.Get() } | |
| 139 | 139 | ||
| 140 | - | func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) } | |
| 140 | + | // currentSession is the explicit local-only narrowing used until proxy-aware | |
| 141 | + | // consumers are introduced. Local PTY/tab paths must treat non-local entries as | |
| 142 | + | // unavailable rather than infer proxy behavior. | |
| 143 | + | func (ac *attachedClient) currentSession() *session { | |
| 144 | + | sess, _ := localSession(ac.currentAttachmentSession()) | |
| 145 | + | return sess | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (ac *attachedClient) setSession(sess attachmentSession) { ac.sess.Set(sess) } | |
| 141 | 149 | ||
| 142 | 150 | func (ac *attachedClient) clearPreviousSession() { | |
| 143 | 151 | if ac != nil { | |
| @@ -610,7 +618,11 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool { | |||
| 610 | 618 | token.ac.captureFrames = nil | |
| 611 | 619 | token.ac.sendMu.Unlock() | |
| 612 | 620 | } | |
| 613 | - | d.firstPaintWithLease(token.sess, token.ac, token.ac.size, token.lease) | |
| 621 | + | sess, ok := localSession(token.sess) | |
| 622 | + | if !ok { | |
| 623 | + | return false | |
| 624 | + | } | |
| 625 | + | d.firstPaintWithLease(sess, token.ac, token.ac.size, token.lease) | |
| 614 | 626 | return true | |
| 615 | 627 | } | |
| 616 | 628 | ||
| @@ -690,13 +702,14 @@ func (d *Daemon) applyThemeForRole(token attachmentRoleToken, msg ports.Theme) b | |||
| 690 | 702 | } | |
| 691 | 703 | clientTheme := themeFromMessage(msg) | |
| 692 | 704 | token.ac.setClientTheme(clientTheme) | |
| 693 | - | if !token.activeEffect() || !d.applyHostTheme(token.sess, token.ac, clientTheme, false) { | |
| 705 | + | sess, ok := localSession(token.sess) | |
| 706 | + | if !ok || !token.activeEffect() || !d.applyHostTheme(sess, token.ac, clientTheme, false) { | |
| 694 | 707 | return false | |
| 695 | 708 | } | |
| 696 | 709 | if !token.activeEffect() { | |
| 697 | 710 | return false | |
| 698 | 711 | } | |
| 699 | - | if rc := token.sess.renderCoordinator(); rc != nil { | |
| 712 | + | if rc := token.sess.core().coordinator.Load(); rc != nil { | |
| 700 | 713 | return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) | |
| 701 | 714 | } | |
| 702 | 715 | return false | |
| @@ -739,7 +752,12 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por | |||
| 739 | 752 | d.notices.routingMu.Unlock() | |
| 740 | 753 | return | |
| 741 | 754 | } | |
| 742 | - | repaint := d.mutateClientNotice(token.sess, token.ac, notice) | |
| 755 | + | sess, ok := localSession(token.sess) | |
| 756 | + | if !ok { | |
| 757 | + | d.notices.routingMu.Unlock() | |
| 758 | + | return | |
| 759 | + | } | |
| 760 | + | repaint := d.mutateClientNotice(sess, token.ac, notice) | |
| 743 | 761 | d.notices.routingMu.Unlock() | |
| 744 | 762 | if repaint && token.activeEffect() { | |
| 745 | 763 | d.repaintForNotice(token.ac) | |
| @@ -70,7 +70,7 @@ type attachedClient struct { | |||||
| 70 | initialSnatchedAttempt *initialSnatchedPanelAttempt | 70 | initialSnatchedAttempt *initialSnatchedPanelAttempt | ||
| 71 | size domain.Size | 71 | size domain.Size | ||
| 72 | keys *keys.Router | 72 | keys *keys.Router | ||
| 73 | - | sess Guarded[*session] | 73 | + | sess Guarded[attachmentSession] |
| 74 | mouseScan mouse.Scanner | 74 | mouseScan mouse.Scanner | ||
| 75 | themeMu sync.Mutex | 75 | themeMu sync.Mutex | ||
| 76 | clientTheme themeui.Theme | 76 | clientTheme themeui.Theme | ||
| @@ -79,7 +79,7 @@ type attachedClient struct { | |||||
| 79 | renderStages renderStageHooks // optional render and handoff observability hooks | 79 | renderStages renderStageHooks // optional render and handoff observability hooks | ||
| 80 | // previousSession is guarded independently. It is retained through temporary | 80 | // previousSession is guarded independently. It is retained through temporary | ||
| 81 | // setSession(nil) hand-offs and cleared only on terminal teardown. | 81 | // setSession(nil) hand-offs and cleared only on terminal teardown. | ||
| 82 | - | previousSession Guarded[*session] | 82 | + | previousSession Guarded[attachmentSession] |
| 83 | // snatchedInputMu serializes the restricted input parser with its delayed | 83 | // snatchedInputMu serializes the restricted input parser with its delayed | ||
| 84 | // standalone-ESC callback. It is independent of routing and transport locks; | 84 | // standalone-ESC callback. It is independent of routing and transport locks; | ||
| 85 | // role activation and terminal close clear it only after releasing those locks. | 85 | // role activation and terminal close clear it only after releasing those locks. | ||
| @@ -135,9 +135,17 @@ func (ac *attachedClient) initOverlays() { | |||||
| 135 | ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) }) | 135 | ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) }) | ||
| 136 | } | 136 | } | ||
| 137 | 137 | ||||
| 138 | - | func (ac *attachedClient) currentSession() *session { return ac.sess.Get() } | 138 | + | func (ac *attachedClient) currentAttachmentSession() attachmentSession { return ac.sess.Get() } |
| 139 | 139 | ||||
| 140 | - | func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) } | 140 | + | // currentSession is the explicit local-only narrowing used until proxy-aware |
| 141 | + | // consumers are introduced. Local PTY/tab paths must treat non-local entries as | |||
| 142 | + | // unavailable rather than infer proxy behavior. | |||
| 143 | + | func (ac *attachedClient) currentSession() *session { | |||
| 144 | + | sess, _ := localSession(ac.currentAttachmentSession()) | |||
| 145 | + | return sess | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (ac *attachedClient) setSession(sess attachmentSession) { ac.sess.Set(sess) } | |||
| 141 | 149 | ||||
| 142 | func (ac *attachedClient) clearPreviousSession() { | 150 | func (ac *attachedClient) clearPreviousSession() { | ||
| 143 | if ac != nil { | 151 | if ac != nil { | ||
| @@ -610,7 +618,11 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool { | |||||
| 610 | token.ac.captureFrames = nil | 618 | token.ac.captureFrames = nil | ||
| 611 | token.ac.sendMu.Unlock() | 619 | token.ac.sendMu.Unlock() | ||
| 612 | } | 620 | } | ||
| 613 | - | d.firstPaintWithLease(token.sess, token.ac, token.ac.size, token.lease) | 621 | + | sess, ok := localSession(token.sess) |
| 622 | + | if !ok { | |||
| 623 | + | return false | |||
| 624 | + | } | |||
| 625 | + | d.firstPaintWithLease(sess, token.ac, token.ac.size, token.lease) | |||
| 614 | return true | 626 | return true | ||
| 615 | } | 627 | } | ||
| 616 | 628 | ||||
| @@ -690,13 +702,14 @@ func (d *Daemon) applyThemeForRole(token attachmentRoleToken, msg ports.Theme) b | |||||
| 690 | } | 702 | } | ||
| 691 | clientTheme := themeFromMessage(msg) | 703 | clientTheme := themeFromMessage(msg) | ||
| 692 | token.ac.setClientTheme(clientTheme) | 704 | token.ac.setClientTheme(clientTheme) | ||
| 693 | - | if !token.activeEffect() || !d.applyHostTheme(token.sess, token.ac, clientTheme, false) { | 705 | + | sess, ok := localSession(token.sess) |
| 706 | + | if !ok || !token.activeEffect() || !d.applyHostTheme(sess, token.ac, clientTheme, false) { | |||
| 694 | return false | 707 | return false | ||
| 695 | } | 708 | } | ||
| 696 | if !token.activeEffect() { | 709 | if !token.activeEffect() { | ||
| 697 | return false | 710 | return false | ||
| 698 | } | 711 | } | ||
| 699 | - | if rc := token.sess.renderCoordinator(); rc != nil { | 712 | + | if rc := token.sess.core().coordinator.Load(); rc != nil { |
| 700 | return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) | 713 | return rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) | ||
| 701 | } | 714 | } | ||
| 702 | return false | 715 | return false | ||
| @@ -739,7 +752,12 @@ func (d *Daemon) handleClientNoticeForRole(token attachmentRoleToken, notice por | |||||
| 739 | d.notices.routingMu.Unlock() | 752 | d.notices.routingMu.Unlock() | ||
| 740 | return | 753 | return | ||
| 741 | } | 754 | } | ||
| 742 | - | repaint := d.mutateClientNotice(token.sess, token.ac, notice) | 755 | + | sess, ok := localSession(token.sess) |
| 756 | + | if !ok { | |||
| 757 | + | d.notices.routingMu.Unlock() | |||
| 758 | + | return | |||
| 759 | + | } | |||
| 760 | + | repaint := d.mutateClientNotice(sess, token.ac, notice) | |||
| 743 | d.notices.routingMu.Unlock() | 761 | d.notices.routingMu.Unlock() | ||
| 744 | if repaint && token.activeEffect() { | 762 | if repaint && token.activeEffect() { | ||
| 745 | d.repaintForNotice(token.ac) | 763 | d.repaintForNotice(token.ac) | ||
M internal/usecase/daemon/client_frame_routing.go +4 -2
| @@ -77,7 +77,9 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram | |||
| 77 | 77 | } | |
| 78 | 78 | case ports.MsgResize: | |
| 79 | 79 | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() { | |
| 80 | - | d.requestTransactionalResizeForLease(token.sess, token.ac, token.lease, rz.Size, false) | |
| 80 | + | if sess, ok := localSession(token.sess); ok { | |
| 81 | + | d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false) | |
| 82 | + | } | |
| 81 | 83 | } | |
| 82 | 84 | case ports.MsgTheme: | |
| 83 | 85 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | |
| @@ -123,7 +125,7 @@ func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool { | |||
| 123 | 125 | ac.sendMu.Lock() | |
| 124 | 126 | ac.output.ack(state) | |
| 125 | 127 | ac.sendMu.Unlock() | |
| 126 | - | if rc := token.sess.renderCoordinator(); rc != nil { | |
| 128 | + | if rc := token.sess.core().coordinator.Load(); rc != nil { | |
| 127 | 129 | rc.notifyAckForLease(token.lease) | |
| 128 | 130 | } | |
| 129 | 131 | return true | |
| @@ -77,7 +77,9 @@ func (d *Daemon) handleActiveClientFrame(token attachmentRoleToken, f ports.Fram | |||||
| 77 | } | 77 | } | ||
| 78 | case ports.MsgResize: | 78 | case ports.MsgResize: | ||
| 79 | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() { | 79 | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.activeEffect() { | ||
| 80 | - | d.requestTransactionalResizeForLease(token.sess, token.ac, token.lease, rz.Size, false) | 80 | + | if sess, ok := localSession(token.sess); ok { |
| 81 | + | d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false) | |||
| 82 | + | } | |||
| 81 | } | 83 | } | ||
| 82 | case ports.MsgTheme: | 84 | case ports.MsgTheme: | ||
| 83 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | 85 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | ||
| @@ -123,7 +125,7 @@ func (d *Daemon) ackActiveOutput(token attachmentRoleToken, state uint64) bool { | |||||
| 123 | ac.sendMu.Lock() | 125 | ac.sendMu.Lock() | ||
| 124 | ac.output.ack(state) | 126 | ac.output.ack(state) | ||
| 125 | ac.sendMu.Unlock() | 127 | ac.sendMu.Unlock() | ||
| 126 | - | if rc := token.sess.renderCoordinator(); rc != nil { | 128 | + | if rc := token.sess.core().coordinator.Load(); rc != nil { |
| 127 | rc.notifyAckForLease(token.lease) | 129 | rc.notifyAckForLease(token.lease) | ||
| 128 | } | 130 | } | ||
| 129 | return true | 131 | return true | ||
M internal/usecase/daemon/client_transport_errors.go +10 -8
| @@ -29,20 +29,21 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 31 | func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool { | |
| 32 | - | if token.effect == nil { | |
| 32 | + | sess, ok := localSession(token.sess) | |
| 33 | + | if !ok || token.effect == nil { | |
| 33 | 34 | return false | |
| 34 | 35 | } | |
| 35 | 36 | token.effect.bindActionEnd(d, "detach") | |
| 36 | 37 | token.effect.End() | |
| 37 | 38 | var parkingToken uint64 | |
| 38 | 39 | if !explicit { | |
| 39 | - | parkingToken = d.markParkingInFlight(token.sess, token.ac) | |
| 40 | + | parkingToken = d.markParkingInFlight(sess, token.ac) | |
| 40 | 41 | } | |
| 41 | 42 | if !d.detachIfRoleCurrent(token) { | |
| 42 | - | d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken) | |
| 43 | + | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) | |
| 43 | 44 | return false | |
| 44 | 45 | } | |
| 45 | - | d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit) | |
| 46 | + | d.finishClientGone(sess, token.ac, token.transport.transport, explicit) | |
| 46 | 47 | return true | |
| 47 | 48 | } | |
| 48 | 49 | ||
| @@ -111,15 +112,16 @@ func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.T | |||
| 111 | 112 | } | |
| 112 | 113 | ||
| 113 | 114 | func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) { | |
| 114 | - | if failed != token.transport.transport { | |
| 115 | + | sess, ok := localSession(token.sess) | |
| 116 | + | if !ok || failed != token.transport.transport { | |
| 115 | 117 | return | |
| 116 | 118 | } | |
| 117 | - | parkingToken := d.markParkingInFlight(token.sess, token.ac) | |
| 119 | + | parkingToken := d.markParkingInFlight(sess, token.ac) | |
| 118 | 120 | if d.detachIfRoleCurrentUntil(token, done) { | |
| 119 | - | d.finishSendErrorDetach(token.sess, token.ac, failed) | |
| 121 | + | d.finishSendErrorDetach(sess, token.ac, failed) | |
| 120 | 122 | return | |
| 121 | 123 | } | |
| 122 | - | d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken) | |
| 124 | + | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) | |
| 123 | 125 | } | |
| 124 | 126 | ||
| 125 | 127 | // reserveRoleSendErrorCleanup accounts for cleanup before End releases the | |
| @@ -29,20 +29,21 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool { | 31 | func (d *Daemon) clientGoneForRole(token attachmentRoleToken, explicit bool) bool { | ||
| 32 | - | if token.effect == nil { | 32 | + | sess, ok := localSession(token.sess) |
| 33 | + | if !ok || token.effect == nil { | |||
| 33 | return false | 34 | return false | ||
| 34 | } | 35 | } | ||
| 35 | token.effect.bindActionEnd(d, "detach") | 36 | token.effect.bindActionEnd(d, "detach") | ||
| 36 | token.effect.End() | 37 | token.effect.End() | ||
| 37 | var parkingToken uint64 | 38 | var parkingToken uint64 | ||
| 38 | if !explicit { | 39 | if !explicit { | ||
| 39 | - | parkingToken = d.markParkingInFlight(token.sess, token.ac) | 40 | + | parkingToken = d.markParkingInFlight(sess, token.ac) |
| 40 | } | 41 | } | ||
| 41 | if !d.detachIfRoleCurrent(token) { | 42 | if !d.detachIfRoleCurrent(token) { | ||
| 42 | - | d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken) | 43 | + | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) |
| 43 | return false | 44 | return false | ||
| 44 | } | 45 | } | ||
| 45 | - | d.finishClientGone(token.sess, token.ac, token.transport.transport, explicit) | 46 | + | d.finishClientGone(sess, token.ac, token.transport.transport, explicit) |
| 46 | return true | 47 | return true | ||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| @@ -111,15 +112,16 @@ func (d *Daemon) detachOnRoleSendError(token attachmentRoleToken, failed ports.T | |||||
| 111 | } | 112 | } | ||
| 112 | 113 | ||||
| 113 | func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) { | 114 | func (d *Daemon) detachOnRoleSendErrorUntil(token attachmentRoleToken, failed ports.Transport, done func() <-chan struct{}) { | ||
| 114 | - | if failed != token.transport.transport { | 115 | + | sess, ok := localSession(token.sess) |
| 116 | + | if !ok || failed != token.transport.transport { | |||
| 115 | return | 117 | return | ||
| 116 | } | 118 | } | ||
| 117 | - | parkingToken := d.markParkingInFlight(token.sess, token.ac) | 119 | + | parkingToken := d.markParkingInFlight(sess, token.ac) |
| 118 | if d.detachIfRoleCurrentUntil(token, done) { | 120 | if d.detachIfRoleCurrentUntil(token, done) { | ||
| 119 | - | d.finishSendErrorDetach(token.sess, token.ac, failed) | 121 | + | d.finishSendErrorDetach(sess, token.ac, failed) |
| 120 | return | 122 | return | ||
| 121 | } | 123 | } | ||
| 122 | - | d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken) | 124 | + | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) |
| 123 | } | 125 | } | ||
| 124 | 126 | ||||
| 125 | // reserveRoleSendErrorCleanup accounts for cleanup before End releases the | 127 | // reserveRoleSendErrorCleanup accounts for cleanup before End releases the | ||
M internal/usecase/daemon/clipboard.go +12 -6
| @@ -89,14 +89,19 @@ func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports. | |||
| 89 | 89 | if err != nil { | |
| 90 | 90 | return "", err | |
| 91 | 91 | } | |
| 92 | - | token.sess.mu.Lock() | |
| 92 | + | sess, ok := localSession(token.sess) | |
| 93 | + | if !ok { | |
| 94 | + | _ = os.Remove(path) | |
| 95 | + | return "", errAttachmentTransition | |
| 96 | + | } | |
| 97 | + | sess.mu.Lock() | |
| 93 | 98 | if !token.activeEffectSessionLocked() { | |
| 94 | - | token.sess.mu.Unlock() | |
| 99 | + | sess.mu.Unlock() | |
| 95 | 100 | _ = os.Remove(path) | |
| 96 | 101 | return "", errAttachmentTransition | |
| 97 | 102 | } | |
| 98 | - | token.sess.clipFiles = append(token.sess.clipFiles, path) | |
| 99 | - | token.sess.mu.Unlock() | |
| 103 | + | sess.clipFiles = append(sess.clipFiles, path) | |
| 104 | + | sess.mu.Unlock() | |
| 100 | 105 | return path, nil | |
| 101 | 106 | } | |
| 102 | 107 | ||
| @@ -130,10 +135,11 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) { | |||
| 130 | 135 | } | |
| 131 | 136 | ||
| 132 | 137 | func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) { | |
| 133 | - | if !token.activeEffect() { | |
| 138 | + | sess, ok := localSession(token.sess) | |
| 139 | + | if !ok || !token.activeEffect() { | |
| 134 | 140 | return | |
| 135 | 141 | } | |
| 136 | - | d.injectClipboardPathToTarget(token.sess, path, &token) | |
| 142 | + | d.injectClipboardPathToTarget(sess, path, &token) | |
| 137 | 143 | } | |
| 138 | 144 | ||
| 139 | 145 | func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) { | |
| @@ -89,14 +89,19 @@ func (d *Daemon) writeClipboardImageForRole(token attachmentRoleToken, ip ports. | |||||
| 89 | if err != nil { | 89 | if err != nil { | ||
| 90 | return "", err | 90 | return "", err | ||
| 91 | } | 91 | } | ||
| 92 | - | token.sess.mu.Lock() | 92 | + | sess, ok := localSession(token.sess) |
| 93 | + | if !ok { | |||
| 94 | + | _ = os.Remove(path) | |||
| 95 | + | return "", errAttachmentTransition | |||
| 96 | + | } | |||
| 97 | + | sess.mu.Lock() | |||
| 93 | if !token.activeEffectSessionLocked() { | 98 | if !token.activeEffectSessionLocked() { | ||
| 94 | - | token.sess.mu.Unlock() | 99 | + | sess.mu.Unlock() |
| 95 | _ = os.Remove(path) | 100 | _ = os.Remove(path) | ||
| 96 | return "", errAttachmentTransition | 101 | return "", errAttachmentTransition | ||
| 97 | } | 102 | } | ||
| 98 | - | token.sess.clipFiles = append(token.sess.clipFiles, path) | 103 | + | sess.clipFiles = append(sess.clipFiles, path) |
| 99 | - | token.sess.mu.Unlock() | 104 | + | sess.mu.Unlock() |
| 100 | return path, nil | 105 | return path, nil | ||
| 101 | } | 106 | } | ||
| 102 | 107 | ||||
| @@ -130,10 +135,11 @@ func (d *Daemon) injectClipboardPath(sess *session, path string) { | |||||
| 130 | } | 135 | } | ||
| 131 | 136 | ||||
| 132 | func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) { | 137 | func (d *Daemon) injectClipboardPathForRole(token attachmentRoleToken, path string) { | ||
| 133 | - | if !token.activeEffect() { | 138 | + | sess, ok := localSession(token.sess) |
| 139 | + | if !ok || !token.activeEffect() { | |||
| 134 | return | 140 | return | ||
| 135 | } | 141 | } | ||
| 136 | - | d.injectClipboardPathToTarget(token.sess, path, &token) | 142 | + | d.injectClipboardPathToTarget(sess, path, &token) |
| 137 | } | 143 | } | ||
| 138 | 144 | ||||
| 139 | func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) { | 145 | func (d *Daemon) injectClipboardPathToTarget(sess *session, path string, token *attachmentRoleToken) { | ||
M internal/usecase/daemon/clipboard_test.go +7 -7
| @@ -231,7 +231,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) { | |||
| 231 | 231 | win := newTestTabWithContext(p, sctx, cancel) | |
| 232 | 232 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 233 | 233 | ac.initOverlays() | |
| 234 | - | sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 234 | + | sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 235 | 235 | publishTiledPaneOwners(sess, win) | |
| 236 | 236 | ac.setSession(sess) | |
| 237 | 237 | d.sessions[sess.id] = sess | |
| @@ -265,7 +265,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) { | |||
| 265 | 265 | win := newTestTabWithContext(p, sctx, cancel) | |
| 266 | 266 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 267 | 267 | ac.initOverlays() | |
| 268 | - | sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 268 | + | sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 269 | 269 | publishTiledPaneOwners(sess, win) | |
| 270 | 270 | ac.setSession(sess) | |
| 271 | 271 | d.sessions[sess.id] = sess | |
| @@ -290,7 +290,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) { | |||
| 290 | 290 | win := newTestTabWithContext(p, sctx, cancel) | |
| 291 | 291 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 292 | 292 | ac.initOverlays() | |
| 293 | - | sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 293 | + | sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 294 | 294 | publishTiledPaneOwners(sess, win) | |
| 295 | 295 | ac.setSession(sess) | |
| 296 | 296 | d.sessions[sess.id] = sess | |
| @@ -312,7 +312,7 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) { | |||
| 312 | 312 | sctx, cancel := context.WithCancel(context.Background()) | |
| 313 | 313 | defer cancel() | |
| 314 | 314 | win := newTestTabWithContext(p, sctx, cancel) | |
| 315 | - | sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 315 | + | sess := &session{sessionCore: sessionCore{id: "clip-noclient", name: "clip-noclient"}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 316 | 316 | publishTiledPaneOwners(sess, win) | |
| 317 | 317 | d.sessions[sess.id] = sess | |
| 318 | 318 | ||
| @@ -356,7 +356,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) { | |||
| 356 | 356 | ||
| 357 | 357 | sourceTab := sess.activeTab() | |
| 358 | 358 | p := sourceTab.focusedPane() | |
| 359 | - | destination := &session{id: "destination", name: "destination"} | |
| 359 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}} | |
| 360 | 360 | publishPaneOwner(p, destination, &tab{}, 0) | |
| 361 | 361 | publishPaneOwner(p, sess, sourceTab, 0) | |
| 362 | 362 | ||
| @@ -424,7 +424,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing | |||
| 424 | 424 | ||
| 425 | 425 | sourceTab := sess.activeTab() | |
| 426 | 426 | p := sourceTab.focusedPane() | |
| 427 | - | destination := &session{id: "destination", name: "destination"} | |
| 427 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}} | |
| 428 | 428 | publishPaneOwner(p, destination, &tab{}, 0) | |
| 429 | 429 | publishPaneOwner(p, sess, sourceTab, 0) | |
| 430 | 430 | ac.sendMu.Unlock() | |
| @@ -455,7 +455,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |||
| 455 | 455 | ||
| 456 | 456 | sourceTab := sess.activeTab() | |
| 457 | 457 | p := sourceTab.focusedPane() | |
| 458 | - | destination := &session{id: "destination", name: "destination"} | |
| 458 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}} | |
| 459 | 459 | publishPaneOwner(p, destination, &tab{}, 0) | |
| 460 | 460 | publishPaneOwner(p, sess, sourceTab, 0) | |
| 461 | 461 | close(oldTransport.release) | |
| @@ -231,7 +231,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) { | |||||
| 231 | win := newTestTabWithContext(p, sctx, cancel) | 231 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 232 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 232 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 233 | ac.initOverlays() | 233 | ac.initOverlays() | ||
| 234 | - | sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 234 | + | sess := &session{sessionCore: sessionCore{id: "clip", name: "clip", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} |
| 235 | publishTiledPaneOwners(sess, win) | 235 | publishTiledPaneOwners(sess, win) | ||
| 236 | ac.setSession(sess) | 236 | ac.setSession(sess) | ||
| 237 | d.sessions[sess.id] = sess | 237 | d.sessions[sess.id] = sess | ||
| @@ -265,7 +265,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) { | |||||
| 265 | win := newTestTabWithContext(p, sctx, cancel) | 265 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 266 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 266 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 267 | ac.initOverlays() | 267 | ac.initOverlays() | ||
| 268 | - | sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 268 | + | sess := &session{sessionCore: sessionCore{id: "clip-big", name: "clip-big", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} |
| 269 | publishTiledPaneOwners(sess, win) | 269 | publishTiledPaneOwners(sess, win) | ||
| 270 | ac.setSession(sess) | 270 | ac.setSession(sess) | ||
| 271 | d.sessions[sess.id] = sess | 271 | d.sessions[sess.id] = sess | ||
| @@ -290,7 +290,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) { | |||||
| 290 | win := newTestTabWithContext(p, sctx, cancel) | 290 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 291 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 291 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 292 | ac.initOverlays() | 292 | ac.initOverlays() | ||
| 293 | - | sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 293 | + | sess := &session{sessionCore: sessionCore{id: "clip-bad", name: "clip-bad", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} |
| 294 | publishTiledPaneOwners(sess, win) | 294 | publishTiledPaneOwners(sess, win) | ||
| 295 | ac.setSession(sess) | 295 | ac.setSession(sess) | ||
| 296 | d.sessions[sess.id] = sess | 296 | d.sessions[sess.id] = sess | ||
| @@ -312,7 +312,7 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) { | |||||
| 312 | sctx, cancel := context.WithCancel(context.Background()) | 312 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 313 | defer cancel() | 313 | defer cancel() | ||
| 314 | win := newTestTabWithContext(p, sctx, cancel) | 314 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 315 | - | sess := &session{id: "clip-noclient", name: "clip-noclient", tabs: []*tab{win}, ctx: sctx, cancel: cancel} | 315 | + | sess := &session{sessionCore: sessionCore{id: "clip-noclient", name: "clip-noclient"}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} |
| 316 | publishTiledPaneOwners(sess, win) | 316 | publishTiledPaneOwners(sess, win) | ||
| 317 | d.sessions[sess.id] = sess | 317 | d.sessions[sess.id] = sess | ||
| 318 | 318 | ||||
| @@ -356,7 +356,7 @@ func TestQueuedClipboardAfterPaneMoveDoesNotSendToFormerOwner(t *testing.T) { | |||||
| 356 | 356 | ||||
| 357 | sourceTab := sess.activeTab() | 357 | sourceTab := sess.activeTab() | ||
| 358 | p := sourceTab.focusedPane() | 358 | p := sourceTab.focusedPane() | ||
| 359 | - | destination := &session{id: "destination", name: "destination"} | 359 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}} |
| 360 | publishPaneOwner(p, destination, &tab{}, 0) | 360 | publishPaneOwner(p, destination, &tab{}, 0) | ||
| 361 | publishPaneOwner(p, sess, sourceTab, 0) | 361 | publishPaneOwner(p, sess, sourceTab, 0) | ||
| 362 | 362 | ||||
| @@ -424,7 +424,7 @@ func TestQueuedClipboardRevalidatesOwnerAfterWaitingForClientSendLock(t *testing | |||||
| 424 | 424 | ||||
| 425 | sourceTab := sess.activeTab() | 425 | sourceTab := sess.activeTab() | ||
| 426 | p := sourceTab.focusedPane() | 426 | p := sourceTab.focusedPane() | ||
| 427 | - | destination := &session{id: "destination", name: "destination"} | 427 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}} |
| 428 | publishPaneOwner(p, destination, &tab{}, 0) | 428 | publishPaneOwner(p, destination, &tab{}, 0) | ||
| 429 | publishPaneOwner(p, sess, sourceTab, 0) | 429 | publishPaneOwner(p, sess, sourceTab, 0) | ||
| 430 | ac.sendMu.Unlock() | 430 | ac.sendMu.Unlock() | ||
| @@ -455,7 +455,7 @@ func TestClipboardSendErrorAfterPaneMoveDoesNotDetachFormerOwner(t *testing.T) { | |||||
| 455 | 455 | ||||
| 456 | sourceTab := sess.activeTab() | 456 | sourceTab := sess.activeTab() | ||
| 457 | p := sourceTab.focusedPane() | 457 | p := sourceTab.focusedPane() | ||
| 458 | - | destination := &session{id: "destination", name: "destination"} | 458 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}} |
| 459 | publishPaneOwner(p, destination, &tab{}, 0) | 459 | publishPaneOwner(p, destination, &tab{}, 0) | ||
| 460 | publishPaneOwner(p, sess, sourceTab, 0) | 460 | publishPaneOwner(p, sess, sourceTab, 0) | ||
| 461 | close(oldTransport.release) | 461 | close(oldTransport.release) | ||
M internal/usecase/daemon/config.go +1 -4
| @@ -336,10 +336,7 @@ func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme { | |||
| 336 | 336 | ||
| 337 | 337 | func (d *Daemon) reapplyThemeAllSessions() { | |
| 338 | 338 | d.mu.Lock() | |
| 339 | - | sessions := make([]*session, 0, len(d.sessions)) | |
| 340 | - | for _, sess := range d.sessions { | |
| 341 | - | sessions = append(sessions, sess) | |
| 342 | - | } | |
| 339 | + | sessions := localSessionsSnapshot(d.sessions) | |
| 343 | 340 | d.mu.Unlock() | |
| 344 | 341 | ||
| 345 | 342 | for _, sess := range sessions { | |
| @@ -336,10 +336,7 @@ func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme { | |||||
| 336 | 336 | ||||
| 337 | func (d *Daemon) reapplyThemeAllSessions() { | 337 | func (d *Daemon) reapplyThemeAllSessions() { | ||
| 338 | d.mu.Lock() | 338 | d.mu.Lock() | ||
| 339 | - | sessions := make([]*session, 0, len(d.sessions)) | 339 | + | sessions := localSessionsSnapshot(d.sessions) |
| 340 | - | for _, sess := range d.sessions { | |||
| 341 | - | sessions = append(sessions, sess) | |||
| 342 | - | } | |||
| 343 | d.mu.Unlock() | 340 | d.mu.Unlock() | ||
| 344 | 341 | ||||
| 345 | for _, sess := range sessions { | 342 | for _, sess := range sessions { | ||
M internal/usecase/daemon/control.go +9 -13
| @@ -122,8 +122,9 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u | |||
| 122 | 122 | return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together" | |
| 123 | 123 | } | |
| 124 | 124 | var match *session | |
| 125 | - | for _, sess := range d.sessions { | |
| 126 | - | if !sess.containsStableIDs(request.TargetTab, request.TargetPane) { | |
| 125 | + | for _, entry := range d.sessions { | |
| 126 | + | sess, ok := localSession(entry) | |
| 127 | + | if !ok || !sess.containsStableIDs(request.TargetTab, request.TargetPane) { | |
| 127 | 128 | continue | |
| 128 | 129 | } | |
| 129 | 130 | if match != nil { | |
| @@ -136,13 +137,14 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u | |||
| 136 | 137 | } | |
| 137 | 138 | return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane" | |
| 138 | 139 | } | |
| 139 | - | if len(d.sessions) == 0 { | |
| 140 | + | locals := localSessionsSnapshot(d.sessions) | |
| 141 | + | if len(locals) == 0 { | |
| 140 | 142 | return nil, ports.ErrNoSuchTarget, "no live sessions" | |
| 141 | 143 | } | |
| 142 | - | if len(d.sessions) != 1 { | |
| 144 | + | if len(locals) != 1 { | |
| 143 | 145 | return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane" | |
| 144 | 146 | } | |
| 145 | - | for _, sess := range d.sessions { | |
| 147 | + | for _, sess := range locals { | |
| 146 | 148 | return sess, 0, "" | |
| 147 | 149 | } | |
| 148 | 150 | panic("unreachable") | |
| @@ -570,10 +572,7 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) { | |||
| 570 | 572 | Active bool `json:"active"` | |
| 571 | 573 | } | |
| 572 | 574 | e.d.mu.Lock() | |
| 573 | - | sessions := make([]*session, 0, len(e.d.sessions)) | |
| 574 | - | for _, sess := range e.d.sessions { | |
| 575 | - | sessions = append(sessions, sess) | |
| 576 | - | } | |
| 575 | + | sessions := localSessionsSnapshot(e.d.sessions) | |
| 577 | 576 | e.d.mu.Unlock() | |
| 578 | 577 | snaps := make([]sessionView, 0, len(sessions)) | |
| 579 | 578 | for _, sess := range sessions { | |
| @@ -610,10 +609,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||
| 610 | 609 | return "", command.ErrInvalidArguments | |
| 611 | 610 | } | |
| 612 | 611 | e.d.mu.Lock() | |
| 613 | - | sessions := make([]*session, 0, len(e.d.sessions)) | |
| 614 | - | for _, sess := range e.d.sessions { | |
| 615 | - | sessions = append(sessions, sess) | |
| 616 | - | } | |
| 612 | + | sessions := localSessionsSnapshot(e.d.sessions) | |
| 617 | 613 | e.d.mu.Unlock() | |
| 618 | 614 | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)) | |
| 619 | 615 | // Stopped sessions live in d.stopped and are intentionally not in this listing. | |
| @@ -122,8 +122,9 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u | |||||
| 122 | return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together" | 122 | return nil, ports.ErrNoSuchTarget, "target tab and pane IDs must be provided together" | ||
| 123 | } | 123 | } | ||
| 124 | var match *session | 124 | var match *session | ||
| 125 | - | for _, sess := range d.sessions { | 125 | + | for _, entry := range d.sessions { |
| 126 | - | if !sess.containsStableIDs(request.TargetTab, request.TargetPane) { | 126 | + | sess, ok := localSession(entry) |
| 127 | + | if !ok || !sess.containsStableIDs(request.TargetTab, request.TargetPane) { | |||
| 127 | continue | 128 | continue | ||
| 128 | } | 129 | } | ||
| 129 | if match != nil { | 130 | if match != nil { | ||
| @@ -136,13 +137,14 @@ func (d *Daemon) resolveTargetSession(request ports.CommandRequest) (*session, u | |||||
| 136 | } | 137 | } | ||
| 137 | return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane" | 138 | return nil, ports.ErrNoSuchTarget, "no live session contains the target tab/pane" | ||
| 138 | } | 139 | } | ||
| 139 | - | if len(d.sessions) == 0 { | 140 | + | locals := localSessionsSnapshot(d.sessions) |
| 141 | + | if len(locals) == 0 { | |||
| 140 | return nil, ports.ErrNoSuchTarget, "no live sessions" | 142 | return nil, ports.ErrNoSuchTarget, "no live sessions" | ||
| 141 | } | 143 | } | ||
| 142 | - | if len(d.sessions) != 1 { | 144 | + | if len(locals) != 1 { |
| 143 | return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane" | 145 | return nil, ports.ErrAmbiguousTarget, "several sessions are live; use -s <session> or run from inside a pane" | ||
| 144 | } | 146 | } | ||
| 145 | - | for _, sess := range d.sessions { | 147 | + | for _, sess := range locals { |
| 146 | return sess, 0, "" | 148 | return sess, 0, "" | ||
| 147 | } | 149 | } | ||
| 148 | panic("unreachable") | 150 | panic("unreachable") | ||
| @@ -570,10 +572,7 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) { | |||||
| 570 | Active bool `json:"active"` | 572 | Active bool `json:"active"` | ||
| 571 | } | 573 | } | ||
| 572 | e.d.mu.Lock() | 574 | e.d.mu.Lock() | ||
| 573 | - | sessions := make([]*session, 0, len(e.d.sessions)) | 575 | + | sessions := localSessionsSnapshot(e.d.sessions) |
| 574 | - | for _, sess := range e.d.sessions { | |||
| 575 | - | sessions = append(sessions, sess) | |||
| 576 | - | } | |||
| 577 | e.d.mu.Unlock() | 576 | e.d.mu.Unlock() | ||
| 578 | snaps := make([]sessionView, 0, len(sessions)) | 577 | snaps := make([]sessionView, 0, len(sessions)) | ||
| 579 | for _, sess := range sessions { | 578 | for _, sess := range sessions { | ||
| @@ -610,10 +609,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||||
| 610 | return "", command.ErrInvalidArguments | 609 | return "", command.ErrInvalidArguments | ||
| 611 | } | 610 | } | ||
| 612 | e.d.mu.Lock() | 611 | e.d.mu.Lock() | ||
| 613 | - | sessions := make([]*session, 0, len(e.d.sessions)) | 612 | + | sessions := localSessionsSnapshot(e.d.sessions) |
| 614 | - | for _, sess := range e.d.sessions { | |||
| 615 | - | sessions = append(sessions, sess) | |||
| 616 | - | } | |||
| 617 | e.d.mu.Unlock() | 613 | e.d.mu.Unlock() | ||
| 618 | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)) | 614 | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)) | ||
| 619 | // Stopped sessions live in d.stopped and are intentionally not in this listing. | 615 | // Stopped sessions live in d.stopped and are intentionally not in this listing. | ||
M internal/usecase/daemon/control_test.go +3 -5
| @@ -1149,9 +1149,8 @@ func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session { | |||
| 1149 | 1149 | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 1150 | 1150 | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 1151 | 1151 | tb.ctx, tb.cancel = context.WithCancel(ctx) | |
| 1152 | - | sess := &session{ | |
| 1153 | - | id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5}, | |
| 1154 | - | ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0, | |
| 1152 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5}, | |
| 1153 | + | ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0, | |
| 1155 | 1154 | } | |
| 1156 | 1155 | publishTiledPaneOwners(sess, tb) | |
| 1157 | 1156 | d.mu.Lock() | |
| @@ -1226,8 +1225,7 @@ func (f *controlPTYFactory) close() { | |||
| 1226 | 1225 | func addControlSession(d *Daemon, name, tabID, paneID string) *session { | |
| 1227 | 1226 | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 1228 | 1227 | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | |
| 1229 | - | sess := &session{ | |
| 1230 | - | id: domain.SessionID("sess-" + name), name: name, ephemeral: true, cwd: "/tmp/" + name, | |
| 1228 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: true}, cwd: "/tmp/" + name, | |
| 1231 | 1229 | ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name}, | |
| 1232 | 1230 | } | |
| 1233 | 1231 | sess.mruAt.Store(1) | |
| @@ -1149,9 +1149,8 @@ func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session { | |||||
| 1149 | ctx, cancel := context.WithCancel(d.serveCtx) | 1149 | ctx, cancel := context.WithCancel(d.serveCtx) | ||
| 1150 | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | 1150 | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | ||
| 1151 | tb.ctx, tb.cancel = context.WithCancel(ctx) | 1151 | tb.ctx, tb.cancel = context.WithCancel(ctx) | ||
| 1152 | - | sess := &session{ | 1152 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5}, |
| 1153 | - | id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5}, | 1153 | + | ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0, |
| 1154 | - | ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0, | |||
| 1155 | } | 1154 | } | ||
| 1156 | publishTiledPaneOwners(sess, tb) | 1155 | publishTiledPaneOwners(sess, tb) | ||
| 1157 | d.mu.Lock() | 1156 | d.mu.Lock() | ||
| @@ -1226,8 +1225,7 @@ func (f *controlPTYFactory) close() { | |||||
| 1226 | func addControlSession(d *Daemon, name, tabID, paneID string) *session { | 1225 | func addControlSession(d *Daemon, name, tabID, paneID string) *session { | ||
| 1227 | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | 1226 | tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | ||
| 1228 | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | 1227 | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | ||
| 1229 | - | sess := &session{ | 1228 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: true}, cwd: "/tmp/" + name, |
| 1230 | - | id: domain.SessionID("sess-" + name), name: name, ephemeral: true, cwd: "/tmp/" + name, | |||
| 1231 | ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name}, | 1229 | ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name}, | ||
| 1232 | } | 1230 | } | ||
| 1233 | sess.mruAt.Store(1) | 1231 | sess.mruAt.Store(1) | ||
M internal/usecase/daemon/copymode_test.go +1 -1
| @@ -48,7 +48,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin | |||
| 48 | 48 | ac := &attachedClient{output: newOutputStateStream()} | |
| 49 | 49 | failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)} | |
| 50 | 50 | ac.replaceTransport(failed) | |
| 51 | - | sess := &session{name: "work", client: ac} | |
| 51 | + | sess := &session{sessionCore: sessionCore{name: "work", client: ac}} | |
| 52 | 52 | ac.setSession(sess) | |
| 53 | 53 | ||
| 54 | 54 | used, err := d.boundedSendOutputErrTransport(ac, []byte("copy")) | |
| @@ -48,7 +48,7 @@ func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testin | |||||
| 48 | ac := &attachedClient{output: newOutputStateStream()} | 48 | ac := &attachedClient{output: newOutputStateStream()} | ||
| 49 | failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)} | 49 | failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)} | ||
| 50 | ac.replaceTransport(failed) | 50 | ac.replaceTransport(failed) | ||
| 51 | - | sess := &session{name: "work", client: ac} | 51 | + | sess := &session{sessionCore: sessionCore{name: "work", client: ac}} |
| 52 | ac.setSession(sess) | 52 | ac.setSession(sess) | ||
| 53 | 53 | ||||
| 54 | used, err := d.boundedSendOutputErrTransport(ac, []byte("copy")) | 54 | used, err := d.boundedSendOutputErrTransport(ac, []byte("copy")) | ||
M internal/usecase/daemon/daemon.go +36 -8
| @@ -69,7 +69,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24} | |||
| 69 | 69 | ||
| 70 | 70 | type Daemon struct { | |
| 71 | 71 | mu sync.Mutex | |
| 72 | - | sessions map[domain.SessionID]*session | |
| 72 | + | sessions map[domain.SessionID]attachmentSession | |
| 73 | 73 | stopped map[string]stoppedSession | |
| 74 | 74 | // creating reserves names while durable creation I/O runs without mu. | |
| 75 | 75 | creating map[string]struct{} | |
| @@ -576,7 +576,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 576 | 576 | } | |
| 577 | 577 | paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background()) | |
| 578 | 578 | d := &Daemon{ | |
| 579 | - | sessions: make(map[domain.SessionID]*session), | |
| 579 | + | sessions: make(map[domain.SessionID]attachmentSession), | |
| 580 | 580 | stopped: make(map[string]stoppedSession), | |
| 581 | 581 | creating: make(map[string]struct{}), | |
| 582 | 582 | parked: make(map[uint64]*parkedAttachment), | |
| @@ -896,12 +896,36 @@ func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, c | |||
| 896 | 896 | } | |
| 897 | 897 | } | |
| 898 | 898 | ||
| 899 | - | func (d *Daemon) sessionsSnapshotLocked() []*session { | |
| 900 | - | snapshot := make([]*session, 0, len(d.sessions)) | |
| 901 | - | for _, s := range d.sessions { | |
| 902 | - | snapshot = append(snapshot, s) | |
| 899 | + | // registerSessionLocked publishes an exact attachment-session identity. Caller | |
| 900 | + | // holds d.mu. | |
| 901 | + | func (d *Daemon) registerSessionLocked(entry attachmentSession) bool { | |
| 902 | + | if entry == nil { | |
| 903 | + | return false | |
| 904 | + | } | |
| 905 | + | core := entry.core() | |
| 906 | + | if core == nil || core.id == "" || d.sessions[core.id] != nil { | |
| 907 | + | return false | |
| 903 | 908 | } | |
| 904 | - | return snapshot | |
| 909 | + | d.sessions[core.id] = entry | |
| 910 | + | return true | |
| 911 | + | } | |
| 912 | + | ||
| 913 | + | // unregisterSessionLocked removes only the exact registered identity. Caller | |
| 914 | + | // holds d.mu. | |
| 915 | + | func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool { | |
| 916 | + | if entry == nil { | |
| 917 | + | return false | |
| 918 | + | } | |
| 919 | + | core := entry.core() | |
| 920 | + | if core == nil || d.sessions[core.id] != entry { | |
| 921 | + | return false | |
| 922 | + | } | |
| 923 | + | delete(d.sessions, core.id) | |
| 924 | + | return true | |
| 925 | + | } | |
| 926 | + | ||
| 927 | + | func (d *Daemon) sessionsSnapshotLocked() []*session { | |
| 928 | + | return localSessionsSnapshot(d.sessions) | |
| 905 | 929 | } | |
| 906 | 930 | ||
| 907 | 931 | // handleConn reads the first frame off a fresh connection and routes it. A | |
| @@ -941,7 +965,11 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||
| 941 | 965 | d.mu.Lock() | |
| 942 | 966 | infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped)) | |
| 943 | 967 | liveNames := make(map[string]struct{}, len(d.sessions)) | |
| 944 | - | for _, s := range d.sessions { | |
| 968 | + | for _, entry := range d.sessions { | |
| 969 | + | s, ok := localSession(entry) | |
| 970 | + | if !ok { | |
| 971 | + | continue | |
| 972 | + | } | |
| 945 | 973 | s.mu.Lock() | |
| 946 | 974 | info := ports.SessionInfo{ | |
| 947 | 975 | SessionID: string(s.id), | |
| @@ -69,7 +69,7 @@ var defaultSize = domain.Size{Cols: 80, Rows: 24} | |||||
| 69 | 69 | ||||
| 70 | type Daemon struct { | 70 | type Daemon struct { | ||
| 71 | mu sync.Mutex | 71 | mu sync.Mutex | ||
| 72 | - | sessions map[domain.SessionID]*session | 72 | + | sessions map[domain.SessionID]attachmentSession |
| 73 | stopped map[string]stoppedSession | 73 | stopped map[string]stoppedSession | ||
| 74 | // creating reserves names while durable creation I/O runs without mu. | 74 | // creating reserves names while durable creation I/O runs without mu. | ||
| 75 | creating map[string]struct{} | 75 | creating map[string]struct{} | ||
| @@ -576,7 +576,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 576 | } | 576 | } | ||
| 577 | paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background()) | 577 | paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background()) | ||
| 578 | d := &Daemon{ | 578 | d := &Daemon{ | ||
| 579 | - | sessions: make(map[domain.SessionID]*session), | 579 | + | sessions: make(map[domain.SessionID]attachmentSession), |
| 580 | stopped: make(map[string]stoppedSession), | 580 | stopped: make(map[string]stoppedSession), | ||
| 581 | creating: make(map[string]struct{}), | 581 | creating: make(map[string]struct{}), | ||
| 582 | parked: make(map[uint64]*parkedAttachment), | 582 | parked: make(map[uint64]*parkedAttachment), | ||
| @@ -896,12 +896,36 @@ func snapshotStopContext(deadline *snapshotShutdownDeadline) (context.Context, c | |||||
| 896 | } | 896 | } | ||
| 897 | } | 897 | } | ||
| 898 | 898 | ||||
| 899 | - | func (d *Daemon) sessionsSnapshotLocked() []*session { | 899 | + | // registerSessionLocked publishes an exact attachment-session identity. Caller |
| 900 | - | snapshot := make([]*session, 0, len(d.sessions)) | 900 | + | // holds d.mu. |
| 901 | - | for _, s := range d.sessions { | 901 | + | func (d *Daemon) registerSessionLocked(entry attachmentSession) bool { |
| 902 | - | snapshot = append(snapshot, s) | 902 | + | if entry == nil { |
| 903 | + | return false | |||
| 904 | + | } | |||
| 905 | + | core := entry.core() | |||
| 906 | + | if core == nil || core.id == "" || d.sessions[core.id] != nil { | |||
| 907 | + | return false | |||
| 903 | } | 908 | } | ||
| 904 | - | return snapshot | 909 | + | d.sessions[core.id] = entry |
| 910 | + | return true | |||
| 911 | + | } | |||
| 912 | + | ||||
| 913 | + | // unregisterSessionLocked removes only the exact registered identity. Caller | |||
| 914 | + | // holds d.mu. | |||
| 915 | + | func (d *Daemon) unregisterSessionLocked(entry attachmentSession) bool { | |||
| 916 | + | if entry == nil { | |||
| 917 | + | return false | |||
| 918 | + | } | |||
| 919 | + | core := entry.core() | |||
| 920 | + | if core == nil || d.sessions[core.id] != entry { | |||
| 921 | + | return false | |||
| 922 | + | } | |||
| 923 | + | delete(d.sessions, core.id) | |||
| 924 | + | return true | |||
| 925 | + | } | |||
| 926 | + | ||||
| 927 | + | func (d *Daemon) sessionsSnapshotLocked() []*session { | |||
| 928 | + | return localSessionsSnapshot(d.sessions) | |||
| 905 | } | 929 | } | ||
| 906 | 930 | ||||
| 907 | // handleConn reads the first frame off a fresh connection and routes it. A | 931 | // handleConn reads the first frame off a fresh connection and routes it. A | ||
| @@ -941,7 +965,11 @@ func (d *Daemon) handleList(tr ports.Transport) { | |||||
| 941 | d.mu.Lock() | 965 | d.mu.Lock() | ||
| 942 | infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped)) | 966 | infos := make([]ports.SessionInfo, 0, len(d.sessions)+len(d.stopped)) | ||
| 943 | liveNames := make(map[string]struct{}, len(d.sessions)) | 967 | liveNames := make(map[string]struct{}, len(d.sessions)) | ||
| 944 | - | for _, s := range d.sessions { | 968 | + | for _, entry := range d.sessions { |
| 969 | + | s, ok := localSession(entry) | |||
| 970 | + | if !ok { | |||
| 971 | + | continue | |||
| 972 | + | } | |||
| 945 | s.mu.Lock() | 973 | s.mu.Lock() | ||
| 946 | info := ports.SessionInfo{ | 974 | info := ports.SessionInfo{ | ||
| 947 | SessionID: string(s.id), | 975 | SessionID: string(s.id), | ||
M internal/usecase/daemon/daemon_test.go +5 -3
| @@ -297,8 +297,10 @@ func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Fram | |||
| 297 | 297 | func firstSession(d *Daemon) *session { | |
| 298 | 298 | d.mu.Lock() | |
| 299 | 299 | defer d.mu.Unlock() | |
| 300 | - | for _, s := range d.sessions { | |
| 301 | - | return s | |
| 300 | + | for _, entry := range d.sessions { | |
| 301 | + | if s, ok := localSession(entry); ok { | |
| 302 | + | return s | |
| 303 | + | } | |
| 302 | 304 | } | |
| 303 | 305 | return nil | |
| 304 | 306 | } | |
| @@ -411,7 +413,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis | |||
| 411 | 413 | } | |
| 412 | 414 | tabs = append(tabs, tb) | |
| 413 | 415 | } | |
| 414 | - | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac} | |
| 416 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: tabs} | |
| 415 | 417 | for _, tb := range tabs { | |
| 416 | 418 | publishTiledPaneOwners(sess, tb) | |
| 417 | 419 | } | |
| @@ -297,8 +297,10 @@ func awaitFrame(t *testing.T, ch chan ports.Frame, typ ports.MsgType) ports.Fram | |||||
| 297 | func firstSession(d *Daemon) *session { | 297 | func firstSession(d *Daemon) *session { | ||
| 298 | d.mu.Lock() | 298 | d.mu.Lock() | ||
| 299 | defer d.mu.Unlock() | 299 | defer d.mu.Unlock() | ||
| 300 | - | for _, s := range d.sessions { | 300 | + | for _, entry := range d.sessions { |
| 301 | - | return s | 301 | + | if s, ok := localSession(entry); ok { |
| 302 | + | return s | |||
| 303 | + | } | |||
| 302 | } | 304 | } | ||
| 303 | return nil | 305 | return nil | ||
| 304 | } | 306 | } | ||
| @@ -411,7 +413,7 @@ func newManualSessionWithPTYsClockCleanup(t testing.TB, clock ports.Clock, regis | |||||
| 411 | } | 413 | } | ||
| 412 | tabs = append(tabs, tb) | 414 | tabs = append(tabs, tb) | ||
| 413 | } | 415 | } | ||
| 414 | - | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac} | 416 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: tabs} |
| 415 | for _, tb := range tabs { | 417 | for _, tb := range tabs { | ||
| 416 | publishTiledPaneOwners(sess, tb) | 418 | publishTiledPaneOwners(sess, tb) | ||
| 417 | } | 419 | } | ||
M internal/usecase/daemon/durable_recovery_test.go +90 -1
| @@ -73,6 +73,23 @@ func mustDurableRecords(t *testing.T, c *durableRecoveryCatalogue) []domain.Cata | |||
| 73 | 73 | return records | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | func (c *durableRecoveryCatalogue) Create(record domain.CatalogueRecord) error { | |
| 77 | + | c.mu.Lock() | |
| 78 | + | defer c.mu.Unlock() | |
| 79 | + | if _, exists := c.records[record.Name]; exists { | |
| 80 | + | return errors.New("catalogue record already exists") | |
| 81 | + | } | |
| 82 | + | c.records[record.Name] = record | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (c *durableRecoveryCatalogue) Delete(name string) error { | |
| 87 | + | c.mu.Lock() | |
| 88 | + | defer c.mu.Unlock() | |
| 89 | + | delete(c.records, name) | |
| 90 | + | return nil | |
| 91 | + | } | |
| 92 | + | ||
| 76 | 93 | func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error { | |
| 77 | 94 | c.mu.Lock() | |
| 78 | 95 | defer c.mu.Unlock() | |
| @@ -645,6 +662,78 @@ func TestFinishRecordRestoreDoesNotDegradeStaleCatalogueAuthority(t *testing.T) | |||
| 645 | 662 | } | |
| 646 | 663 | } | |
| 647 | 664 | ||
| 665 | + | func TestPersistAndRegisterRestoredSessionRegistrationFailurePreservesStoppedEntry(t *testing.T) { | |
| 666 | + | record := durableRecoveryRecord(0) | |
| 667 | + | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |
| 668 | + | d.mu.Lock() | |
| 669 | + | before := d.stopped[record.Name] | |
| 670 | + | collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 671 | + | d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}} | |
| 672 | + | d.mu.Unlock() | |
| 673 | + | sess := newSnapshotTestSession(t, record.Name, false, record.Cwd) | |
| 674 | + | sess.incarnation = record.IncarnationID | |
| 675 | + | ||
| 676 | + | registered, err := d.persistAndRegisterRestoredSession(t.Context(), sess) | |
| 677 | + | ||
| 678 | + | require.ErrorContains(t, err, "registry rejected") | |
| 679 | + | require.False(t, registered) | |
| 680 | + | d.mu.Lock() | |
| 681 | + | after := d.stopped[record.Name] | |
| 682 | + | d.mu.Unlock() | |
| 683 | + | require.Equal(t, before, after, "failed runtime publication must preserve the exact stopped authority and barrier") | |
| 684 | + | require.Equal(t, before.restoreDone, after.restoreDone) | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | func TestCreateNamedSessionRegistrationFailureRollsBackDurableState(t *testing.T) { | |
| 688 | + | for _, tc := range []struct { | |
| 689 | + | name string | |
| 690 | + | records []domain.CatalogueRecord | |
| 691 | + | sessName string | |
| 692 | + | cwd string | |
| 693 | + | }{ | |
| 694 | + | {name: "fresh create", sessName: "fresh", cwd: "/fresh"}, | |
| 695 | + | {name: "stopped metadata update", records: []domain.CatalogueRecord{durableRecoveryRecord(0)}, sessName: durableRecoveryRecord(0).Name, cwd: "/resumed"}, | |
| 696 | + | } { | |
| 697 | + | t.Run(tc.name, func(t *testing.T) { | |
| 698 | + | pty, release := newBlockingPTY(t) | |
| 699 | + | defer release() | |
| 700 | + | catalogue := newDurableRecoveryCatalogue(tc.records) | |
| 701 | + | coordinator := recoveryusecase.NewCoordinator(catalogue, nil, bytes.NewReader(bytes.Repeat([]byte{0x6a}, 16))) | |
| 702 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 703 | + | WithCatalogue(catalogue, tc.records)(d) | |
| 704 | + | WithRecoveryCoordinator(coordinator)(d) | |
| 705 | + | ||
| 706 | + | d.mu.Lock() | |
| 707 | + | for _, record := range tc.records { | |
| 708 | + | d.stopped[record.Name] = stoppedSession{ | |
| 709 | + | name: record.Name, cwd: record.Cwd, createdAt: record.CreatedAt, | |
| 710 | + | incarnation: record.IncarnationID, lastUsedSeq: record.LastUsedSeq, | |
| 711 | + | tabNames: append([]string(nil), record.TabNames...), record: record, | |
| 712 | + | state: ports.SessionStopped, restoreDone: make(chan struct{}), | |
| 713 | + | } | |
| 714 | + | } | |
| 715 | + | beforeStopped, hadStopped := d.stopped[tc.sessName] | |
| 716 | + | collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 717 | + | d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}} | |
| 718 | + | d.mu.Unlock() | |
| 719 | + | beforeRecords := mustDurableRecords(t, catalogue) | |
| 720 | + | ||
| 721 | + | _, err := createSessionForTest(d, tc.sessName, false, tc.cwd, defaultSize, terminalEnv{}, d.baseEnv) | |
| 722 | + | ||
| 723 | + | require.ErrorContains(t, err, "registry rejected") | |
| 724 | + | require.ElementsMatch(t, beforeRecords, mustDurableRecords(t, catalogue), "failed runtime publication must leave no created record or metadata mutation") | |
| 725 | + | d.mu.Lock() | |
| 726 | + | afterStopped, stillStopped := d.stopped[tc.sessName] | |
| 727 | + | d.mu.Unlock() | |
| 728 | + | require.Equal(t, hadStopped, stillStopped) | |
| 729 | + | if hadStopped { | |
| 730 | + | require.Equal(t, beforeStopped, afterStopped, "resume publication failure must restore the exact stopped state") | |
| 731 | + | require.Equal(t, beforeStopped.restoreDone, afterStopped.restoreDone) | |
| 732 | + | } | |
| 733 | + | }) | |
| 734 | + | } | |
| 735 | + | } | |
| 736 | + | ||
| 648 | 737 | func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testing.T) { | |
| 649 | 738 | record := durableRecoveryRecord(0) | |
| 650 | 739 | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |
| @@ -750,7 +839,7 @@ func TestAttachBarrierSurvivesLiveRegistryPublication(t *testing.T) { | |||
| 750 | 839 | d.mu.Lock() | |
| 751 | 840 | done := d.stopped[record.Name].restoreDone | |
| 752 | 841 | d.mu.Unlock() | |
| 753 | - | sess := &session{name: record.Name, incarnation: record.IncarnationID} | |
| 842 | + | sess := &session{sessionCore: sessionCore{name: record.Name, incarnation: record.IncarnationID}} | |
| 754 | 843 | ||
| 755 | 844 | registered, err := d.persistAndRegisterRestoredSession(t.Context(), sess) | |
| 756 | 845 | require.NoError(t, err) | |
| @@ -73,6 +73,23 @@ func mustDurableRecords(t *testing.T, c *durableRecoveryCatalogue) []domain.Cata | |||||
| 73 | return records | 73 | return records | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | func (c *durableRecoveryCatalogue) Create(record domain.CatalogueRecord) error { | |||
| 77 | + | c.mu.Lock() | |||
| 78 | + | defer c.mu.Unlock() | |||
| 79 | + | if _, exists := c.records[record.Name]; exists { | |||
| 80 | + | return errors.New("catalogue record already exists") | |||
| 81 | + | } | |||
| 82 | + | c.records[record.Name] = record | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (c *durableRecoveryCatalogue) Delete(name string) error { | |||
| 87 | + | c.mu.Lock() | |||
| 88 | + | defer c.mu.Unlock() | |||
| 89 | + | delete(c.records, name) | |||
| 90 | + | return nil | |||
| 91 | + | } | |||
| 92 | + | ||||
| 76 | func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error { | 93 | func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueRecord) error { | ||
| 77 | c.mu.Lock() | 94 | c.mu.Lock() | ||
| 78 | defer c.mu.Unlock() | 95 | defer c.mu.Unlock() | ||
| @@ -645,6 +662,78 @@ func TestFinishRecordRestoreDoesNotDegradeStaleCatalogueAuthority(t *testing.T) | |||||
| 645 | } | 662 | } | ||
| 646 | } | 663 | } | ||
| 647 | 664 | ||||
| 665 | + | func TestPersistAndRegisterRestoredSessionRegistrationFailurePreservesStoppedEntry(t *testing.T) { | |||
| 666 | + | record := durableRecoveryRecord(0) | |||
| 667 | + | d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | |||
| 668 | + | d.mu.Lock() | |||
| 669 | + | before := d.stopped[record.Name] | |||
| 670 | + | collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |||
| 671 | + | d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}} | |||
| 672 | + | d.mu.Unlock() | |||
| 673 | + | sess := newSnapshotTestSession(t, record.Name, false, record.Cwd) | |||
| 674 | + | sess.incarnation = record.IncarnationID | |||
| 675 | + | ||||
| 676 | + | registered, err := d.persistAndRegisterRestoredSession(t.Context(), sess) | |||
| 677 | + | ||||
| 678 | + | require.ErrorContains(t, err, "registry rejected") | |||
| 679 | + | require.False(t, registered) | |||
| 680 | + | d.mu.Lock() | |||
| 681 | + | after := d.stopped[record.Name] | |||
| 682 | + | d.mu.Unlock() | |||
| 683 | + | require.Equal(t, before, after, "failed runtime publication must preserve the exact stopped authority and barrier") | |||
| 684 | + | require.Equal(t, before.restoreDone, after.restoreDone) | |||
| 685 | + | } | |||
| 686 | + | ||||
| 687 | + | func TestCreateNamedSessionRegistrationFailureRollsBackDurableState(t *testing.T) { | |||
| 688 | + | for _, tc := range []struct { | |||
| 689 | + | name string | |||
| 690 | + | records []domain.CatalogueRecord | |||
| 691 | + | sessName string | |||
| 692 | + | cwd string | |||
| 693 | + | }{ | |||
| 694 | + | {name: "fresh create", sessName: "fresh", cwd: "/fresh"}, | |||
| 695 | + | {name: "stopped metadata update", records: []domain.CatalogueRecord{durableRecoveryRecord(0)}, sessName: durableRecoveryRecord(0).Name, cwd: "/resumed"}, | |||
| 696 | + | } { | |||
| 697 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 698 | + | pty, release := newBlockingPTY(t) | |||
| 699 | + | defer release() | |||
| 700 | + | catalogue := newDurableRecoveryCatalogue(tc.records) | |||
| 701 | + | coordinator := recoveryusecase.NewCoordinator(catalogue, nil, bytes.NewReader(bytes.Repeat([]byte{0x6a}, 16))) | |||
| 702 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 703 | + | WithCatalogue(catalogue, tc.records)(d) | |||
| 704 | + | WithRecoveryCoordinator(coordinator)(d) | |||
| 705 | + | ||||
| 706 | + | d.mu.Lock() | |||
| 707 | + | for _, record := range tc.records { | |||
| 708 | + | d.stopped[record.Name] = stoppedSession{ | |||
| 709 | + | name: record.Name, cwd: record.Cwd, createdAt: record.CreatedAt, | |||
| 710 | + | incarnation: record.IncarnationID, lastUsedSeq: record.LastUsedSeq, | |||
| 711 | + | tabNames: append([]string(nil), record.TabNames...), record: record, | |||
| 712 | + | state: ports.SessionStopped, restoreDone: make(chan struct{}), | |||
| 713 | + | } | |||
| 714 | + | } | |||
| 715 | + | beforeStopped, hadStopped := d.stopped[tc.sessName] | |||
| 716 | + | collisionID := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |||
| 717 | + | d.sessions[collisionID] = &session{sessionCore: sessionCore{id: collisionID, name: "collision"}} | |||
| 718 | + | d.mu.Unlock() | |||
| 719 | + | beforeRecords := mustDurableRecords(t, catalogue) | |||
| 720 | + | ||||
| 721 | + | _, err := createSessionForTest(d, tc.sessName, false, tc.cwd, defaultSize, terminalEnv{}, d.baseEnv) | |||
| 722 | + | ||||
| 723 | + | require.ErrorContains(t, err, "registry rejected") | |||
| 724 | + | require.ElementsMatch(t, beforeRecords, mustDurableRecords(t, catalogue), "failed runtime publication must leave no created record or metadata mutation") | |||
| 725 | + | d.mu.Lock() | |||
| 726 | + | afterStopped, stillStopped := d.stopped[tc.sessName] | |||
| 727 | + | d.mu.Unlock() | |||
| 728 | + | require.Equal(t, hadStopped, stillStopped) | |||
| 729 | + | if hadStopped { | |||
| 730 | + | require.Equal(t, beforeStopped, afterStopped, "resume publication failure must restore the exact stopped state") | |||
| 731 | + | require.Equal(t, beforeStopped.restoreDone, afterStopped.restoreDone) | |||
| 732 | + | } | |||
| 733 | + | }) | |||
| 734 | + | } | |||
| 735 | + | } | |||
| 736 | + | ||||
| 648 | func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testing.T) { | 737 | func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testing.T) { | ||
| 649 | record := durableRecoveryRecord(0) | 738 | record := durableRecoveryRecord(0) | ||
| 650 | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | 739 | d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{}) | ||
| @@ -750,7 +839,7 @@ func TestAttachBarrierSurvivesLiveRegistryPublication(t *testing.T) { | |||||
| 750 | d.mu.Lock() | 839 | d.mu.Lock() | ||
| 751 | done := d.stopped[record.Name].restoreDone | 840 | done := d.stopped[record.Name].restoreDone | ||
| 752 | d.mu.Unlock() | 841 | d.mu.Unlock() | ||
| 753 | - | sess := &session{name: record.Name, incarnation: record.IncarnationID} | 842 | + | sess := &session{sessionCore: sessionCore{name: record.Name, incarnation: record.IncarnationID}} |
| 754 | 843 | ||||
| 755 | registered, err := d.persistAndRegisterRestoredSession(t.Context(), sess) | 844 | registered, err := d.persistAndRegisterRestoredSession(t.Context(), sess) | ||
| 756 | require.NoError(t, err) | 845 | require.NoError(t, err) | ||
M internal/usecase/daemon/floating_open_test.go +5 -5
| @@ -107,7 +107,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |||
| 107 | 107 | tabCtx, cancelTab := context.WithCancel(t.Context()) | |
| 108 | 108 | tb.ctx, tb.cancel = tabCtx, cancelTab | |
| 109 | 109 | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 110 | - | sess := &session{id: "context-" + domain.SessionID(tt.name), name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession} | |
| 110 | + | sess := &session{sessionCore: sessionCore{id: "context-" + domain.SessionID(tt.name), name: "work"}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession} | |
| 111 | 111 | d.mu.Lock() | |
| 112 | 112 | d.sessions[sess.id] = sess | |
| 113 | 113 | d.mu.Unlock() | |
| @@ -228,7 +228,7 @@ func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing. | |||
| 228 | 228 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 229 | 229 | ac.initOverlays() | |
| 230 | 230 | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 231 | - | sess := &session{id: "floating-open-error", name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession, client: ac} | |
| 231 | + | sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", client: ac}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession} | |
| 232 | 232 | ac.setSession(sess) | |
| 233 | 233 | ||
| 234 | 234 | // Close must be safe to run before failure publication and without | |
| @@ -350,7 +350,7 @@ func TestFloatingFailedAndStaleOpenCancelContext(t *testing.T) { | |||
| 350 | 350 | factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)} | |
| 351 | 351 | d := newTestDaemon(t, factory, stubClock{}) | |
| 352 | 352 | tb := newFloatingTestTab(t) | |
| 353 | - | sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()} | |
| 353 | + | sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 354 | 354 | tb.mu.Lock() | |
| 355 | 355 | generation := tb.beginFloatingWarmLocked(true) | |
| 356 | 356 | tb.mu.Unlock() | |
| @@ -400,7 +400,7 @@ func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testin | |||
| 400 | 400 | d := newTestDaemon(t, factory, stubClock{}) | |
| 401 | 401 | tb := newFloatingTestTab(t) | |
| 402 | 402 | ctx, cancel := context.WithCancel(t.Context()) | |
| 403 | - | sess := &session{id: "blocked-open", name: "work", tabs: []*tab{tb}, ctx: ctx, cancel: cancel} | |
| 403 | + | sess := &session{sessionCore: sessionCore{id: "blocked-open", name: "work"}, tabs: []*tab{tb}, ctx: ctx, cancel: cancel} | |
| 404 | 404 | d.mu.Lock() | |
| 405 | 405 | d.sessions[sess.id] = sess | |
| 406 | 406 | d.mu.Unlock() | |
| @@ -446,7 +446,7 @@ func TestFloatingOpensFromUnrelatedSessionsAreConcurrent(t *testing.T) { | |||
| 446 | 446 | for _, id := range []domain.SessionID{"one", "two"} { | |
| 447 | 447 | tb := newFloatingTestTab(t) | |
| 448 | 448 | ctx, cancel := context.WithCancel(t.Context()) | |
| 449 | - | sess := &session{id: id, name: string(id), tabs: []*tab{tb}, ctx: ctx, cancel: cancel} | |
| 449 | + | sess := &session{sessionCore: sessionCore{id: id, name: string(id)}, tabs: []*tab{tb}, ctx: ctx, cancel: cancel} | |
| 450 | 450 | tabs = append(tabs, tb) | |
| 451 | 451 | cancels = append(cancels, cancel) | |
| 452 | 452 | d.startFloating(sess, tb, false) | |
| @@ -107,7 +107,7 @@ func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) { | |||||
| 107 | tabCtx, cancelTab := context.WithCancel(t.Context()) | 107 | tabCtx, cancelTab := context.WithCancel(t.Context()) | ||
| 108 | tb.ctx, tb.cancel = tabCtx, cancelTab | 108 | tb.ctx, tb.cancel = tabCtx, cancelTab | ||
| 109 | sessCtx, cancelSession := context.WithCancel(t.Context()) | 109 | sessCtx, cancelSession := context.WithCancel(t.Context()) | ||
| 110 | - | sess := &session{id: "context-" + domain.SessionID(tt.name), name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession} | 110 | + | sess := &session{sessionCore: sessionCore{id: "context-" + domain.SessionID(tt.name), name: "work"}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession} |
| 111 | d.mu.Lock() | 111 | d.mu.Lock() | ||
| 112 | d.sessions[sess.id] = sess | 112 | d.sessions[sess.id] = sess | ||
| 113 | d.mu.Unlock() | 113 | d.mu.Unlock() | ||
| @@ -228,7 +228,7 @@ func TestFloatingOpenErrorReleasesReturnedPTYBeforePublishingFailure(t *testing. | |||||
| 228 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 228 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 229 | ac.initOverlays() | 229 | ac.initOverlays() | ||
| 230 | sessCtx, cancelSession := context.WithCancel(t.Context()) | 230 | sessCtx, cancelSession := context.WithCancel(t.Context()) | ||
| 231 | - | sess := &session{id: "floating-open-error", name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession, client: ac} | 231 | + | sess := &session{sessionCore: sessionCore{id: "floating-open-error", name: "work", client: ac}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession} |
| 232 | ac.setSession(sess) | 232 | ac.setSession(sess) | ||
| 233 | 233 | ||||
| 234 | // Close must be safe to run before failure publication and without | 234 | // Close must be safe to run before failure publication and without | ||
| @@ -350,7 +350,7 @@ func TestFloatingFailedAndStaleOpenCancelContext(t *testing.T) { | |||||
| 350 | factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)} | 350 | factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)} | ||
| 351 | d := newTestDaemon(t, factory, stubClock{}) | 351 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 352 | tb := newFloatingTestTab(t) | 352 | tb := newFloatingTestTab(t) | ||
| 353 | - | sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()} | 353 | + | sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()} |
| 354 | tb.mu.Lock() | 354 | tb.mu.Lock() | ||
| 355 | generation := tb.beginFloatingWarmLocked(true) | 355 | generation := tb.beginFloatingWarmLocked(true) | ||
| 356 | tb.mu.Unlock() | 356 | tb.mu.Unlock() | ||
| @@ -400,7 +400,7 @@ func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testin | |||||
| 400 | d := newTestDaemon(t, factory, stubClock{}) | 400 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 401 | tb := newFloatingTestTab(t) | 401 | tb := newFloatingTestTab(t) | ||
| 402 | ctx, cancel := context.WithCancel(t.Context()) | 402 | ctx, cancel := context.WithCancel(t.Context()) | ||
| 403 | - | sess := &session{id: "blocked-open", name: "work", tabs: []*tab{tb}, ctx: ctx, cancel: cancel} | 403 | + | sess := &session{sessionCore: sessionCore{id: "blocked-open", name: "work"}, tabs: []*tab{tb}, ctx: ctx, cancel: cancel} |
| 404 | d.mu.Lock() | 404 | d.mu.Lock() | ||
| 405 | d.sessions[sess.id] = sess | 405 | d.sessions[sess.id] = sess | ||
| 406 | d.mu.Unlock() | 406 | d.mu.Unlock() | ||
| @@ -446,7 +446,7 @@ func TestFloatingOpensFromUnrelatedSessionsAreConcurrent(t *testing.T) { | |||||
| 446 | for _, id := range []domain.SessionID{"one", "two"} { | 446 | for _, id := range []domain.SessionID{"one", "two"} { | ||
| 447 | tb := newFloatingTestTab(t) | 447 | tb := newFloatingTestTab(t) | ||
| 448 | ctx, cancel := context.WithCancel(t.Context()) | 448 | ctx, cancel := context.WithCancel(t.Context()) | ||
| 449 | - | sess := &session{id: id, name: string(id), tabs: []*tab{tb}, ctx: ctx, cancel: cancel} | 449 | + | sess := &session{sessionCore: sessionCore{id: id, name: string(id)}, tabs: []*tab{tb}, ctx: ctx, cancel: cancel} |
| 450 | tabs = append(tabs, tb) | 450 | tabs = append(tabs, tb) | ||
| 451 | cancels = append(cancels, cancel) | 451 | cancels = append(cancels, cancel) | ||
| 452 | d.startFloating(sess, tb, false) | 452 | d.startFloating(sess, tb, false) | ||
M internal/usecase/daemon/floating_render_test.go +2 -2
| @@ -169,7 +169,7 @@ func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testi | |||
| 169 | 169 | d := newTestDaemon(t, nil, stubClock{}) | |
| 170 | 170 | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 171 | 171 | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |
| 172 | - | sess := &session{name: "test", tabs: []*tab{tb}, ctx: t.Context()} | |
| 172 | + | sess := &session{sessionCore: sessionCore{name: "test"}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 173 | 173 | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, true) | |
| 174 | 174 | require.NoError(t, err) | |
| 175 | 175 | require.Equal(t, geometry, spec.geometry, "the 1x1 PTY fallback must not replace drawer presentation") | |
| @@ -595,7 +595,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||
| 595 | 595 | installTestFloating(tb, p, true) | |
| 596 | 596 | ac := &attachedClient{} | |
| 597 | 597 | ac.initOverlays() | |
| 598 | - | sess := &session{tabs: []*tab{tb}, client: ac} | |
| 598 | + | sess := &session{sessionCore: sessionCore{client: ac}, tabs: []*tab{tb}} | |
| 599 | 599 | base := barState{} | |
| 600 | 600 | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 601 | 601 | ||
| @@ -169,7 +169,7 @@ func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testi | |||||
| 169 | d := newTestDaemon(t, nil, stubClock{}) | 169 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 170 | d.ApplyConfig(domain.Config{Floating: cfg}) | 170 | d.ApplyConfig(domain.Config{Floating: cfg}) | ||
| 171 | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | 171 | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | ||
| 172 | - | sess := &session{name: "test", tabs: []*tab{tb}, ctx: t.Context()} | 172 | + | sess := &session{sessionCore: sessionCore{name: "test"}, tabs: []*tab{tb}, ctx: t.Context()} |
| 173 | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, true) | 173 | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, true) | ||
| 174 | require.NoError(t, err) | 174 | require.NoError(t, err) | ||
| 175 | require.Equal(t, geometry, spec.geometry, "the 1x1 PTY fallback must not replace drawer presentation") | 175 | require.Equal(t, geometry, spec.geometry, "the 1x1 PTY fallback must not replace drawer presentation") | ||
| @@ -595,7 +595,7 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||||
| 595 | installTestFloating(tb, p, true) | 595 | installTestFloating(tb, p, true) | ||
| 596 | ac := &attachedClient{} | 596 | ac := &attachedClient{} | ||
| 597 | ac.initOverlays() | 597 | ac.initOverlays() | ||
| 598 | - | sess := &session{tabs: []*tab{tb}, client: ac} | 598 | + | sess := &session{sessionCore: sessionCore{client: ac}, tabs: []*tab{tb}} |
| 599 | base := barState{} | 599 | base := barState{} | ||
| 600 | cfg := domain.FloatingConfig{Width: 100, Height: 100} | 600 | cfg := domain.FloatingConfig{Width: 100, Height: 100} | ||
| 601 | 601 | ||||
M internal/usecase/daemon/floating_test.go +10 -10
| @@ -172,7 +172,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi | |||
| 172 | 172 | cwd := t.TempDir() | |
| 173 | 173 | tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40}) | |
| 174 | 174 | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | |
| 175 | - | sess := &session{name: "work", cwd: cwd, terminal: terminalEnv{TrueColor: true}, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 175 | + | sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, terminal: terminalEnv{TrueColor: true}, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 176 | 176 | d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}}) | |
| 177 | 177 | d.ensureFloatingWarm(sess, tb) | |
| 178 | 178 | // Open has started while this goroutine owns tab.mu: an external factory | |
| @@ -239,7 +239,7 @@ func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) { | |||
| 239 | 239 | t.Cleanup(cancelTab) | |
| 240 | 240 | tb := newTab(&transactionalResizePTY{}, tabSize(initial)) | |
| 241 | 241 | tb.ctx, tb.cancel = tabCtx, cancelTab | |
| 242 | - | sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()} | |
| 242 | + | sess := &session{sessionCore: sessionCore{id: "responsive-open", name: "work"}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 243 | 243 | ||
| 244 | 244 | d.startFloating(sess, tb, true) | |
| 245 | 245 | openCall := <-opened | |
| @@ -294,7 +294,7 @@ func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) { | |||
| 294 | 294 | d := newTestDaemon(t, factory, stubClock{}) | |
| 295 | 295 | tb := newFloatingTestTab(t) | |
| 296 | 296 | sessCtx, cancel := context.WithCancel(t.Context()) | |
| 297 | - | sess := &session{id: "floating-ownership", name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel} | |
| 297 | + | sess := &session{sessionCore: sessionCore{id: "floating-ownership", name: "work"}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel} | |
| 298 | 298 | d.mu.Lock() | |
| 299 | 299 | d.sessions[sess.id] = sess | |
| 300 | 300 | d.mu.Unlock() | |
| @@ -345,7 +345,7 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test | |||
| 345 | 345 | factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil) | |
| 346 | 346 | d := newTestDaemon(t, factory, stubClock{}) | |
| 347 | 347 | tb := newFloatingTestTab(t) | |
| 348 | - | sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()} | |
| 348 | + | sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 349 | 349 | d.ensureFloatingWarm(sess, tb) | |
| 350 | 350 | <-opened | |
| 351 | 351 | d.teardownFloating(tb, nil) // invalidate before the delayed Open returns | |
| @@ -378,8 +378,8 @@ func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) { | |||
| 378 | 378 | factory := &lifetimePTYFactory{} | |
| 379 | 379 | d := newTestDaemon(t, factory, stubClock{}) | |
| 380 | 380 | tb := newFloatingTestTab(t) | |
| 381 | - | source := &session{id: "source", name: "source", tabs: []*tab{tb}, ctx: t.Context()} | |
| 382 | - | destination := &session{id: "destination", name: "destination", tabs: []*tab{tb}, ctx: t.Context()} | |
| 381 | + | source := &session{sessionCore: sessionCore{id: "source", name: "source"}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 382 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 383 | 383 | ||
| 384 | 384 | tb.mu.Lock() | |
| 385 | 385 | generation := tb.beginFloatingWarmLocked(false) | |
| @@ -630,7 +630,7 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) { | |||
| 630 | 630 | d.procCwd = func(int) (string, error) { return tt.liveCwd, nil } | |
| 631 | 631 | } | |
| 632 | 632 | tb := newFloatingTestTab(t) | |
| 633 | - | sess := &session{name: "work", cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()} | |
| 633 | + | sess := &session{sessionCore: sessionCore{name: "work"}, cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()} | |
| 634 | 634 | d.startFloating(sess, tb, false) | |
| 635 | 635 | select { | |
| 636 | 636 | case got := <-opened: | |
| @@ -668,7 +668,7 @@ func TestFloatingLaunchFailureCapturesSessionNameBeforeConcurrentRename(t *testi | |||
| 668 | 668 | d := newTestDaemon(t, factory, stubClock{}) | |
| 669 | 669 | d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled}) | |
| 670 | 670 | tb := newFloatingTestTab(t) | |
| 671 | - | sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()} | |
| 671 | + | sess := &session{sessionCore: sessionCore{name: "captured"}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 672 | 672 | d.startFloating(sess, tb, false) | |
| 673 | 673 | <-opened | |
| 674 | 674 | stopRename := make(chan struct{}) | |
| @@ -706,7 +706,7 @@ func TestFloatingInstallLogsSessionNameSafelyDuringRename(t *testing.T) { | |||
| 706 | 706 | d := newTestDaemon(t, factory, stubClock{}) | |
| 707 | 707 | d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled}) | |
| 708 | 708 | tb := newFloatingTestTab(t) | |
| 709 | - | sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()} | |
| 709 | + | sess := &session{sessionCore: sessionCore{name: "captured"}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 710 | 710 | ||
| 711 | 711 | d.startFloating(sess, tb, false) | |
| 712 | 712 | <-opened | |
| @@ -770,7 +770,7 @@ func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) { | |||
| 770 | 770 | tr, _ := newCapturingTransport(t) | |
| 771 | 771 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 772 | 772 | ac.initOverlays() | |
| 773 | - | sess := &session{id: "floating-spawn", name: "work", tabs: []*tab{tb}, ctx: t.Context(), client: ac} | |
| 773 | + | sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", client: ac}, tabs: []*tab{tb}, ctx: t.Context()} | |
| 774 | 774 | ac.setSession(sess) | |
| 775 | 775 | ||
| 776 | 776 | d.startFloating(sess, tb, tc.userOpen) | |
| @@ -172,7 +172,7 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi | |||||
| 172 | cwd := t.TempDir() | 172 | cwd := t.TempDir() | ||
| 173 | tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40}) | 173 | tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40}) | ||
| 174 | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | 174 | tb.ctx, tb.cancel = context.WithCancel(t.Context()) | ||
| 175 | - | sess := &session{name: "work", cwd: cwd, terminal: terminalEnv{TrueColor: true}, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()} | 175 | + | sess := &session{sessionCore: sessionCore{name: "work"}, cwd: cwd, terminal: terminalEnv{TrueColor: true}, env: []string{"ORDINARY=preserved", "DUP=first", "DUP=second", "PAIR=a=b", "SHELL=/bin/first", "SHELL=/bin/custom-shell", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}, tabs: []*tab{tb}, ctx: t.Context()} |
| 176 | d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}}) | 176 | d.ApplyConfig(domain.Config{Theme: domain.ThemeDark, Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}}) | ||
| 177 | d.ensureFloatingWarm(sess, tb) | 177 | d.ensureFloatingWarm(sess, tb) | ||
| 178 | // Open has started while this goroutine owns tab.mu: an external factory | 178 | // Open has started while this goroutine owns tab.mu: an external factory | ||
| @@ -239,7 +239,7 @@ func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) { | |||||
| 239 | t.Cleanup(cancelTab) | 239 | t.Cleanup(cancelTab) | ||
| 240 | tb := newTab(&transactionalResizePTY{}, tabSize(initial)) | 240 | tb := newTab(&transactionalResizePTY{}, tabSize(initial)) | ||
| 241 | tb.ctx, tb.cancel = tabCtx, cancelTab | 241 | tb.ctx, tb.cancel = tabCtx, cancelTab | ||
| 242 | - | sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()} | 242 | + | sess := &session{sessionCore: sessionCore{id: "responsive-open", name: "work"}, tabs: []*tab{tb}, ctx: t.Context()} |
| 243 | 243 | ||||
| 244 | d.startFloating(sess, tb, true) | 244 | d.startFloating(sess, tb, true) | ||
| 245 | openCall := <-opened | 245 | openCall := <-opened | ||
| @@ -294,7 +294,7 @@ func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) { | |||||
| 294 | d := newTestDaemon(t, factory, stubClock{}) | 294 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 295 | tb := newFloatingTestTab(t) | 295 | tb := newFloatingTestTab(t) | ||
| 296 | sessCtx, cancel := context.WithCancel(t.Context()) | 296 | sessCtx, cancel := context.WithCancel(t.Context()) | ||
| 297 | - | sess := &session{id: "floating-ownership", name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel} | 297 | + | sess := &session{sessionCore: sessionCore{id: "floating-ownership", name: "work"}, tabs: []*tab{tb}, ctx: sessCtx, cancel: cancel} |
| 298 | d.mu.Lock() | 298 | d.mu.Lock() | ||
| 299 | d.sessions[sess.id] = sess | 299 | d.sessions[sess.id] = sess | ||
| 300 | d.mu.Unlock() | 300 | d.mu.Unlock() | ||
| @@ -345,7 +345,7 @@ func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *test | |||||
| 345 | factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil) | 345 | factory, opened, allowOpen := newGatedOpenFactory(t, pty, nil) | ||
| 346 | d := newTestDaemon(t, factory, stubClock{}) | 346 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 347 | tb := newFloatingTestTab(t) | 347 | tb := newFloatingTestTab(t) | ||
| 348 | - | sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()} | 348 | + | sess := &session{sessionCore: sessionCore{name: "work"}, tabs: []*tab{tb}, ctx: t.Context()} |
| 349 | d.ensureFloatingWarm(sess, tb) | 349 | d.ensureFloatingWarm(sess, tb) | ||
| 350 | <-opened | 350 | <-opened | ||
| 351 | d.teardownFloating(tb, nil) // invalidate before the delayed Open returns | 351 | d.teardownFloating(tb, nil) // invalidate before the delayed Open returns | ||
| @@ -378,8 +378,8 @@ func TestFloatingExitUsesCurrentOwnerAfterTabTransfer(t *testing.T) { | |||||
| 378 | factory := &lifetimePTYFactory{} | 378 | factory := &lifetimePTYFactory{} | ||
| 379 | d := newTestDaemon(t, factory, stubClock{}) | 379 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 380 | tb := newFloatingTestTab(t) | 380 | tb := newFloatingTestTab(t) | ||
| 381 | - | source := &session{id: "source", name: "source", tabs: []*tab{tb}, ctx: t.Context()} | 381 | + | source := &session{sessionCore: sessionCore{id: "source", name: "source"}, tabs: []*tab{tb}, ctx: t.Context()} |
| 382 | - | destination := &session{id: "destination", name: "destination", tabs: []*tab{tb}, ctx: t.Context()} | 382 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{tb}, ctx: t.Context()} |
| 383 | 383 | ||||
| 384 | tb.mu.Lock() | 384 | tb.mu.Lock() | ||
| 385 | generation := tb.beginFloatingWarmLocked(false) | 385 | generation := tb.beginFloatingWarmLocked(false) | ||
| @@ -630,7 +630,7 @@ func TestFloatingLaunchUsesLiveOrValidatedSessionCwd(t *testing.T) { | |||||
| 630 | d.procCwd = func(int) (string, error) { return tt.liveCwd, nil } | 630 | d.procCwd = func(int) (string, error) { return tt.liveCwd, nil } | ||
| 631 | } | 631 | } | ||
| 632 | tb := newFloatingTestTab(t) | 632 | tb := newFloatingTestTab(t) | ||
| 633 | - | sess := &session{name: "work", cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()} | 633 | + | sess := &session{sessionCore: sessionCore{name: "work"}, cwd: tt.sessionCwd, tabs: []*tab{tb}, ctx: t.Context()} |
| 634 | d.startFloating(sess, tb, false) | 634 | d.startFloating(sess, tb, false) | ||
| 635 | select { | 635 | select { | ||
| 636 | case got := <-opened: | 636 | case got := <-opened: | ||
| @@ -668,7 +668,7 @@ func TestFloatingLaunchFailureCapturesSessionNameBeforeConcurrentRename(t *testi | |||||
| 668 | d := newTestDaemon(t, factory, stubClock{}) | 668 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 669 | d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled}) | 669 | d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled}) | ||
| 670 | tb := newFloatingTestTab(t) | 670 | tb := newFloatingTestTab(t) | ||
| 671 | - | sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()} | 671 | + | sess := &session{sessionCore: sessionCore{name: "captured"}, tabs: []*tab{tb}, ctx: t.Context()} |
| 672 | d.startFloating(sess, tb, false) | 672 | d.startFloating(sess, tb, false) | ||
| 673 | <-opened | 673 | <-opened | ||
| 674 | stopRename := make(chan struct{}) | 674 | stopRename := make(chan struct{}) | ||
| @@ -706,7 +706,7 @@ func TestFloatingInstallLogsSessionNameSafelyDuringRename(t *testing.T) { | |||||
| 706 | d := newTestDaemon(t, factory, stubClock{}) | 706 | d := newTestDaemon(t, factory, stubClock{}) | ||
| 707 | d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled}) | 707 | d.log = slog.New(&floatingLogHandler{Handler: slog.NewTextHandler(&logs, nil), handled: handled}) | ||
| 708 | tb := newFloatingTestTab(t) | 708 | tb := newFloatingTestTab(t) | ||
| 709 | - | sess := &session{name: "captured", tabs: []*tab{tb}, ctx: t.Context()} | 709 | + | sess := &session{sessionCore: sessionCore{name: "captured"}, tabs: []*tab{tb}, ctx: t.Context()} |
| 710 | 710 | ||||
| 711 | d.startFloating(sess, tb, false) | 711 | d.startFloating(sess, tb, false) | ||
| 712 | <-opened | 712 | <-opened | ||
| @@ -770,7 +770,7 @@ func TestFloatingAsyncSpawnFailureToastsOnlyForUserOpen(t *testing.T) { | |||||
| 770 | tr, _ := newCapturingTransport(t) | 770 | tr, _ := newCapturingTransport(t) | ||
| 771 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 771 | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 772 | ac.initOverlays() | 772 | ac.initOverlays() | ||
| 773 | - | sess := &session{id: "floating-spawn", name: "work", tabs: []*tab{tb}, ctx: t.Context(), client: ac} | 773 | + | sess := &session{sessionCore: sessionCore{id: "floating-spawn", name: "work", client: ac}, tabs: []*tab{tb}, ctx: t.Context()} |
| 774 | ac.setSession(sess) | 774 | ac.setSession(sess) | ||
| 775 | 775 | ||||
| 776 | d.startFloating(sess, tb, tc.userOpen) | 776 | d.startFloating(sess, tb, tc.userOpen) | ||
M internal/usecase/daemon/input.go +8 -2
| @@ -310,7 +310,8 @@ type daemonKeyHandler struct { | |||
| 310 | 310 | func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) { | |
| 311 | 311 | if h.roleToken.ac != nil { | |
| 312 | 312 | if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() { | |
| 313 | - | return h.roleToken.sess, effect, false | |
| 313 | + | sess, _ := localSession(h.roleToken.sess) | |
| 314 | + | return sess, effect, false | |
| 314 | 315 | } | |
| 315 | 316 | effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken) | |
| 316 | 317 | if h.d.afterDelayedKeyEffectAttempt != nil { | |
| @@ -324,7 +325,12 @@ func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool | |||
| 324 | 325 | token.effect = effect | |
| 325 | 326 | h.d.afterRoleEffectAdmitted(token) | |
| 326 | 327 | } | |
| 327 | - | return h.roleToken.sess, effect, true | |
| 328 | + | sess, ok := localSession(h.roleToken.sess) | |
| 329 | + | if !ok { | |
| 330 | + | effect.End() | |
| 331 | + | return nil, nil, false | |
| 332 | + | } | |
| 333 | + | return sess, effect, true | |
| 328 | 334 | } | |
| 329 | 335 | sess := h.ac.currentSession() | |
| 330 | 336 | if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched { | |
| @@ -310,7 +310,8 @@ type daemonKeyHandler struct { | |||||
| 310 | func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) { | 310 | func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool) { | ||
| 311 | if h.roleToken.ac != nil { | 311 | if h.roleToken.ac != nil { | ||
| 312 | if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() { | 312 | if effect := h.roleToken.effect; effect != nil && !effect.ended.Load() { | ||
| 313 | - | return h.roleToken.sess, effect, false | 313 | + | sess, _ := localSession(h.roleToken.sess) |
| 314 | + | return sess, effect, false | |||
| 314 | } | 315 | } | ||
| 315 | effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken) | 316 | effect, admitted := h.roleToken.ac.beginRoleEffect(h.roleToken) | ||
| 316 | if h.d.afterDelayedKeyEffectAttempt != nil { | 317 | if h.d.afterDelayedKeyEffectAttempt != nil { | ||
| @@ -324,7 +325,12 @@ func (h daemonKeyHandler) acquireRoleEffect() (*session, *roleEffectTicket, bool | |||||
| 324 | token.effect = effect | 325 | token.effect = effect | ||
| 325 | h.d.afterRoleEffectAdmitted(token) | 326 | h.d.afterRoleEffectAdmitted(token) | ||
| 326 | } | 327 | } | ||
| 327 | - | return h.roleToken.sess, effect, true | 328 | + | sess, ok := localSession(h.roleToken.sess) |
| 329 | + | if !ok { | |||
| 330 | + | effect.End() | |||
| 331 | + | return nil, nil, false | |||
| 332 | + | } | |||
| 333 | + | return sess, effect, true | |||
| 328 | } | 334 | } | ||
| 329 | sess := h.ac.currentSession() | 335 | sess := h.ac.currentSession() | ||
| 330 | if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched { | 336 | if sess == nil || sess.attachmentRole(h.ac) == attachmentSnatched { | ||
M internal/usecase/daemon/input_test.go +13 -11
| @@ -354,8 +354,10 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||
| 354 | 354 | d.mu.Lock() | |
| 355 | 355 | var sess *session | |
| 356 | 356 | for _, candidate := range d.sessions { | |
| 357 | - | sess = candidate | |
| 358 | - | break | |
| 357 | + | if local, ok := localSession(candidate); ok { | |
| 358 | + | sess = local | |
| 359 | + | break | |
| 360 | + | } | |
| 359 | 361 | } | |
| 360 | 362 | d.mu.Unlock() | |
| 361 | 363 | require.NotNil(t, sess) | |
| @@ -971,7 +973,7 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) { | |||
| 971 | 973 | func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) { | |
| 972 | 974 | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 973 | 975 | defer releaseAll(releases) | |
| 974 | - | recent := d.sessions[domain.SessionID("recent")] | |
| 976 | + | recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) | |
| 975 | 977 | ||
| 976 | 978 | // Picker-style successful transition records its origin. | |
| 977 | 979 | require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1})) | |
| @@ -989,7 +991,7 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) { | |||
| 989 | 991 | prepare func() | |
| 990 | 992 | }{ | |
| 991 | 993 | {name: "no target", prepare: func() {}}, | |
| 992 | - | {name: "stale target", prepare: func() { ac.previousSession.Set(&session{id: "gone"}) }}, | |
| 994 | + | {name: "stale target", prepare: func() { ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}}) }}, | |
| 993 | 995 | {name: "same session", prepare: func() { ac.previousSession.Set(current) }}, | |
| 994 | 996 | } { | |
| 995 | 997 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -1027,8 +1029,8 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) { | |||
| 1027 | 1029 | ||
| 1028 | 1030 | func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) { | |
| 1029 | 1031 | ac := &attachedClient{} | |
| 1030 | - | stale := &session{id: "stale"} | |
| 1031 | - | updated := &session{id: "updated"} | |
| 1032 | + | stale := &session{sessionCore: sessionCore{id: "stale"}} | |
| 1033 | + | updated := &session{sessionCore: sessionCore{id: "updated"}} | |
| 1032 | 1034 | ac.previousSession.Set(stale) | |
| 1033 | 1035 | ||
| 1034 | 1036 | // Model a completed hand-off between observing a stale target and clearing | |
| @@ -1051,7 +1053,7 @@ func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) { | |||
| 1051 | 1053 | }{ | |
| 1052 | 1054 | {name: "no previous target", prepare: func(*session, *attachedClient) {}}, | |
| 1053 | 1055 | {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) { | |
| 1054 | - | ac.previousSession.Set(&session{id: "gone"}) | |
| 1056 | + | ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}}) | |
| 1055 | 1057 | }}, | |
| 1056 | 1058 | {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) { | |
| 1057 | 1059 | ac.previousSession.Set(current) | |
| @@ -1121,7 +1123,7 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) { | |||
| 1121 | 1123 | t.Run(tc.name, func(t *testing.T) { | |
| 1122 | 1124 | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 1123 | 1125 | defer releaseAll(releases) | |
| 1124 | - | recent := d.sessions[domain.SessionID("recent")] | |
| 1126 | + | recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) | |
| 1125 | 1127 | ||
| 1126 | 1128 | if tc.name == "missing target does not record" { | |
| 1127 | 1129 | require.Error(t, d.switchToTarget(current, ac, tc.target(current, recent))) | |
| @@ -1133,7 +1135,7 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) { | |||
| 1133 | 1135 | require.Nil(t, ac.previousSession.Get()) | |
| 1134 | 1136 | return | |
| 1135 | 1137 | } | |
| 1136 | - | require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get().id) | |
| 1138 | + | require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get().core().id) | |
| 1137 | 1139 | }) | |
| 1138 | 1140 | } | |
| 1139 | 1141 | } | |
| @@ -1170,8 +1172,8 @@ func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attached | |||
| 1170 | 1172 | current.id = "current" | |
| 1171 | 1173 | delete(d.sessions, domain.SessionID("manual")) | |
| 1172 | 1174 | d.sessions[current.id] = current | |
| 1173 | - | recent := &session{id: "recent", name: "recent", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} | |
| 1174 | - | older := &session{id: "older", name: "older", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}} | |
| 1175 | + | recent := &session{sessionCore: sessionCore{id: "recent", name: "recent"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} | |
| 1176 | + | older := &session{sessionCore: sessionCore{id: "older", name: "older"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}} | |
| 1175 | 1177 | d.sessions[recent.id] = recent | |
| 1176 | 1178 | d.sessions[older.id] = older | |
| 1177 | 1179 | current.mruAt.Store(30) | |
| @@ -354,8 +354,10 @@ func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) { | |||||
| 354 | d.mu.Lock() | 354 | d.mu.Lock() | ||
| 355 | var sess *session | 355 | var sess *session | ||
| 356 | for _, candidate := range d.sessions { | 356 | for _, candidate := range d.sessions { | ||
| 357 | - | sess = candidate | 357 | + | if local, ok := localSession(candidate); ok { |
| 358 | - | break | 358 | + | sess = local |
| 359 | + | break | |||
| 360 | + | } | |||
| 359 | } | 361 | } | ||
| 360 | d.mu.Unlock() | 362 | d.mu.Unlock() | ||
| 361 | require.NotNil(t, sess) | 363 | require.NotNil(t, sess) | ||
| @@ -971,7 +973,7 @@ func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) { | |||||
| 971 | func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) { | 973 | func TestPaletteBackSessionTogglesPreviousSession(t *testing.T) { | ||
| 972 | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | 974 | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | ||
| 973 | defer releaseAll(releases) | 975 | defer releaseAll(releases) | ||
| 974 | - | recent := d.sessions[domain.SessionID("recent")] | 976 | + | recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) |
| 975 | 977 | ||||
| 976 | // Picker-style successful transition records its origin. | 978 | // Picker-style successful transition records its origin. | ||
| 977 | require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1})) | 979 | require.NoError(t, d.switchToTarget(current, ac, picker.Target{Session: recent.id, TabIndex: -1})) | ||
| @@ -989,7 +991,7 @@ func TestPaletteBackSessionDoesNotMoveWithoutValidTarget(t *testing.T) { | |||||
| 989 | prepare func() | 991 | prepare func() | ||
| 990 | }{ | 992 | }{ | ||
| 991 | {name: "no target", prepare: func() {}}, | 993 | {name: "no target", prepare: func() {}}, | ||
| 992 | - | {name: "stale target", prepare: func() { ac.previousSession.Set(&session{id: "gone"}) }}, | 994 | + | {name: "stale target", prepare: func() { ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}}) }}, |
| 993 | {name: "same session", prepare: func() { ac.previousSession.Set(current) }}, | 995 | {name: "same session", prepare: func() { ac.previousSession.Set(current) }}, | ||
| 994 | } { | 996 | } { | ||
| 995 | t.Run(tc.name, func(t *testing.T) { | 997 | t.Run(tc.name, func(t *testing.T) { | ||
| @@ -1027,8 +1029,8 @@ func TestBackSessionReportsStaleHandoffOnce(t *testing.T) { | |||||
| 1027 | 1029 | ||||
| 1028 | func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) { | 1030 | func TestStaleBackSessionClearPreservesConcurrentTarget(t *testing.T) { | ||
| 1029 | ac := &attachedClient{} | 1031 | ac := &attachedClient{} | ||
| 1030 | - | stale := &session{id: "stale"} | 1032 | + | stale := &session{sessionCore: sessionCore{id: "stale"}} |
| 1031 | - | updated := &session{id: "updated"} | 1033 | + | updated := &session{sessionCore: sessionCore{id: "updated"}} |
| 1032 | ac.previousSession.Set(stale) | 1034 | ac.previousSession.Set(stale) | ||
| 1033 | 1035 | ||||
| 1034 | // Model a completed hand-off between observing a stale target and clearing | 1036 | // Model a completed hand-off between observing a stale target and clearing | ||
| @@ -1051,7 +1053,7 @@ func TestBackSessionInvalidTargetsFallBackThroughOneInvalidation(t *testing.T) { | |||||
| 1051 | }{ | 1053 | }{ | ||
| 1052 | {name: "no previous target", prepare: func(*session, *attachedClient) {}}, | 1054 | {name: "no previous target", prepare: func(*session, *attachedClient) {}}, | ||
| 1053 | {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) { | 1055 | {name: "stale previous target", prepare: func(_ *session, ac *attachedClient) { | ||
| 1054 | - | ac.previousSession.Set(&session{id: "gone"}) | 1056 | + | ac.previousSession.Set(&session{sessionCore: sessionCore{id: "gone"}}) |
| 1055 | }}, | 1057 | }}, | ||
| 1056 | {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) { | 1058 | {name: "previous target equals current", prepare: func(current *session, ac *attachedClient) { | ||
| 1057 | ac.previousSession.Set(current) | 1059 | ac.previousSession.Set(current) | ||
| @@ -1121,7 +1123,7 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) { | |||||
| 1121 | t.Run(tc.name, func(t *testing.T) { | 1123 | t.Run(tc.name, func(t *testing.T) { | ||
| 1122 | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | 1124 | d, current, ac, _, releases := newRecentNavigationTestSessions(t) | ||
| 1123 | defer releaseAll(releases) | 1125 | defer releaseAll(releases) | ||
| 1124 | - | recent := d.sessions[domain.SessionID("recent")] | 1126 | + | recent := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) |
| 1125 | 1127 | ||||
| 1126 | if tc.name == "missing target does not record" { | 1128 | if tc.name == "missing target does not record" { | ||
| 1127 | require.Error(t, d.switchToTarget(current, ac, tc.target(current, recent))) | 1129 | require.Error(t, d.switchToTarget(current, ac, tc.target(current, recent))) | ||
| @@ -1133,7 +1135,7 @@ func TestSwitchSourcePreviousSessionContracts(t *testing.T) { | |||||
| 1133 | require.Nil(t, ac.previousSession.Get()) | 1135 | require.Nil(t, ac.previousSession.Get()) | ||
| 1134 | return | 1136 | return | ||
| 1135 | } | 1137 | } | ||
| 1136 | - | require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get().id) | 1138 | + | require.Equal(t, domain.SessionID(tc.wantPrev), ac.previousSession.Get().core().id) |
| 1137 | }) | 1139 | }) | ||
| 1138 | } | 1140 | } | ||
| 1139 | } | 1141 | } | ||
| @@ -1170,8 +1172,8 @@ func newRecentNavigationTestSessions(t *testing.T) (*Daemon, *session, *attached | |||||
| 1170 | current.id = "current" | 1172 | current.id = "current" | ||
| 1171 | delete(d.sessions, domain.SessionID("manual")) | 1173 | delete(d.sessions, domain.SessionID("manual")) | ||
| 1172 | d.sessions[current.id] = current | 1174 | d.sessions[current.id] = current | ||
| 1173 | - | recent := &session{id: "recent", name: "recent", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} | 1175 | + | recent := &session{sessionCore: sessionCore{id: "recent", name: "recent"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} |
| 1174 | - | older := &session{id: "older", name: "older", ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}} | 1176 | + | older := &session{sessionCore: sessionCore{id: "older", name: "older"}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{newTab(p3, domain.Size{Cols: 80, Rows: 23})}} |
| 1175 | d.sessions[recent.id] = recent | 1177 | d.sessions[recent.id] = recent | ||
| 1176 | d.sessions[older.id] = older | 1178 | d.sessions[older.id] = older | ||
| 1177 | current.mruAt.Store(30) | 1179 | current.mruAt.Store(30) | ||
M internal/usecase/daemon/move.go +2 -2
| @@ -238,8 +238,8 @@ func moveSessionForLocatorLocked(d *Daemon, locator moveSessionLocator) *session | |||
| 238 | 238 | if d == nil { | |
| 239 | 239 | return nil | |
| 240 | 240 | } | |
| 241 | - | sess := d.sessions[locator.ID] | |
| 242 | - | if sess == nil || sess.incarnation != locator.Incarnation { | |
| 241 | + | sess, ok := localSession(d.sessions[locator.ID]) | |
| 242 | + | if !ok || sess.incarnation != locator.Incarnation { | |
| 243 | 243 | return nil | |
| 244 | 244 | } | |
| 245 | 245 | return sess | |
| @@ -238,8 +238,8 @@ func moveSessionForLocatorLocked(d *Daemon, locator moveSessionLocator) *session | |||||
| 238 | if d == nil { | 238 | if d == nil { | ||
| 239 | return nil | 239 | return nil | ||
| 240 | } | 240 | } | ||
| 241 | - | sess := d.sessions[locator.ID] | 241 | + | sess, ok := localSession(d.sessions[locator.ID]) |
| 242 | - | if sess == nil || sess.incarnation != locator.Incarnation { | 242 | + | if !ok || sess.incarnation != locator.Incarnation { |
| 243 | return nil | 243 | return nil | ||
| 244 | } | 244 | } | ||
| 245 | return sess | 245 | return sess | ||
M internal/usecase/daemon/move_acceptance_test.go +3 -12
| @@ -89,10 +89,7 @@ func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T | |||
| 89 | 89 | ||
| 90 | 90 | destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23}) | |
| 91 | 91 | destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23}) | |
| 92 | - | destination := &session{ | |
| 93 | - | id: "destination", name: "destination", ephemeral: true, | |
| 94 | - | tabs: []*tab{destinationActive, destinationTab}, active: 0, | |
| 95 | - | } | |
| 92 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{destinationActive, destinationTab}, active: 0} | |
| 96 | 93 | publishTiledPaneOwners(destination, destinationActive) | |
| 97 | 94 | publishTiledPaneOwners(destination, destinationTab) | |
| 98 | 95 | d.mu.Lock() | |
| @@ -185,10 +182,7 @@ func TestMovePaneRejectsStaleIncarnationWithoutMutation(t *testing.T) { | |||
| 185 | 182 | sourceTab := source.tabs[0] | |
| 186 | 183 | sourceTab.stableID = "source-tab" | |
| 187 | 184 | moved := sourceTab.focusedPane() | |
| 188 | - | destination := &session{ | |
| 189 | - | id: "destination", name: "destination", ephemeral: true, | |
| 190 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0, | |
| 191 | - | } | |
| 185 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 192 | 186 | destinationTab := destination.tabs[0] | |
| 193 | 187 | publishTiledPaneOwners(destination, destinationTab) | |
| 194 | 188 | d.mu.Lock() | |
| @@ -237,10 +231,7 @@ func TestMovePaneRejectsStaleLayoutGenerationBeforeMutation(t *testing.T) { | |||
| 237 | 231 | sourceTab := source.tabs[0] | |
| 238 | 232 | sourceTab.stableID = "source-tab" | |
| 239 | 233 | moved := sourceTab.focusedPane() | |
| 240 | - | destination := &session{ | |
| 241 | - | id: "destination", name: "destination", ephemeral: true, | |
| 242 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0, | |
| 243 | - | } | |
| 234 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 244 | 235 | destinationTab := destination.tabs[0] | |
| 245 | 236 | publishTiledPaneOwners(destination, destinationTab) | |
| 246 | 237 | d.mu.Lock() | |
| @@ -89,10 +89,7 @@ func TestMovePaneAcceptanceRetainsSourceFocusAndDestinationActivity(t *testing.T | |||||
| 89 | 89 | ||||
| 90 | destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23}) | 90 | destinationActive := newTabWithStableID("destination-active", "active-pane", activePTY, domain.Size{Cols: 80, Rows: 23}) | ||
| 91 | destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23}) | 91 | destinationTab := newTabWithStableID("destination-target", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23}) | ||
| 92 | - | destination := &session{ | 92 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{destinationActive, destinationTab}, active: 0} |
| 93 | - | id: "destination", name: "destination", ephemeral: true, | |||
| 94 | - | tabs: []*tab{destinationActive, destinationTab}, active: 0, | |||
| 95 | - | } | |||
| 96 | publishTiledPaneOwners(destination, destinationActive) | 93 | publishTiledPaneOwners(destination, destinationActive) | ||
| 97 | publishTiledPaneOwners(destination, destinationTab) | 94 | publishTiledPaneOwners(destination, destinationTab) | ||
| 98 | d.mu.Lock() | 95 | d.mu.Lock() | ||
| @@ -185,10 +182,7 @@ func TestMovePaneRejectsStaleIncarnationWithoutMutation(t *testing.T) { | |||||
| 185 | sourceTab := source.tabs[0] | 182 | sourceTab := source.tabs[0] | ||
| 186 | sourceTab.stableID = "source-tab" | 183 | sourceTab.stableID = "source-tab" | ||
| 187 | moved := sourceTab.focusedPane() | 184 | moved := sourceTab.focusedPane() | ||
| 188 | - | destination := &session{ | 185 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0} |
| 189 | - | id: "destination", name: "destination", ephemeral: true, | |||
| 190 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0, | |||
| 191 | - | } | |||
| 192 | destinationTab := destination.tabs[0] | 186 | destinationTab := destination.tabs[0] | ||
| 193 | publishTiledPaneOwners(destination, destinationTab) | 187 | publishTiledPaneOwners(destination, destinationTab) | ||
| 194 | d.mu.Lock() | 188 | d.mu.Lock() | ||
| @@ -237,10 +231,7 @@ func TestMovePaneRejectsStaleLayoutGenerationBeforeMutation(t *testing.T) { | |||||
| 237 | sourceTab := source.tabs[0] | 231 | sourceTab := source.tabs[0] | ||
| 238 | sourceTab.stableID = "source-tab" | 232 | sourceTab.stableID = "source-tab" | ||
| 239 | moved := sourceTab.focusedPane() | 233 | moved := sourceTab.focusedPane() | ||
| 240 | - | destination := &session{ | 234 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0} |
| 241 | - | id: "destination", name: "destination", ephemeral: true, | |||
| 242 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0, | |||
| 243 | - | } | |||
| 244 | destinationTab := destination.tabs[0] | 235 | destinationTab := destination.tabs[0] | ||
| 245 | publishTiledPaneOwners(destination, destinationTab) | 236 | publishTiledPaneOwners(destination, destinationTab) | ||
| 246 | d.mu.Lock() | 237 | d.mu.Lock() | ||
M internal/usecase/daemon/move_atomicity_test.go +3 -5
| @@ -84,12 +84,10 @@ func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) { | |||
| 84 | 84 | sourceTab.stableID = "source-tab" | |
| 85 | 85 | movedPane := sourceTab.focusedPane() | |
| 86 | 86 | ||
| 87 | - | destination := &session{ | |
| 88 | - | id: "destination", | |
| 87 | + | destination := &session{sessionCore: sessionCore{id: "destination", | |
| 89 | 88 | name: "destination", | |
| 90 | - | ephemeral: true, | |
| 91 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})}, | |
| 92 | - | active: 0, | |
| 89 | + | ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})}, | |
| 90 | + | active: 0, | |
| 93 | 91 | } | |
| 94 | 92 | destinationTab := destination.tabs[0] | |
| 95 | 93 | publishTiledPaneOwners(destination, destinationTab) | |
| @@ -84,12 +84,10 @@ func TestMovePaneCommitPointHidesPartialPublication(t *testing.T) { | |||||
| 84 | sourceTab.stableID = "source-tab" | 84 | sourceTab.stableID = "source-tab" | ||
| 85 | movedPane := sourceTab.focusedPane() | 85 | movedPane := sourceTab.focusedPane() | ||
| 86 | 86 | ||||
| 87 | - | destination := &session{ | 87 | + | destination := &session{sessionCore: sessionCore{id: "destination", |
| 88 | - | id: "destination", | |||
| 89 | name: "destination", | 88 | name: "destination", | ||
| 90 | - | ephemeral: true, | 89 | + | ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})}, |
| 91 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})}, | 90 | + | active: 0, |
| 92 | - | active: 0, | |||
| 93 | } | 91 | } | ||
| 94 | destinationTab := destination.tabs[0] | 92 | destinationTab := destination.tabs[0] | ||
| 95 | publishTiledPaneOwners(destination, destinationTab) | 93 | publishTiledPaneOwners(destination, destinationTab) | ||
M internal/usecase/daemon/move_cleanup_test.go +2 -6
| @@ -26,9 +26,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) { | |||
| 26 | 26 | client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}} | |
| 27 | 27 | client.sendMu.Unlock() | |
| 28 | 28 | ||
| 29 | - | destination := &session{ | |
| 30 | - | id: "destination", name: "destination", ephemeral: true, | |
| 31 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 29 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 32 | 30 | active: 0, | |
| 33 | 31 | } | |
| 34 | 32 | destinationTab := destination.tabs[0] | |
| @@ -106,9 +104,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) { | |||
| 106 | 104 | moved := sourceTab.focusedPane() | |
| 107 | 105 | require.NotNil(t, d.attachCoordinator(source, nil, displaced, true)) | |
| 108 | 106 | ||
| 109 | - | destination := &session{ | |
| 110 | - | id: "destination", name: "destination", ephemeral: true, | |
| 111 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 107 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 112 | 108 | active: 0, | |
| 113 | 109 | } | |
| 114 | 110 | destinationTab := destination.tabs[0] | |
| @@ -26,9 +26,7 @@ func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) { | |||||
| 26 | client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}} | 26 | client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}} | ||
| 27 | client.sendMu.Unlock() | 27 | client.sendMu.Unlock() | ||
| 28 | 28 | ||||
| 29 | - | destination := &session{ | 29 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, |
| 30 | - | id: "destination", name: "destination", ephemeral: true, | |||
| 31 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |||
| 32 | active: 0, | 30 | active: 0, | ||
| 33 | } | 31 | } | ||
| 34 | destinationTab := destination.tabs[0] | 32 | destinationTab := destination.tabs[0] | ||
| @@ -106,9 +104,7 @@ func TestMovePaneCleanupUsesCommitPointSourceAttachmentToken(t *testing.T) { | |||||
| 106 | moved := sourceTab.focusedPane() | 104 | moved := sourceTab.focusedPane() | ||
| 107 | require.NotNil(t, d.attachCoordinator(source, nil, displaced, true)) | 105 | require.NotNil(t, d.attachCoordinator(source, nil, displaced, true)) | ||
| 108 | 106 | ||||
| 109 | - | destination := &session{ | 107 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, |
| 110 | - | id: "destination", name: "destination", ephemeral: true, | |||
| 111 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |||
| 112 | active: 0, | 108 | active: 0, | ||
| 113 | } | 109 | } | ||
| 114 | destinationTab := destination.tabs[0] | 110 | destinationTab := destination.tabs[0] | ||
M internal/usecase/daemon/move_gate_race_test.go +1 -3
| @@ -21,9 +21,7 @@ func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *sess | |||
| 21 | 21 | moved := sourceTab.focusedPane() | |
| 22 | 22 | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 23 | 23 | ||
| 24 | - | destination := &session{ | |
| 25 | - | id: "destination", name: "destination", ephemeral: true, | |
| 26 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 24 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 27 | 25 | active: 0, | |
| 28 | 26 | } | |
| 29 | 27 | destinationTab := destination.tabs[0] | |
| @@ -21,9 +21,7 @@ func newMoveGateRaceFixture(t *testing.T) (*Daemon, *session, *tab, *pane, *sess | |||||
| 21 | moved := sourceTab.focusedPane() | 21 | moved := sourceTab.focusedPane() | ||
| 22 | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | 22 | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | ||
| 23 | 23 | ||||
| 24 | - | destination := &session{ | 24 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, |
| 25 | - | id: "destination", name: "destination", ephemeral: true, | |||
| 26 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |||
| 27 | active: 0, | 25 | active: 0, | ||
| 28 | } | 26 | } | ||
| 29 | destinationTab := destination.tabs[0] | 27 | destinationTab := destination.tabs[0] | ||
M internal/usecase/daemon/move_handoff_failure_test.go +7 -9
| @@ -70,7 +70,7 @@ type movePaneState struct { | |||
| 70 | 70 | ||
| 71 | 71 | type moveState struct { | |
| 72 | 72 | closing bool | |
| 73 | - | registry map[domain.SessionID]*session | |
| 73 | + | registry map[domain.SessionID]attachmentSession | |
| 74 | 74 | source moveSessionState | |
| 75 | 75 | destination moveSessionState | |
| 76 | 76 | sourceTab moveTabState | |
| @@ -87,7 +87,7 @@ func captureMoveState(d *Daemon, source, destination *session, sourceTab, destin | |||
| 87 | 87 | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | |
| 88 | 88 | movedPane.mu.Lock() | |
| 89 | 89 | ||
| 90 | - | registry := make(map[domain.SessionID]*session, len(d.sessions)) | |
| 90 | + | registry := make(map[domain.SessionID]attachmentSession, len(d.sessions)) | |
| 91 | 91 | maps.Copy(registry, d.sessions) | |
| 92 | 92 | state := moveState{ | |
| 93 | 93 | closing: d.closing, | |
| @@ -207,12 +207,10 @@ func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |||
| 207 | 207 | sourceTab.stableID = "source-tab" | |
| 208 | 208 | movedPane := sourceTab.focusedPane() | |
| 209 | 209 | ||
| 210 | - | destination := &session{ | |
| 211 | - | id: "destination", | |
| 210 | + | destination := &session{sessionCore: sessionCore{id: "destination", | |
| 212 | 211 | name: "destination", | |
| 213 | - | ephemeral: true, | |
| 214 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})}, | |
| 215 | - | active: 0, | |
| 212 | + | ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})}, | |
| 213 | + | active: 0, | |
| 216 | 214 | } | |
| 217 | 215 | destinationTab := destination.tabs[0] | |
| 218 | 216 | publishTiledPaneOwners(destination, destinationTab) | |
| @@ -261,7 +259,7 @@ func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |||
| 261 | 259 | ||
| 262 | 260 | type moveTabHandoffState struct { | |
| 263 | 261 | closing bool | |
| 264 | - | registry map[domain.SessionID]*session | |
| 262 | + | registry map[domain.SessionID]attachmentSession | |
| 265 | 263 | source moveSessionState | |
| 266 | 264 | destination moveSessionState | |
| 267 | 265 | movedTab moveTabState | |
| @@ -275,7 +273,7 @@ func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved * | |||
| 275 | 273 | unlockSessions := lockAttachmentSessions(source, destination) | |
| 276 | 274 | moved.mu.Lock() | |
| 277 | 275 | ||
| 278 | - | registry := make(map[domain.SessionID]*session, len(d.sessions)) | |
| 276 | + | registry := make(map[domain.SessionID]attachmentSession, len(d.sessions)) | |
| 279 | 277 | maps.Copy(registry, d.sessions) | |
| 280 | 278 | state := moveTabHandoffState{ | |
| 281 | 279 | closing: d.closing, | |
| @@ -70,7 +70,7 @@ type movePaneState struct { | |||||
| 70 | 70 | ||||
| 71 | type moveState struct { | 71 | type moveState struct { | ||
| 72 | closing bool | 72 | closing bool | ||
| 73 | - | registry map[domain.SessionID]*session | 73 | + | registry map[domain.SessionID]attachmentSession |
| 74 | source moveSessionState | 74 | source moveSessionState | ||
| 75 | destination moveSessionState | 75 | destination moveSessionState | ||
| 76 | sourceTab moveTabState | 76 | sourceTab moveTabState | ||
| @@ -87,7 +87,7 @@ func captureMoveState(d *Daemon, source, destination *session, sourceTab, destin | |||||
| 87 | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | 87 | unlockTabs := lockMoveTabs(sourceTab, destinationTab) | ||
| 88 | movedPane.mu.Lock() | 88 | movedPane.mu.Lock() | ||
| 89 | 89 | ||||
| 90 | - | registry := make(map[domain.SessionID]*session, len(d.sessions)) | 90 | + | registry := make(map[domain.SessionID]attachmentSession, len(d.sessions)) |
| 91 | maps.Copy(registry, d.sessions) | 91 | maps.Copy(registry, d.sessions) | ||
| 92 | state := moveState{ | 92 | state := moveState{ | ||
| 93 | closing: d.closing, | 93 | closing: d.closing, | ||
| @@ -207,12 +207,10 @@ func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |||||
| 207 | sourceTab.stableID = "source-tab" | 207 | sourceTab.stableID = "source-tab" | ||
| 208 | movedPane := sourceTab.focusedPane() | 208 | movedPane := sourceTab.focusedPane() | ||
| 209 | 209 | ||||
| 210 | - | destination := &session{ | 210 | + | destination := &session{sessionCore: sessionCore{id: "destination", |
| 211 | - | id: "destination", | |||
| 212 | name: "destination", | 211 | name: "destination", | ||
| 213 | - | ephemeral: true, | 212 | + | ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})}, |
| 214 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", p2, domain.Size{Cols: 80, Rows: 23})}, | 213 | + | active: 0, |
| 215 | - | active: 0, | |||
| 216 | } | 214 | } | ||
| 217 | destinationTab := destination.tabs[0] | 215 | destinationTab := destination.tabs[0] | ||
| 218 | publishTiledPaneOwners(destination, destinationTab) | 216 | publishTiledPaneOwners(destination, destinationTab) | ||
| @@ -261,7 +259,7 @@ func TestMovePaneFinalSourceHandoffValidationFailureIsAtomic(t *testing.T) { | |||||
| 261 | 259 | ||||
| 262 | type moveTabHandoffState struct { | 260 | type moveTabHandoffState struct { | ||
| 263 | closing bool | 261 | closing bool | ||
| 264 | - | registry map[domain.SessionID]*session | 262 | + | registry map[domain.SessionID]attachmentSession |
| 265 | source moveSessionState | 263 | source moveSessionState | ||
| 266 | destination moveSessionState | 264 | destination moveSessionState | ||
| 267 | movedTab moveTabState | 265 | movedTab moveTabState | ||
| @@ -275,7 +273,7 @@ func captureMoveTabHandoffState(d *Daemon, source, destination *session, moved * | |||||
| 275 | unlockSessions := lockAttachmentSessions(source, destination) | 273 | unlockSessions := lockAttachmentSessions(source, destination) | ||
| 276 | moved.mu.Lock() | 274 | moved.mu.Lock() | ||
| 277 | 275 | ||||
| 278 | - | registry := make(map[domain.SessionID]*session, len(d.sessions)) | 276 | + | registry := make(map[domain.SessionID]attachmentSession, len(d.sessions)) |
| 279 | maps.Copy(registry, d.sessions) | 277 | maps.Copy(registry, d.sessions) | ||
| 280 | state := moveTabHandoffState{ | 278 | state := moveTabHandoffState{ | ||
| 281 | closing: d.closing, | 279 | closing: d.closing, | ||
M internal/usecase/daemon/move_lifecycle_test.go +12 -10
| @@ -66,7 +66,7 @@ func awaitDaemonMoveClosing(t *testing.T, d *Daemon) { | |||
| 66 | 66 | ||
| 67 | 67 | func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) { | |
| 68 | 68 | d := newTestDaemon(t, nil, stubClock{}) | |
| 69 | - | sess := &session{id: "source"} | |
| 69 | + | sess := &session{sessionCore: sessionCore{id: "source"}} | |
| 70 | 70 | ||
| 71 | 71 | reservation, err := d.reserveMoveLifecycles(sess, sess) | |
| 72 | 72 | require.NoError(t, err) | |
| @@ -88,11 +88,13 @@ func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) { | |||
| 88 | 88 | ||
| 89 | 89 | func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) { | |
| 90 | 90 | d := newTestDaemon(t, nil, stubClock{}) | |
| 91 | - | first := &session{id: "a"} | |
| 92 | - | second := &session{id: "b"} | |
| 91 | + | first := &session{sessionCore: sessionCore{id: "a"}} | |
| 92 | + | second := &session{sessionCore: | |
| 93 | 93 | ||
| 94 | 94 | // Both opposite-direction reservations must block on a before either takes | |
| 95 | 95 | // b. This proves order from immutable IDs rather than argument direction. | |
| 96 | + | sessionCore{id: "b"}} | |
| 97 | + | ||
| 96 | 98 | first.teardownMu.Lock() | |
| 97 | 99 | beforeTeardownLocks := make(chan struct{}, 2) | |
| 98 | 100 | d.afterMoveLifecycleGateBeforeTeardownLocks = func() { beforeTeardownLocks <- struct{}{} } | |
| @@ -127,8 +129,8 @@ func TestMoveLifecycleReservationRejectsTeardownWithoutLeakingCounts(t *testing. | |||
| 127 | 129 | for _, teardownSession := range []string{"source", "destination"} { | |
| 128 | 130 | t.Run(teardownSession, func(t *testing.T) { | |
| 129 | 131 | d := newTestDaemon(t, nil, stubClock{}) | |
| 130 | - | source := &session{id: "source"} | |
| 131 | - | destination := &session{id: "destination"} | |
| 132 | + | source := &session{sessionCore: sessionCore{id: "source"}} | |
| 133 | + | destination := &session{sessionCore: sessionCore{id: "destination"}} | |
| 132 | 134 | tearingDown := source | |
| 133 | 135 | if teardownSession == "destination" { | |
| 134 | 136 | tearingDown = destination | |
| @@ -148,7 +150,7 @@ func TestMoveLifecycleReservationRejectsTeardownWithoutLeakingCounts(t *testing. | |||
| 148 | 150 | ||
| 149 | 151 | func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T) { | |
| 150 | 152 | d := newTestDaemon(t, nil, stubClock{}) | |
| 151 | - | sess := &session{id: "same"} | |
| 153 | + | sess := &session{sessionCore: sessionCore{id: "same"}} | |
| 152 | 154 | reservation, err := d.reserveMoveLifecycles(sess, sess) | |
| 153 | 155 | require.NoError(t, err) | |
| 154 | 156 | awaitMoveTeardownState(t, sess, 1, 0) | |
| @@ -170,7 +172,7 @@ func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T | |||
| 170 | 172 | ||
| 171 | 173 | func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *testing.T) { | |
| 172 | 174 | d := newTestDaemon(t, nil, stubClock{}) | |
| 173 | - | sess := &session{id: "source"} | |
| 175 | + | sess := &session{sessionCore: sessionCore{id: "source"}} | |
| 174 | 176 | reservation, err := d.reserveMoveLifecycles(sess, sess) | |
| 175 | 177 | require.NoError(t, err) | |
| 176 | 178 | deadline := &snapshotShutdownDeadline{done: make(chan struct{})} | |
| @@ -190,7 +192,7 @@ func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *test | |||
| 190 | 192 | ||
| 191 | 193 | func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) { | |
| 192 | 194 | d := newTestDaemon(t, nil, stubClock{}) | |
| 193 | - | sess := &session{id: "source"} | |
| 195 | + | sess := &session{sessionCore: sessionCore{id: "source"}} | |
| 194 | 196 | reservation, err := d.reserveMoveLifecycles(sess, sess) | |
| 195 | 197 | require.NoError(t, err) | |
| 196 | 198 | ||
| @@ -221,8 +223,8 @@ func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) { | |||
| 221 | 223 | ||
| 222 | 224 | func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) { | |
| 223 | 225 | d := newTestDaemon(t, nil, stubClock{}) | |
| 224 | - | source := &session{id: "source"} | |
| 225 | - | destination := &session{id: "destination"} | |
| 226 | + | source := &session{sessionCore: sessionCore{id: "source"}} | |
| 227 | + | destination := &session{sessionCore: sessionCore{id: "destination"}} | |
| 226 | 228 | reservation, err := d.reserveMoveLifecycles(source, destination) | |
| 227 | 229 | require.NoError(t, err) | |
| 228 | 230 | paneLifetime := d.paneProcessCtx | |
| @@ -66,7 +66,7 @@ func awaitDaemonMoveClosing(t *testing.T, d *Daemon) { | |||||
| 66 | 66 | ||||
| 67 | func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) { | 67 | func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) { | ||
| 68 | d := newTestDaemon(t, nil, stubClock{}) | 68 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 69 | - | sess := &session{id: "source"} | 69 | + | sess := &session{sessionCore: sessionCore{id: "source"}} |
| 70 | 70 | ||||
| 71 | reservation, err := d.reserveMoveLifecycles(sess, sess) | 71 | reservation, err := d.reserveMoveLifecycles(sess, sess) | ||
| 72 | require.NoError(t, err) | 72 | require.NoError(t, err) | ||
| @@ -88,11 +88,13 @@ func TestMoveLifecycleReservationMakesTeardownWait(t *testing.T) { | |||||
| 88 | 88 | ||||
| 89 | func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) { | 89 | func TestMoveLifecycleReservationsUseStableSessionOrder(t *testing.T) { | ||
| 90 | d := newTestDaemon(t, nil, stubClock{}) | 90 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 91 | - | first := &session{id: "a"} | 91 | + | first := &session{sessionCore: sessionCore{id: "a"}} |
| 92 | - | second := &session{id: "b"} | 92 | + | second := &session{sessionCore: |
| 93 | 93 | ||||
| 94 | // Both opposite-direction reservations must block on a before either takes | 94 | // Both opposite-direction reservations must block on a before either takes | ||
| 95 | // b. This proves order from immutable IDs rather than argument direction. | 95 | // b. This proves order from immutable IDs rather than argument direction. | ||
| 96 | + | sessionCore{id: "b"}} | |||
| 97 | + | ||||
| 96 | first.teardownMu.Lock() | 98 | first.teardownMu.Lock() | ||
| 97 | beforeTeardownLocks := make(chan struct{}, 2) | 99 | beforeTeardownLocks := make(chan struct{}, 2) | ||
| 98 | d.afterMoveLifecycleGateBeforeTeardownLocks = func() { beforeTeardownLocks <- struct{}{} } | 100 | d.afterMoveLifecycleGateBeforeTeardownLocks = func() { beforeTeardownLocks <- struct{}{} } | ||
| @@ -127,8 +129,8 @@ func TestMoveLifecycleReservationRejectsTeardownWithoutLeakingCounts(t *testing. | |||||
| 127 | for _, teardownSession := range []string{"source", "destination"} { | 129 | for _, teardownSession := range []string{"source", "destination"} { | ||
| 128 | t.Run(teardownSession, func(t *testing.T) { | 130 | t.Run(teardownSession, func(t *testing.T) { | ||
| 129 | d := newTestDaemon(t, nil, stubClock{}) | 131 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 130 | - | source := &session{id: "source"} | 132 | + | source := &session{sessionCore: sessionCore{id: "source"}} |
| 131 | - | destination := &session{id: "destination"} | 133 | + | destination := &session{sessionCore: sessionCore{id: "destination"}} |
| 132 | tearingDown := source | 134 | tearingDown := source | ||
| 133 | if teardownSession == "destination" { | 135 | if teardownSession == "destination" { | ||
| 134 | tearingDown = destination | 136 | tearingDown = destination | ||
| @@ -148,7 +150,7 @@ func TestMoveLifecycleReservationRejectsTeardownWithoutLeakingCounts(t *testing. | |||||
| 148 | 150 | ||||
| 149 | func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T) { | 151 | func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T) { | ||
| 150 | d := newTestDaemon(t, nil, stubClock{}) | 152 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 151 | - | sess := &session{id: "same"} | 153 | + | sess := &session{sessionCore: sessionCore{id: "same"}} |
| 152 | reservation, err := d.reserveMoveLifecycles(sess, sess) | 154 | reservation, err := d.reserveMoveLifecycles(sess, sess) | ||
| 153 | require.NoError(t, err) | 155 | require.NoError(t, err) | ||
| 154 | awaitMoveTeardownState(t, sess, 1, 0) | 156 | awaitMoveTeardownState(t, sess, 1, 0) | ||
| @@ -170,7 +172,7 @@ func TestMoveLifecycleReservationDeduplicatesAndReleasesExactlyOnce(t *testing.T | |||||
| 170 | 172 | ||||
| 171 | func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *testing.T) { | 173 | func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *testing.T) { | ||
| 172 | d := newTestDaemon(t, nil, stubClock{}) | 174 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 173 | - | sess := &session{id: "source"} | 175 | + | sess := &session{sessionCore: sessionCore{id: "source"}} |
| 174 | reservation, err := d.reserveMoveLifecycles(sess, sess) | 176 | reservation, err := d.reserveMoveLifecycles(sess, sess) | ||
| 175 | require.NoError(t, err) | 177 | require.NoError(t, err) | ||
| 176 | deadline := &snapshotShutdownDeadline{done: make(chan struct{})} | 178 | deadline := &snapshotShutdownDeadline{done: make(chan struct{})} | ||
| @@ -190,7 +192,7 @@ func TestMoveLifecycleReservationWaitCanBeCancelledWithoutChangingCounts(t *test | |||||
| 190 | 192 | ||||
| 191 | func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) { | 193 | func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) { | ||
| 192 | d := newTestDaemon(t, nil, stubClock{}) | 194 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 193 | - | sess := &session{id: "source"} | 195 | + | sess := &session{sessionCore: sessionCore{id: "source"}} |
| 194 | reservation, err := d.reserveMoveLifecycles(sess, sess) | 196 | reservation, err := d.reserveMoveLifecycles(sess, sess) | ||
| 195 | require.NoError(t, err) | 197 | require.NoError(t, err) | ||
| 196 | 198 | ||||
| @@ -221,8 +223,8 @@ func TestMoveLifecycleReservationWakesAllTeardownWaiters(t *testing.T) { | |||||
| 221 | 223 | ||||
| 222 | func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) { | 224 | func TestDaemonShutdownDrainsMoveBeforePaneLifetimeCancellation(t *testing.T) { | ||
| 223 | d := newTestDaemon(t, nil, stubClock{}) | 225 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 224 | - | source := &session{id: "source"} | 226 | + | source := &session{sessionCore: sessionCore{id: "source"}} |
| 225 | - | destination := &session{id: "destination"} | 227 | + | destination := &session{sessionCore: sessionCore{id: "destination"}} |
| 226 | reservation, err := d.reserveMoveLifecycles(source, destination) | 228 | reservation, err := d.reserveMoveLifecycles(source, destination) | ||
| 227 | require.NoError(t, err) | 229 | require.NoError(t, err) | ||
| 228 | paneLifetime := d.paneProcessCtx | 230 | paneLifetime := d.paneProcessCtx | ||
M internal/usecase/daemon/move_pane_commit.go +1 -1
| @@ -156,7 +156,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||
| 156 | 156 | } | |
| 157 | 157 | ||
| 158 | 158 | if candidate.removeSourceTab && len(c.source.tabs) == 0 { | |
| 159 | - | delete(d.sessions, c.source.id) | |
| 159 | + | d.unregisterSessionLocked(c.source) | |
| 160 | 160 | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) | |
| 161 | 161 | d.purgeParkingForSessionLocked(c.source) | |
| 162 | 162 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | |
| @@ -156,7 +156,7 @@ func (c *movePaneCommit) publishLocked(d *Daemon) bool { | |||||
| 156 | } | 156 | } | ||
| 157 | 157 | ||||
| 158 | if candidate.removeSourceTab && len(c.source.tabs) == 0 { | 158 | if candidate.removeSourceTab && len(c.source.tabs) == 0 { | ||
| 159 | - | delete(d.sessions, c.source.id) | 159 | + | d.unregisterSessionLocked(c.source) |
| 160 | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) | 160 | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) | ||
| 161 | d.purgeParkingForSessionLocked(c.source) | 161 | d.purgeParkingForSessionLocked(c.source) | ||
| 162 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | 162 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | ||
M internal/usecase/daemon/move_picker.go +4 -4
| @@ -10,8 +10,8 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error { | |||
| 10 | 10 | if d == nil || source.Session.ID == "" || source.TabID == "" { | |
| 11 | 11 | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | |
| 12 | 12 | } | |
| 13 | - | sess := d.sessionByID(source.Session.ID) | |
| 14 | - | if sess == nil || sess.incarnation != source.Session.Incarnation { | |
| 13 | + | sess, ok := localSession(d.sessionByID(source.Session.ID)) | |
| 14 | + | if !ok || sess.incarnation != source.Session.Incarnation { | |
| 15 | 15 | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | |
| 16 | 16 | } | |
| 17 | 17 | sess.mu.Lock() | |
| @@ -109,8 +109,8 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc | |||
| 109 | 109 | ||
| 110 | 110 | func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) { | |
| 111 | 111 | d.mu.Lock() | |
| 112 | - | sess := d.sessions[target.Session] | |
| 113 | - | if sess == nil { | |
| 112 | + | sess, ok := localSession(d.sessions[target.Session]) | |
| 113 | + | if !ok { | |
| 114 | 114 | d.mu.Unlock() | |
| 115 | 115 | return nil, nil | |
| 116 | 116 | } | |
| @@ -10,8 +10,8 @@ func (d *Daemon) movePickerSourceError(source moveSourceLocator) error { | |||||
| 10 | if d == nil || source.Session.ID == "" || source.TabID == "" { | 10 | if d == nil || source.Session.ID == "" || source.TabID == "" { | ||
| 11 | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | 11 | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | ||
| 12 | } | 12 | } | ||
| 13 | - | sess := d.sessionByID(source.Session.ID) | 13 | + | sess, ok := localSession(d.sessionByID(source.Session.ID)) |
| 14 | - | if sess == nil || sess.incarnation != source.Session.Incarnation { | 14 | + | if !ok || sess.incarnation != source.Session.Incarnation { |
| 15 | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | 15 | return domain.UserWarn(domain.NoticeSessionUnavailable, "Source session is no longer available.", errMovePaneInvalid) | ||
| 16 | } | 16 | } | ||
| 17 | sess.mu.Lock() | 17 | sess.mu.Lock() | ||
| @@ -109,8 +109,8 @@ func (d *Daemon) commitMovePickerSelection(intent pickerIntent, source moveSourc | |||||
| 109 | 109 | ||||
| 110 | func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) { | 110 | func (d *Daemon) previewTarget(target picker.Target, intent pickerIntent) (*session, *tab) { | ||
| 111 | d.mu.Lock() | 111 | d.mu.Lock() | ||
| 112 | - | sess := d.sessions[target.Session] | 112 | + | sess, ok := localSession(d.sessions[target.Session]) |
| 113 | - | if sess == nil { | 113 | + | if !ok { |
| 114 | d.mu.Unlock() | 114 | d.mu.Unlock() | ||
| 115 | return nil, nil | 115 | return nil, nil | ||
| 116 | } | 116 | } | ||
M internal/usecase/daemon/move_picker_test.go +1 -4
| @@ -35,10 +35,7 @@ func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDest | |||
| 35 | 35 | releases = append(releases, releaseDest) | |
| 36 | 36 | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23}) | |
| 37 | 37 | publishTiledPaneOwners(source, sourceTab) | |
| 38 | - | destination := &session{ | |
| 39 | - | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |
| 40 | - | ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0, | |
| 41 | - | } | |
| 38 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0} | |
| 42 | 39 | publishTiledPaneOwners(destination, destinationTab) | |
| 43 | 40 | for range extraDestinationTabs { | |
| 44 | 41 | extraPTY, releaseExtra := newBlockingPTY(t) | |
| @@ -35,10 +35,7 @@ func setupMovePickerSessionsWithClock(t *testing.T, clock ports.Clock, extraDest | |||||
| 35 | releases = append(releases, releaseDest) | 35 | releases = append(releases, releaseDest) | ||
| 36 | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23}) | 36 | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destPTY, domain.Size{Cols: 80, Rows: 23}) | ||
| 37 | publishTiledPaneOwners(source, sourceTab) | 37 | publishTiledPaneOwners(source, sourceTab) | ||
| 38 | - | destination := &session{ | 38 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0} |
| 39 | - | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |||
| 40 | - | ctx: source.ctx, cancel: func() {}, tabs: []*tab{destinationTab}, active: 0, | |||
| 41 | - | } | |||
| 42 | publishTiledPaneOwners(destination, destinationTab) | 39 | publishTiledPaneOwners(destination, destinationTab) | ||
| 43 | for range extraDestinationTabs { | 40 | for range extraDestinationTabs { | ||
| 44 | extraPTY, releaseExtra := newBlockingPTY(t) | 41 | extraPTY, releaseExtra := newBlockingPTY(t) | ||
M internal/usecase/daemon/move_resize_fences_test.go +17 -17
| @@ -22,8 +22,8 @@ func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) { | |||
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | 24 | func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) { | |
| 25 | - | first := &session{id: "a-first"} | |
| 26 | - | second := &session{id: "z-second"} | |
| 25 | + | first := &session{sessionCore: sessionCore{id: "a-first"}} | |
| 26 | + | second := &session{sessionCore: sessionCore{id: "z-second"}} | |
| 27 | 27 | fences := newMoveResizeFences([]*session{second, first}, nil, nil) | |
| 28 | 28 | ||
| 29 | 29 | first.layoutApplyMu.Lock() | |
| @@ -45,7 +45,7 @@ func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) { | |||
| 45 | 45 | } | |
| 46 | 46 | ||
| 47 | 47 | func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) { | |
| 48 | - | sess := &session{id: "session"} | |
| 48 | + | sess := &session{sessionCore: sessionCore{id: "session"}} | |
| 49 | 49 | first := &tab{stableID: "a-first"} | |
| 50 | 50 | second := &tab{stableID: "z-second"} | |
| 51 | 51 | fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil) | |
| @@ -71,7 +71,7 @@ func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) { | |||
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | 73 | func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) { | |
| 74 | - | sess := &session{id: "session"} | |
| 74 | + | sess := &session{sessionCore: sessionCore{id: "session"}} | |
| 75 | 75 | tb := &tab{stableID: "tab"} | |
| 76 | 76 | first := &pane{stableID: "a-first"} | |
| 77 | 77 | second := &pane{stableID: "z-second"} | |
| @@ -99,7 +99,7 @@ func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) { | |||
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | 101 | func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) { | |
| 102 | - | sess := &session{id: "session"} | |
| 102 | + | sess := &session{sessionCore: sessionCore{id: "session"}} | |
| 103 | 103 | tb := &tab{stableID: "tab"} | |
| 104 | 104 | p := &pane{stableID: "pane"} | |
| 105 | 105 | ||
| @@ -113,8 +113,8 @@ func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) { | |||
| 113 | 113 | } | |
| 114 | 114 | ||
| 115 | 115 | func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) { | |
| 116 | - | resolved := &session{id: "same"} | |
| 117 | - | collision := &session{id: "same"} | |
| 116 | + | resolved := &session{sessionCore: sessionCore{id: "same"}} | |
| 117 | + | collision := &session{sessionCore: sessionCore{id: "same"}} | |
| 118 | 118 | ||
| 119 | 119 | fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil) | |
| 120 | 120 | ||
| @@ -143,7 +143,7 @@ func TestMoveResizeFencesDeduplicatePaneIDWithoutReplacingResolvedOwner(t *testi | |||
| 143 | 143 | } | |
| 144 | 144 | ||
| 145 | 145 | func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) { | |
| 146 | - | sess := &session{id: "session"} | |
| 146 | + | sess := &session{sessionCore: sessionCore{id: "session"}} | |
| 147 | 147 | tb := &tab{stableID: "tab"} | |
| 148 | 148 | p := &pane{stableID: "pane"} | |
| 149 | 149 | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | |
| @@ -171,7 +171,7 @@ func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) { | |||
| 171 | 171 | } | |
| 172 | 172 | ||
| 173 | 173 | func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) { | |
| 174 | - | sess := &session{id: "session"} | |
| 174 | + | sess := &session{sessionCore: sessionCore{id: "session"}} | |
| 175 | 175 | tb := &tab{stableID: "tab"} | |
| 176 | 176 | p := &pane{stableID: "pane"} | |
| 177 | 177 | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | |
| @@ -187,7 +187,7 @@ func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) { | |||
| 187 | 187 | ||
| 188 | 188 | func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *testing.T) { | |
| 189 | 189 | d := &Daemon{} | |
| 190 | - | sess := &session{id: "session"} | |
| 190 | + | sess := &session{sessionCore: sessionCore{id: "session"}} | |
| 191 | 191 | tb := &tab{stableID: "tab"} | |
| 192 | 192 | p := &pane{stableID: "pane"} | |
| 193 | 193 | members := []resizeMember{{session: sess, tab: tb, pane: p}} | |
| @@ -216,7 +216,7 @@ func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *test | |||
| 216 | 216 | ||
| 217 | 217 | func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) { | |
| 218 | 218 | d := &Daemon{} | |
| 219 | - | sess := &session{id: "session"} | |
| 219 | + | sess := &session{sessionCore: sessionCore{id: "session"}} | |
| 220 | 220 | tb := &tab{stableID: "tab"} | |
| 221 | 221 | p := &pane{stableID: "pane"} | |
| 222 | 222 | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | |
| @@ -243,8 +243,8 @@ func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) { | |||
| 243 | 243 | } | |
| 244 | 244 | ||
| 245 | 245 | func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) { | |
| 246 | - | source := &session{id: "source"} | |
| 247 | - | destination := &session{id: "destination"} | |
| 246 | + | source := &session{sessionCore: sessionCore{id: "source"}} | |
| 247 | + | destination := &session{sessionCore: sessionCore{id: "destination"}} | |
| 248 | 248 | sourceTab := &tab{stableID: "a-source-tab"} | |
| 249 | 249 | destinationTab := &tab{stableID: "z-destination-tab"} | |
| 250 | 250 | p := &pane{stableID: "pane"} | |
| @@ -280,8 +280,8 @@ func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) | |||
| 280 | 280 | } | |
| 281 | 281 | ||
| 282 | 282 | func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) { | |
| 283 | - | source := &session{id: "source"} | |
| 284 | - | destination := &session{id: "destination"} | |
| 283 | + | source := &session{sessionCore: sessionCore{id: "source"}} | |
| 284 | + | destination := &session{sessionCore: sessionCore{id: "destination"}} | |
| 285 | 285 | sourceTab := &tab{stableID: "source-tab"} | |
| 286 | 286 | destinationTab := &tab{stableID: "destination-tab"} | |
| 287 | 287 | moved := &pane{stableID: "moved-pane"} | |
| @@ -299,8 +299,8 @@ func TestMoveTabResizeFencesCoverEveryContainedPane(t *testing.T) { | |||
| 299 | 299 | state floatingState | |
| 300 | 300 | }{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} { | |
| 301 | 301 | t.Run(tc.name, func(t *testing.T) { | |
| 302 | - | source := &session{id: "source"} | |
| 303 | - | destination := &session{id: "destination"} | |
| 302 | + | source := &session{sessionCore: sessionCore{id: "source"}} | |
| 303 | + | destination := &session{sessionCore: sessionCore{id: "destination"}} | |
| 304 | 304 | first := &pane{stableID: "first"} | |
| 305 | 305 | second := &pane{stableID: "second"} | |
| 306 | 306 | floating := &pane{stableID: "floating"} | |
| @@ -22,8 +22,8 @@ func requireMutexLocked(t *testing.T, mu *sync.Mutex, message string) { | |||||
| 22 | } | 22 | } | ||
| 23 | 23 | ||||
| 24 | func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) { | 24 | func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) { | ||
| 25 | - | first := &session{id: "a-first"} | 25 | + | first := &session{sessionCore: sessionCore{id: "a-first"}} |
| 26 | - | second := &session{id: "z-second"} | 26 | + | second := &session{sessionCore: sessionCore{id: "z-second"}} |
| 27 | fences := newMoveResizeFences([]*session{second, first}, nil, nil) | 27 | fences := newMoveResizeFences([]*session{second, first}, nil, nil) | ||
| 28 | 28 | ||||
| 29 | first.layoutApplyMu.Lock() | 29 | first.layoutApplyMu.Lock() | ||
| @@ -45,7 +45,7 @@ func TestMoveResizeFencesAcquireSessionsByStableID(t *testing.T) { | |||||
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) { | 47 | func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) { | ||
| 48 | - | sess := &session{id: "session"} | 48 | + | sess := &session{sessionCore: sessionCore{id: "session"}} |
| 49 | first := &tab{stableID: "a-first"} | 49 | first := &tab{stableID: "a-first"} | ||
| 50 | second := &tab{stableID: "z-second"} | 50 | second := &tab{stableID: "z-second"} | ||
| 51 | fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil) | 51 | fences := newMoveResizeFences([]*session{sess}, []*tab{second, first}, nil) | ||
| @@ -71,7 +71,7 @@ func TestMoveResizeFencesAcquireTabsByStableIDAfterSessions(t *testing.T) { | |||||
| 71 | } | 71 | } | ||
| 72 | 72 | ||||
| 73 | func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) { | 73 | func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) { | ||
| 74 | - | sess := &session{id: "session"} | 74 | + | sess := &session{sessionCore: sessionCore{id: "session"}} |
| 75 | tb := &tab{stableID: "tab"} | 75 | tb := &tab{stableID: "tab"} | ||
| 76 | first := &pane{stableID: "a-first"} | 76 | first := &pane{stableID: "a-first"} | ||
| 77 | second := &pane{stableID: "z-second"} | 77 | second := &pane{stableID: "z-second"} | ||
| @@ -99,7 +99,7 @@ func TestMoveResizeFencesAcquirePanesByStableIDAfterTabs(t *testing.T) { | |||||
| 99 | } | 99 | } | ||
| 100 | 100 | ||||
| 101 | func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) { | 101 | func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) { | ||
| 102 | - | sess := &session{id: "session"} | 102 | + | sess := &session{sessionCore: sessionCore{id: "session"}} |
| 103 | tb := &tab{stableID: "tab"} | 103 | tb := &tab{stableID: "tab"} | ||
| 104 | p := &pane{stableID: "pane"} | 104 | p := &pane{stableID: "pane"} | ||
| 105 | 105 | ||||
| @@ -113,8 +113,8 @@ func TestMoveResizeFencesDeduplicateStableIdentities(t *testing.T) { | |||||
| 113 | } | 113 | } | ||
| 114 | 114 | ||||
| 115 | func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) { | 115 | func TestMoveResizeFencesDeduplicateSessionIDWithoutReplacingResolvedOwner(t *testing.T) { | ||
| 116 | - | resolved := &session{id: "same"} | 116 | + | resolved := &session{sessionCore: sessionCore{id: "same"}} |
| 117 | - | collision := &session{id: "same"} | 117 | + | collision := &session{sessionCore: sessionCore{id: "same"}} |
| 118 | 118 | ||||
| 119 | fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil) | 119 | fences := newMoveResizeFences([]*session{resolved, collision}, nil, nil) | ||
| 120 | 120 | ||||
| @@ -143,7 +143,7 @@ func TestMoveResizeFencesDeduplicatePaneIDWithoutReplacingResolvedOwner(t *testi | |||||
| 143 | } | 143 | } | ||
| 144 | 144 | ||||
| 145 | func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) { | 145 | func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) { | ||
| 146 | - | sess := &session{id: "session"} | 146 | + | sess := &session{sessionCore: sessionCore{id: "session"}} |
| 147 | tb := &tab{stableID: "tab"} | 147 | tb := &tab{stableID: "tab"} | ||
| 148 | p := &pane{stableID: "pane"} | 148 | p := &pane{stableID: "pane"} | ||
| 149 | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | 149 | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | ||
| @@ -171,7 +171,7 @@ func TestMoveResizeFencesRejectStaleMembershipAndCleanUp(t *testing.T) { | |||||
| 171 | } | 171 | } | ||
| 172 | 172 | ||||
| 173 | func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) { | 173 | func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) { | ||
| 174 | - | sess := &session{id: "session"} | 174 | + | sess := &session{sessionCore: sessionCore{id: "session"}} |
| 175 | tb := &tab{stableID: "tab"} | 175 | tb := &tab{stableID: "tab"} | ||
| 176 | p := &pane{stableID: "pane"} | 176 | p := &pane{stableID: "pane"} | ||
| 177 | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | 177 | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | ||
| @@ -187,7 +187,7 @@ func TestMoveResizeFencesCleanUpWhenPublicationPanics(t *testing.T) { | |||||
| 187 | 187 | ||||
| 188 | func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *testing.T) { | 188 | func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *testing.T) { | ||
| 189 | d := &Daemon{} | 189 | d := &Daemon{} | ||
| 190 | - | sess := &session{id: "session"} | 190 | + | sess := &session{sessionCore: sessionCore{id: "session"}} |
| 191 | tb := &tab{stableID: "tab"} | 191 | tb := &tab{stableID: "tab"} | ||
| 192 | p := &pane{stableID: "pane"} | 192 | p := &pane{stableID: "pane"} | ||
| 193 | members := []resizeMember{{session: sess, tab: tb, pane: p}} | 193 | members := []resizeMember{{session: sess, tab: tb, pane: p}} | ||
| @@ -216,7 +216,7 @@ func TestPublishResizeOwnerPostEffectReleasesAllFencesWhenCallbackPanics(t *test | |||||
| 216 | 216 | ||||
| 217 | func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) { | 217 | func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) { | ||
| 218 | d := &Daemon{} | 218 | d := &Daemon{} | ||
| 219 | - | sess := &session{id: "session"} | 219 | + | sess := &session{sessionCore: sessionCore{id: "session"}} |
| 220 | tb := &tab{stableID: "tab"} | 220 | tb := &tab{stableID: "tab"} | ||
| 221 | p := &pane{stableID: "pane"} | 221 | p := &pane{stableID: "pane"} | ||
| 222 | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | 222 | fences := newMoveResizeFences([]*session{sess}, []*tab{tb}, []*pane{p}) | ||
| @@ -243,8 +243,8 @@ func TestMoveResizeFencesWaitBeforeTakingArchitectureLocks(t *testing.T) { | |||||
| 243 | } | 243 | } | ||
| 244 | 244 | ||||
| 245 | func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) { | 245 | func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) { | ||
| 246 | - | source := &session{id: "source"} | 246 | + | source := &session{sessionCore: sessionCore{id: "source"}} |
| 247 | - | destination := &session{id: "destination"} | 247 | + | destination := &session{sessionCore: sessionCore{id: "destination"}} |
| 248 | sourceTab := &tab{stableID: "a-source-tab"} | 248 | sourceTab := &tab{stableID: "a-source-tab"} | ||
| 249 | destinationTab := &tab{stableID: "z-destination-tab"} | 249 | destinationTab := &tab{stableID: "z-destination-tab"} | ||
| 250 | p := &pane{stableID: "pane"} | 250 | p := &pane{stableID: "pane"} | ||
| @@ -280,8 +280,8 @@ func TestMoveResizeFencesHoldThroughPublicationAndGenerationBumps(t *testing.T) | |||||
| 280 | } | 280 | } | ||
| 281 | 281 | ||||
| 282 | func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) { | 282 | func TestMovePaneResizeFencesCoverAffectedOwners(t *testing.T) { | ||
| 283 | - | source := &session{id: "source"} | 283 | + | source := &session{sessionCore: sessionCore{id: "source"}} |
| 284 | - | destination := &session{id: "destination"} | 284 | + | destination := &session{sessionCore: sessionCore{id: "destination"}} |
| 285 | sourceTab := &tab{stableID: "source-tab"} | 285 | sourceTab := &tab{stableID: "source-tab"} | ||
| 286 | destinationTab := &tab{stableID: "destination-tab"} | 286 | destinationTab := &tab{stableID: "destination-tab"} | ||
| 287 | moved := &pane{stableID: "moved-pane"} | 287 | moved := &pane{stableID: "moved-pane"} | ||
| @@ -299,8 +299,8 @@ func TestMoveTabResizeFencesCoverEveryContainedPane(t *testing.T) { | |||||
| 299 | state floatingState | 299 | state floatingState | ||
| 300 | }{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} { | 300 | }{{name: "hidden", state: floatingHidden}, {name: "visible", state: floatingVisible}} { | ||
| 301 | t.Run(tc.name, func(t *testing.T) { | 301 | t.Run(tc.name, func(t *testing.T) { | ||
| 302 | - | source := &session{id: "source"} | 302 | + | source := &session{sessionCore: sessionCore{id: "source"}} |
| 303 | - | destination := &session{id: "destination"} | 303 | + | destination := &session{sessionCore: sessionCore{id: "destination"}} |
| 304 | first := &pane{stableID: "first"} | 304 | first := &pane{stableID: "first"} | ||
| 305 | second := &pane{stableID: "second"} | 305 | second := &pane{stableID: "second"} | ||
| 306 | floating := &pane{stableID: "floating"} | 306 | floating := &pane{stableID: "floating"} | ||
M internal/usecase/daemon/move_stale_destination_test.go +1 -4
| @@ -18,10 +18,7 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T) | |||
| 18 | 18 | sourceTab := source.tabs[0] | |
| 19 | 19 | sourceTab.stableID = "source-tab" | |
| 20 | 20 | moved := sourceTab.focusedPane() | |
| 21 | - | destination := &session{ | |
| 22 | - | id: "destination", name: "destination", ephemeral: true, | |
| 23 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0, | |
| 24 | - | } | |
| 21 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 25 | 22 | destinationTab := destination.tabs[0] | |
| 26 | 23 | publishTiledPaneOwners(destination, destinationTab) | |
| 27 | 24 | d.mu.Lock() | |
| @@ -18,10 +18,7 @@ func TestMovePaneRejectsStaleDestinationIncarnationWithoutMutation(t *testing.T) | |||||
| 18 | sourceTab := source.tabs[0] | 18 | sourceTab := source.tabs[0] | ||
| 19 | sourceTab.stableID = "source-tab" | 19 | sourceTab.stableID = "source-tab" | ||
| 20 | moved := sourceTab.focusedPane() | 20 | moved := sourceTab.focusedPane() | ||
| 21 | - | destination := &session{ | 21 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0} |
| 22 | - | id: "destination", name: "destination", ephemeral: true, | |||
| 23 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, active: 0, | |||
| 24 | - | } | |||
| 25 | destinationTab := destination.tabs[0] | 22 | destinationTab := destination.tabs[0] | ||
| 26 | publishTiledPaneOwners(destination, destinationTab) | 23 | publishTiledPaneOwners(destination, destinationTab) | ||
| 27 | d.mu.Lock() | 24 | d.mu.Lock() | ||
M internal/usecase/daemon/move_tab.go +1 -1
| @@ -384,7 +384,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||
| 384 | 384 | c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client) | |
| 385 | 385 | } | |
| 386 | 386 | if len(c.source.tabs) == 0 { | |
| 387 | - | delete(d.sessions, c.source.id) | |
| 387 | + | d.unregisterSessionLocked(c.source) | |
| 388 | 388 | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) | |
| 389 | 389 | d.purgeParkingForSessionLocked(c.source) | |
| 390 | 390 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | |
| @@ -384,7 +384,7 @@ func (c *moveTabCommit) publishLocked(d *Daemon, fencedPanes []*pane) bool { | |||||
| 384 | c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client) | 384 | c.sourceCleanupToken = c.source.attachmentTokenLocked(c.source.client) | ||
| 385 | } | 385 | } | ||
| 386 | if len(c.source.tabs) == 0 { | 386 | if len(c.source.tabs) == 0 { | ||
| 387 | - | delete(d.sessions, c.source.id) | 387 | + | d.unregisterSessionLocked(c.source) |
| 388 | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) | 388 | c.retiredAttachments = retireEmptyMoveSessionLocked(c.source, retirement) | ||
| 389 | d.purgeParkingForSessionLocked(c.source) | 389 | d.purgeParkingForSessionLocked(c.source) | ||
| 390 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | 390 | c.retiredParked = d.purgeParkedForSessionLocked(c.source) | ||
M internal/usecase/daemon/move_tab_test.go +2 -5
| @@ -33,10 +33,7 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) { | |||
| 33 | 33 | t.Cleanup(destinationCancel) | |
| 34 | 34 | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30}) | |
| 35 | 35 | destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx) | |
| 36 | - | destination := &session{ | |
| 37 | - | id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral, | |
| 38 | - | ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0, | |
| 39 | - | } | |
| 36 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral}, ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0} | |
| 40 | 37 | publishTiledPaneOwners(destination, destinationTab) | |
| 41 | 38 | ||
| 42 | 39 | floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8}) | |
| @@ -575,7 +572,7 @@ func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *sessio | |||
| 575 | 572 | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 576 | 573 | tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | |
| 577 | 574 | tb.ctx, tb.cancel = context.WithCancel(ctx) | |
| 578 | - | sess := &session{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0} | |
| 575 | + | sess := &session{sessionCore: sessionCore{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0} | |
| 579 | 576 | publishTiledPaneOwners(sess, tb) | |
| 580 | 577 | d.mu.Lock() | |
| 581 | 578 | d.sessions[sess.id] = sess | |
| @@ -33,10 +33,7 @@ func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) { | |||||
| 33 | t.Cleanup(destinationCancel) | 33 | t.Cleanup(destinationCancel) | ||
| 34 | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30}) | 34 | destinationTab := newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 100, Rows: 30}) | ||
| 35 | destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx) | 35 | destinationTab.ctx, destinationTab.cancel = context.WithCancel(destinationCtx) | ||
| 36 | - | destination := &session{ | 36 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral}, ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0} |
| 37 | - | id: "destination", name: "destination", incarnation: domain.IncarnationID{9}, ephemeral: destinationEphemeral, | |||
| 38 | - | ctx: destinationCtx, cancel: destinationCancel, tabs: []*tab{destinationTab}, active: 0, | |||
| 39 | - | } | |||
| 40 | publishTiledPaneOwners(destination, destinationTab) | 37 | publishTiledPaneOwners(destination, destinationTab) | ||
| 41 | 38 | ||||
| 42 | floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8}) | 39 | floating := newPaneWithStableID("floating", "floating-stable", floatingPTY, domain.Size{Cols: 20, Rows: 8}) | ||
| @@ -575,7 +572,7 @@ func addMoveTabTestSession(d *Daemon, id domain.SessionID, tabID string) *sessio | |||||
| 575 | ctx, cancel := context.WithCancel(d.serveCtx) | 572 | ctx, cancel := context.WithCancel(d.serveCtx) | ||
| 576 | tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | 573 | tb := newTabWithStableID(tabID, tabID+"-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23}) | ||
| 577 | tb.ctx, tb.cancel = context.WithCancel(ctx) | 574 | tb.ctx, tb.cancel = context.WithCancel(ctx) | ||
| 578 | - | sess := &session{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0} | 575 | + | sess := &session{sessionCore: sessionCore{id: id, name: string(id), incarnation: domain.IncarnationID{5}, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}, active: 0} |
| 579 | publishTiledPaneOwners(sess, tb) | 576 | publishTiledPaneOwners(sess, tb) | ||
| 580 | d.mu.Lock() | 577 | d.mu.Lock() | ||
| 581 | d.sessions[sess.id] = sess | 578 | d.sessions[sess.id] = sess | ||
M internal/usecase/daemon/move_test.go +4 -8
| @@ -57,7 +57,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) { | |||
| 57 | 57 | movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx) | |
| 58 | 58 | moved.ctx, moved.cancel = movedCtx, movedCancel | |
| 59 | 59 | ||
| 60 | - | destination := &session{id: "destination", name: "destination", ephemeral: true, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 60 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 61 | 61 | destinationTab := destination.tabs[0] | |
| 62 | 62 | destinationTab.stableID = "destination-tab" | |
| 63 | 63 | publishTiledPaneOwners(destination, destinationTab) | |
| @@ -97,7 +97,7 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) { | |||
| 97 | 97 | require.NotNil(t, rc) | |
| 98 | 98 | var rebased atomic.Bool | |
| 99 | 99 | client.renderStages.handoffRebase = func() { rebased.Store(true) } | |
| 100 | - | destination := &session{id: "destination", name: "destination", tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 100 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0} | |
| 101 | 101 | destinationTab := destination.tabs[0] | |
| 102 | 102 | destinationTab.stableID = "destination-tab" | |
| 103 | 103 | publishTiledPaneOwners(destination, destinationTab) | |
| @@ -140,9 +140,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing | |||
| 140 | 140 | movedTab.stableID = "source-tab" | |
| 141 | 141 | ||
| 142 | 142 | destinationPTY := newQuietPTY() | |
| 143 | - | destination := &session{ | |
| 144 | - | id: "destination", name: "destination", ephemeral: true, | |
| 145 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 143 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 146 | 144 | active: 0, | |
| 147 | 145 | } | |
| 148 | 146 | publishTiledPaneOwners(destination, destination.tabs[0]) | |
| @@ -192,9 +190,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) { | |||
| 192 | 190 | parked.initOverlays() | |
| 193 | 191 | require.True(t, d.parkAttachment(source, parked)) | |
| 194 | 192 | destinationPTY := newQuietPTY() | |
| 195 | - | destination := &session{ | |
| 196 | - | id: "destination", name: "destination", ephemeral: true, | |
| 197 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 193 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |
| 198 | 194 | active: 0, | |
| 199 | 195 | } | |
| 200 | 196 | publishTiledPaneOwners(destination, destination.tabs[0]) | |
| @@ -57,7 +57,7 @@ func TestMovePaneMutationCrossSession(t *testing.T) { | |||||
| 57 | movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx) | 57 | movedCtx, movedCancel := context.WithCancel(d.paneProcessCtx) | ||
| 58 | moved.ctx, moved.cancel = movedCtx, movedCancel | 58 | moved.ctx, moved.cancel = movedCtx, movedCancel | ||
| 59 | 59 | ||||
| 60 | - | destination := &session{id: "destination", name: "destination", ephemeral: true, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0} | 60 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0} |
| 61 | destinationTab := destination.tabs[0] | 61 | destinationTab := destination.tabs[0] | ||
| 62 | destinationTab.stableID = "destination-tab" | 62 | destinationTab.stableID = "destination-tab" | ||
| 63 | publishTiledPaneOwners(destination, destinationTab) | 63 | publishTiledPaneOwners(destination, destinationTab) | ||
| @@ -97,7 +97,7 @@ func TestMovePaneFinalSourceClientFollowsDestination(t *testing.T) { | |||||
| 97 | require.NotNil(t, rc) | 97 | require.NotNil(t, rc) | ||
| 98 | var rebased atomic.Bool | 98 | var rebased atomic.Bool | ||
| 99 | client.renderStages.handoffRebase = func() { rebased.Store(true) } | 99 | client.renderStages.handoffRebase = func() { rebased.Store(true) } | ||
| 100 | - | destination := &session{id: "destination", name: "destination", tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0} | 100 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}, active: 0} |
| 101 | destinationTab := destination.tabs[0] | 101 | destinationTab := destination.tabs[0] | ||
| 102 | destinationTab.stableID = "destination-tab" | 102 | destinationTab.stableID = "destination-tab" | ||
| 103 | publishTiledPaneOwners(destination, destinationTab) | 103 | publishTiledPaneOwners(destination, destinationTab) | ||
| @@ -140,9 +140,7 @@ func TestMovePaneRetiresEmptySourceWithoutClosingTransferredResources(t *testing | |||||
| 140 | movedTab.stableID = "source-tab" | 140 | movedTab.stableID = "source-tab" | ||
| 141 | 141 | ||||
| 142 | destinationPTY := newQuietPTY() | 142 | destinationPTY := newQuietPTY() | ||
| 143 | - | destination := &session{ | 143 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, |
| 144 | - | id: "destination", name: "destination", ephemeral: true, | |||
| 145 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |||
| 146 | active: 0, | 144 | active: 0, | ||
| 147 | } | 145 | } | ||
| 148 | publishTiledPaneOwners(destination, destination.tabs[0]) | 146 | publishTiledPaneOwners(destination, destination.tabs[0]) | ||
| @@ -192,9 +190,7 @@ func TestMovePaneRetiresSourceParkedClients(t *testing.T) { | |||||
| 192 | parked.initOverlays() | 190 | parked.initOverlays() | ||
| 193 | require.True(t, d.parkAttachment(source, parked)) | 191 | require.True(t, d.parkAttachment(source, parked)) | ||
| 194 | destinationPTY := newQuietPTY() | 192 | destinationPTY := newQuietPTY() | ||
| 195 | - | destination := &session{ | 193 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, |
| 196 | - | id: "destination", name: "destination", ephemeral: true, | |||
| 197 | - | tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}, | |||
| 198 | active: 0, | 194 | active: 0, | ||
| 199 | } | 195 | } | ||
| 200 | publishTiledPaneOwners(destination, destination.tabs[0]) | 196 | publishTiledPaneOwners(destination, destination.tabs[0]) | ||
M internal/usecase/daemon/navigation_overflow.go +1 -4
| @@ -85,10 +85,7 @@ func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction, | |||
| 85 | 85 | current.mu.Unlock() | |
| 86 | 86 | ||
| 87 | 87 | d.mu.Lock() | |
| 88 | - | live := make([]*session, 0, len(d.sessions)) | |
| 89 | - | for _, sess := range d.sessions { | |
| 90 | - | live = append(live, sess) | |
| 91 | - | } | |
| 88 | + | live := localSessionsSnapshot(d.sessions) | |
| 92 | 89 | d.mu.Unlock() | |
| 93 | 90 | ||
| 94 | 91 | snapshots := make([]sessionOverflowSnapshot, 0, len(live)) | |
| @@ -85,10 +85,7 @@ func (d *Daemon) prepareSessionOverflow(current *session, dir layout.Direction, | |||||
| 85 | current.mu.Unlock() | 85 | current.mu.Unlock() | ||
| 86 | 86 | ||||
| 87 | d.mu.Lock() | 87 | d.mu.Lock() | ||
| 88 | - | live := make([]*session, 0, len(d.sessions)) | 88 | + | live := localSessionsSnapshot(d.sessions) |
| 89 | - | for _, sess := range d.sessions { | |||
| 90 | - | live = append(live, sess) | |||
| 91 | - | } | |||
| 92 | d.mu.Unlock() | 89 | d.mu.Unlock() | ||
| 93 | 90 | ||||
| 94 | snapshots := make([]sessionOverflowSnapshot, 0, len(live)) | 91 | snapshots := make([]sessionOverflowSnapshot, 0, len(live)) | ||
M internal/usecase/daemon/navigation_overflow_test.go +18 -10
| @@ -93,7 +93,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||
| 93 | 93 | target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus} | |
| 94 | 94 | target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | |
| 95 | 95 | target.mu.Unlock() | |
| 96 | - | return &session{id: domain.SessionID(id), name: name, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active} | |
| 96 | + | return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name}, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active} | |
| 97 | 97 | } | |
| 98 | 98 | charlie := newSession("live-charlie", "charlie", 1, "pane-2") | |
| 99 | 99 | echo := newSession("live-echo", "echo", 1, "pane-2") | |
| @@ -138,7 +138,11 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||
| 138 | 138 | d.mu.Lock() | |
| 139 | 139 | _, stopped := d.stopped["bravo"] | |
| 140 | 140 | var bravoLive bool | |
| 141 | - | for _, candidate := range d.sessions { | |
| 141 | + | for _, entry := range d.sessions { | |
| 142 | + | candidate, ok := localSession(entry) | |
| 143 | + | if !ok { | |
| 144 | + | continue | |
| 145 | + | } | |
| 142 | 146 | candidate.mu.Lock() | |
| 143 | 147 | bravoLive = bravoLive || candidate.name == "bravo" | |
| 144 | 148 | candidate.mu.Unlock() | |
| @@ -158,7 +162,7 @@ func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) { | |||
| 158 | 162 | alpha.name = "alpha" | |
| 159 | 163 | alpha.mu.Unlock() | |
| 160 | 164 | installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |
| 161 | - | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 165 | + | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 162 | 166 | d.mu.Lock() | |
| 163 | 167 | d.sessions[charlie.id] = charlie | |
| 164 | 168 | d.mu.Unlock() | |
| @@ -180,7 +184,7 @@ func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) { | |||
| 180 | 184 | alpha.mu.Lock() | |
| 181 | 185 | alpha.name = "alpha" | |
| 182 | 186 | alpha.mu.Unlock() | |
| 183 | - | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 187 | + | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 184 | 188 | d.mu.Lock() | |
| 185 | 189 | d.sessions[charlie.id] = charlie | |
| 186 | 190 | d.mu.Unlock() | |
| @@ -206,7 +210,7 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) { | |||
| 206 | 210 | expectedSource := alpha.tabs[0] | |
| 207 | 211 | alpha.active = 1 | |
| 208 | 212 | alpha.mu.Unlock() | |
| 209 | - | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 213 | + | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 210 | 214 | d.mu.Lock() | |
| 211 | 215 | d.sessions[charlie.id] = charlie | |
| 212 | 216 | d.mu.Unlock() | |
| @@ -224,7 +228,7 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) { | |||
| 224 | 228 | func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) { | |
| 225 | 229 | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 226 | 230 | installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | |
| 227 | - | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 231 | + | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 228 | 232 | d.mu.Lock() | |
| 229 | 233 | d.sessions[charlie.id] = charlie | |
| 230 | 234 | d.mu.Unlock() | |
| @@ -238,7 +242,7 @@ func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) { | |||
| 238 | 242 | alpha.mu.Lock() | |
| 239 | 243 | alpha.name = "alpha" | |
| 240 | 244 | alpha.mu.Unlock() | |
| 241 | - | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 245 | + | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 242 | 246 | d.mu.Lock() | |
| 243 | 247 | d.sessions[charlie.id] = charlie | |
| 244 | 248 | d.mu.Unlock() | |
| @@ -265,8 +269,8 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |||
| 265 | 269 | alpha.mu.Lock() | |
| 266 | 270 | alpha.name = "alpha" | |
| 267 | 271 | alpha.mu.Unlock() | |
| 268 | - | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 269 | - | echo := &session{id: "live-echo", name: "echo", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 272 | + | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 273 | + | echo := &session{sessionCore: sessionCore{id: "live-echo", name: "echo"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | |
| 270 | 274 | d.mu.Lock() | |
| 271 | 275 | d.sessions[echo.id] = echo | |
| 272 | 276 | d.sessions[charlie.id] = charlie | |
| @@ -304,7 +308,11 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |||
| 304 | 308 | current := ac.currentSession() | |
| 305 | 309 | owners := make([]*session, 0, 1) | |
| 306 | 310 | d.mu.Lock() | |
| 307 | - | for _, candidate := range d.sessions { | |
| 311 | + | for _, entry := range d.sessions { | |
| 312 | + | candidate, ok := localSession(entry) | |
| 313 | + | if !ok { | |
| 314 | + | continue | |
| 315 | + | } | |
| 308 | 316 | candidate.mu.Lock() | |
| 309 | 317 | if candidate.client == ac { | |
| 310 | 318 | owners = append(owners, candidate) | |
| @@ -93,7 +93,7 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||||
| 93 | target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus} | 93 | target.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: focus} | ||
| 94 | target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | 94 | target.panes["pane-2"] = newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 10}) | ||
| 95 | target.mu.Unlock() | 95 | target.mu.Unlock() | ||
| 96 | - | return &session{id: domain.SessionID(id), name: name, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active} | 96 | + | return &session{sessionCore: sessionCore{id: domain.SessionID(id), name: name}, ctx: t.Context(), cancel: func() {}, tabs: tabs, active: active} |
| 97 | } | 97 | } | ||
| 98 | charlie := newSession("live-charlie", "charlie", 1, "pane-2") | 98 | charlie := newSession("live-charlie", "charlie", 1, "pane-2") | ||
| 99 | echo := newSession("live-echo", "echo", 1, "pane-2") | 99 | echo := newSession("live-echo", "echo", 1, "pane-2") | ||
| @@ -138,7 +138,11 @@ func TestKeyboardVerticalOverflowSwitchesOnlyAcrossAlphabeticalLiveSessions(t *t | |||||
| 138 | d.mu.Lock() | 138 | d.mu.Lock() | ||
| 139 | _, stopped := d.stopped["bravo"] | 139 | _, stopped := d.stopped["bravo"] | ||
| 140 | var bravoLive bool | 140 | var bravoLive bool | ||
| 141 | - | for _, candidate := range d.sessions { | 141 | + | for _, entry := range d.sessions { |
| 142 | + | candidate, ok := localSession(entry) | |||
| 143 | + | if !ok { | |||
| 144 | + | continue | |||
| 145 | + | } | |||
| 142 | candidate.mu.Lock() | 146 | candidate.mu.Lock() | ||
| 143 | bravoLive = bravoLive || candidate.name == "bravo" | 147 | bravoLive = bravoLive || candidate.name == "bravo" | ||
| 144 | candidate.mu.Unlock() | 148 | candidate.mu.Unlock() | ||
| @@ -158,7 +162,7 @@ func TestKeyboardVerticalOverflowRefusesVisibleFloatingSource(t *testing.T) { | |||||
| 158 | alpha.name = "alpha" | 162 | alpha.name = "alpha" | ||
| 159 | alpha.mu.Unlock() | 163 | alpha.mu.Unlock() | ||
| 160 | installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | 164 | installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | ||
| 161 | - | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | 165 | + | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} |
| 162 | d.mu.Lock() | 166 | d.mu.Lock() | ||
| 163 | d.sessions[charlie.id] = charlie | 167 | d.sessions[charlie.id] = charlie | ||
| 164 | d.mu.Unlock() | 168 | d.mu.Unlock() | ||
| @@ -180,7 +184,7 @@ func TestVerticalOverflowRevalidatesFloatingSourceBeforeHandoff(t *testing.T) { | |||||
| 180 | alpha.mu.Lock() | 184 | alpha.mu.Lock() | ||
| 181 | alpha.name = "alpha" | 185 | alpha.name = "alpha" | ||
| 182 | alpha.mu.Unlock() | 186 | alpha.mu.Unlock() | ||
| 183 | - | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | 187 | + | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} |
| 184 | d.mu.Lock() | 188 | d.mu.Lock() | ||
| 185 | d.sessions[charlie.id] = charlie | 189 | d.sessions[charlie.id] = charlie | ||
| 186 | d.mu.Unlock() | 190 | d.mu.Unlock() | ||
| @@ -206,7 +210,7 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) { | |||||
| 206 | expectedSource := alpha.tabs[0] | 210 | expectedSource := alpha.tabs[0] | ||
| 207 | alpha.active = 1 | 211 | alpha.active = 1 | ||
| 208 | alpha.mu.Unlock() | 212 | alpha.mu.Unlock() | ||
| 209 | - | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | 213 | + | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} |
| 210 | d.mu.Lock() | 214 | d.mu.Lock() | ||
| 211 | d.sessions[charlie.id] = charlie | 215 | d.sessions[charlie.id] = charlie | ||
| 212 | d.mu.Unlock() | 216 | d.mu.Unlock() | ||
| @@ -224,7 +228,7 @@ func TestVerticalOverflowRejectsStaleSourceTab(t *testing.T) { | |||||
| 224 | func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) { | 228 | func TestOrdinarySessionSwitchDoesNotApplyOverflowGuard(t *testing.T) { | ||
| 225 | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | 229 | d, alpha, ac, _ := newManualSessionWithPTYs(t, nil) | ||
| 226 | installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | 230 | installTestFloating(alpha.tabs[0], newPane("floating", nil, domain.Size{Cols: 20, Rows: 5}), true) | ||
| 227 | - | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | 231 | + | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} |
| 228 | d.mu.Lock() | 232 | d.mu.Lock() | ||
| 229 | d.sessions[charlie.id] = charlie | 233 | d.sessions[charlie.id] = charlie | ||
| 230 | d.mu.Unlock() | 234 | d.mu.Unlock() | ||
| @@ -238,7 +242,7 @@ func TestVerticalOverflowTreatsDisplacedSourceClientAsNoNeighbor(t *testing.T) { | |||||
| 238 | alpha.mu.Lock() | 242 | alpha.mu.Lock() | ||
| 239 | alpha.name = "alpha" | 243 | alpha.name = "alpha" | ||
| 240 | alpha.mu.Unlock() | 244 | alpha.mu.Unlock() | ||
| 241 | - | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | 245 | + | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} |
| 242 | d.mu.Lock() | 246 | d.mu.Lock() | ||
| 243 | d.sessions[charlie.id] = charlie | 247 | d.sessions[charlie.id] = charlie | ||
| 244 | d.mu.Unlock() | 248 | d.mu.Unlock() | ||
| @@ -265,8 +269,8 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |||||
| 265 | alpha.mu.Lock() | 269 | alpha.mu.Lock() | ||
| 266 | alpha.name = "alpha" | 270 | alpha.name = "alpha" | ||
| 267 | alpha.mu.Unlock() | 271 | alpha.mu.Unlock() | ||
| 268 | - | charlie := &session{id: "live-charlie", name: "charlie", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | 272 | + | charlie := &session{sessionCore: sessionCore{id: "live-charlie", name: "charlie"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} |
| 269 | - | echo := &session{id: "live-echo", name: "echo", ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} | 273 | + | echo := &session{sessionCore: sessionCore{id: "live-echo", name: "echo"}, ctx: t.Context(), cancel: func() {}, tabs: []*tab{newTab(nil, domain.Size{Cols: 41, Rows: 10})}} |
| 270 | d.mu.Lock() | 274 | d.mu.Lock() | ||
| 271 | d.sessions[echo.id] = echo | 275 | d.sessions[echo.id] = echo | ||
| 272 | d.sessions[charlie.id] = charlie | 276 | d.sessions[charlie.id] = charlie | ||
| @@ -304,7 +308,11 @@ func TestVerticalOverflowIsRaceFreeDuringSessionRename(t *testing.T) { | |||||
| 304 | current := ac.currentSession() | 308 | current := ac.currentSession() | ||
| 305 | owners := make([]*session, 0, 1) | 309 | owners := make([]*session, 0, 1) | ||
| 306 | d.mu.Lock() | 310 | d.mu.Lock() | ||
| 307 | - | for _, candidate := range d.sessions { | 311 | + | for _, entry := range d.sessions { |
| 312 | + | candidate, ok := localSession(entry) | |||
| 313 | + | if !ok { | |||
| 314 | + | continue | |||
| 315 | + | } | |||
| 308 | candidate.mu.Lock() | 316 | candidate.mu.Lock() | ||
| 309 | if candidate.client == ac { | 317 | if candidate.client == ac { | ||
| 310 | owners = append(owners, candidate) | 318 | owners = append(owners, candidate) | ||
M internal/usecase/daemon/navigation_role_handoff_test.go +1 -1
| @@ -89,7 +89,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||
| 89 | 89 | d, source, old, _, releases := newManualTabSession(t, 2) | |
| 90 | 90 | defer releaseAll(releases) | |
| 91 | 91 | d.ptys = newFactorySeq(t) | |
| 92 | - | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 92 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 93 | 93 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 94 | 94 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 95 | 95 | }} | |
| @@ -89,7 +89,7 @@ func TestNavigationHandoffsDropReplacedInitiatorWithoutMutation(t *testing.T) { | |||||
| 89 | d, source, old, _, releases := newManualTabSession(t, 2) | 89 | d, source, old, _, releases := newManualTabSession(t, 2) | ||
| 90 | defer releaseAll(releases) | 90 | defer releaseAll(releases) | ||
| 91 | d.ptys = newFactorySeq(t) | 91 | d.ptys = newFactorySeq(t) | ||
| 92 | - | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | 92 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ |
| 93 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | 93 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | ||
| 94 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | 94 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | ||
| 95 | }} | 95 | }} | ||
M internal/usecase/daemon/navigation_role_incarnation_test.go +1 -1
| @@ -39,7 +39,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||
| 39 | 39 | for _, tt := range tests { | |
| 40 | 40 | t.Run(tt.name, func(t *testing.T) { | |
| 41 | 41 | d, source, ac, _ := newManualSessionWithPTYs(t, nil) | |
| 42 | - | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 42 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 43 | 43 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 44 | 44 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | |
| 45 | 45 | }} | |
| @@ -39,7 +39,7 @@ func TestNavigationHandoffRejectsResumedOrReboundInitiatorIncarnation(t *testing | |||||
| 39 | for _, tt := range tests { | 39 | for _, tt := range tests { | ||
| 40 | t.Run(tt.name, func(t *testing.T) { | 40 | t.Run(tt.name, func(t *testing.T) { | ||
| 41 | d, source, ac, _ := newManualSessionWithPTYs(t, nil) | 41 | d, source, ac, _ := newManualSessionWithPTYs(t, nil) | ||
| 42 | - | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | 42 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ |
| 43 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | 43 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | ||
| 44 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | 44 | newTab(nil, domain.Size{Cols: 80, Rows: 23}), | ||
| 45 | }} | 45 | }} | ||
M internal/usecase/daemon/nil_pty_test.go +3 -3
| @@ -17,7 +17,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||
| 17 | 17 | d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 18 | 18 | ctx, cancel := context.WithCancel(context.Background()) | |
| 19 | 19 | defer cancel() | |
| 20 | - | sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel} | |
| 20 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, ctx: ctx, cancel: cancel} | |
| 21 | 21 | d.sessions[sess.id] = sess | |
| 22 | 22 | first := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |
| 23 | 23 | second := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |
| @@ -47,7 +47,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||
| 47 | 47 | func TestKillSessionIgnoresNilPanePTYs(t *testing.T) { | |
| 48 | 48 | d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 49 | 49 | ctx, cancel := context.WithCancel(context.Background()) | |
| 50 | - | sess := &session{id: "s", name: "s", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 10, Rows: 3})}} | |
| 50 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 10, Rows: 3})}} | |
| 51 | 51 | d.sessions[sess.id] = sess | |
| 52 | 52 | ||
| 53 | 53 | require.NotPanics(t, func() { | |
| @@ -61,7 +61,7 @@ func TestNilPTYInputPathsDoNotPanic(t *testing.T) { | |||
| 61 | 61 | ctx, cancel := context.WithCancel(context.Background()) | |
| 62 | 62 | defer cancel() | |
| 63 | 63 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |
| 64 | - | sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel, tabs: []*tab{tb}} | |
| 64 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}} | |
| 65 | 65 | ac := &attachedClient{} | |
| 66 | 66 | ac.initOverlays() | |
| 67 | 67 | ac.setSession(sess) | |
| @@ -17,7 +17,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||||
| 17 | d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) | 17 | d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 18 | ctx, cancel := context.WithCancel(context.Background()) | 18 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 19 | defer cancel() | 19 | defer cancel() | ||
| 20 | - | sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel} | 20 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, ctx: ctx, cancel: cancel} |
| 21 | d.sessions[sess.id] = sess | 21 | d.sessions[sess.id] = sess | ||
| 22 | first := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | 22 | first := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | ||
| 23 | second := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | 23 | second := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | ||
| @@ -47,7 +47,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||||
| 47 | func TestKillSessionIgnoresNilPanePTYs(t *testing.T) { | 47 | func TestKillSessionIgnoresNilPanePTYs(t *testing.T) { | ||
| 48 | d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) | 48 | d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 49 | ctx, cancel := context.WithCancel(context.Background()) | 49 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 50 | - | sess := &session{id: "s", name: "s", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 10, Rows: 3})}} | 50 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 10, Rows: 3})}} |
| 51 | d.sessions[sess.id] = sess | 51 | d.sessions[sess.id] = sess | ||
| 52 | 52 | ||||
| 53 | require.NotPanics(t, func() { | 53 | require.NotPanics(t, func() { | ||
| @@ -61,7 +61,7 @@ func TestNilPTYInputPathsDoNotPanic(t *testing.T) { | |||||
| 61 | ctx, cancel := context.WithCancel(context.Background()) | 61 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 62 | defer cancel() | 62 | defer cancel() | ||
| 63 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | 63 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | ||
| 64 | - | sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel, tabs: []*tab{tb}} | 64 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, ctx: ctx, cancel: cancel, tabs: []*tab{tb}} |
| 65 | ac := &attachedClient{} | 65 | ac := &attachedClient{} | ||
| 66 | ac.initOverlays() | 66 | ac.initOverlays() | ||
| 67 | ac.setSession(sess) | 67 | ac.setSession(sess) | ||
M internal/usecase/daemon/notify.go +1 -4
| @@ -293,10 +293,7 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||
| 293 | 293 | func (d *Daemon) deliverGlobal(n domain.Notification) { | |
| 294 | 294 | d.mu.Lock() | |
| 295 | 295 | d.notices.routingMu.Lock() | |
| 296 | - | sessions := make([]*session, 0, len(d.sessions)) | |
| 297 | - | for _, s := range d.sessions { | |
| 298 | - | sessions = append(sessions, s) | |
| 299 | - | } | |
| 296 | + | sessions := localSessionsSnapshot(d.sessions) | |
| 300 | 297 | d.mu.Unlock() | |
| 301 | 298 | ||
| 302 | 299 | if d.notices.beforeGlobalDelivery != nil { | |
| @@ -293,10 +293,7 @@ func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.No | |||||
| 293 | func (d *Daemon) deliverGlobal(n domain.Notification) { | 293 | func (d *Daemon) deliverGlobal(n domain.Notification) { | ||
| 294 | d.mu.Lock() | 294 | d.mu.Lock() | ||
| 295 | d.notices.routingMu.Lock() | 295 | d.notices.routingMu.Lock() | ||
| 296 | - | sessions := make([]*session, 0, len(d.sessions)) | 296 | + | sessions := localSessionsSnapshot(d.sessions) |
| 297 | - | for _, s := range d.sessions { | |||
| 298 | - | sessions = append(sessions, s) | |||
| 299 | - | } | |||
| 300 | d.mu.Unlock() | 297 | d.mu.Unlock() | ||
| 301 | 298 | ||||
| 302 | if d.notices.beforeGlobalDelivery != nil { | 299 | if d.notices.beforeGlobalDelivery != nil { | ||
M internal/usecase/daemon/notify_test.go +4 -4
| @@ -364,7 +364,7 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach | |||
| 364 | 364 | for _, pane := range tb.panes { | |
| 365 | 365 | pane.ctx, pane.cancel = wctx, wcancel | |
| 366 | 366 | } | |
| 367 | - | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: []*tab{tb}, client: ac} | |
| 367 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}} | |
| 368 | 368 | ac.setSession(sess) | |
| 369 | 369 | d.sessions[sess.id] = sess | |
| 370 | 370 | t.Cleanup(cancel) | |
| @@ -506,7 +506,7 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) { | |||
| 506 | 506 | ||
| 507 | 507 | other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 508 | 508 | other.initOverlays() | |
| 509 | - | sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: other} | |
| 509 | + | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: other}, ctx: sess.ctx, cancel: func() {}} | |
| 510 | 510 | other.setSession(sess2) | |
| 511 | 511 | d.mu.Lock() | |
| 512 | 512 | d.sessions[sess2.id] = sess2 | |
| @@ -596,7 +596,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||
| 596 | 596 | ||
| 597 | 597 | clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 598 | 598 | clientB.initOverlays() | |
| 599 | - | sessB := &session{id: "manual-2", name: "other", ctx: sessA.ctx, cancel: func() {}} | |
| 599 | + | sessB := &session{sessionCore: sessionCore{id: "manual-2", name: "other"}, ctx: sessA.ctx, cancel: func() {}} | |
| 600 | 600 | clientB.setSession(sessB) | |
| 601 | 601 | d.mu.Lock() | |
| 602 | 602 | d.sessions[sessB.id] = sessB | |
| @@ -649,7 +649,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | |||
| 649 | 649 | ||
| 650 | 650 | second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 651 | 651 | second.initOverlays() | |
| 652 | - | sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: second} | |
| 652 | + | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: second}, ctx: sess.ctx, cancel: func() {}} | |
| 653 | 653 | second.setSession(sess2) | |
| 654 | 654 | d.mu.Lock() | |
| 655 | 655 | d.sessions[sess2.id] = sess2 | |
| @@ -364,7 +364,7 @@ func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attach | |||||
| 364 | for _, pane := range tb.panes { | 364 | for _, pane := range tb.panes { | ||
| 365 | pane.ctx, pane.cancel = wctx, wcancel | 365 | pane.ctx, pane.cancel = wctx, wcancel | ||
| 366 | } | 366 | } | ||
| 367 | - | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: []*tab{tb}, client: ac} | 367 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work", client: ac}, ctx: sctx, cancel: cancel, tabs: []*tab{tb}} |
| 368 | ac.setSession(sess) | 368 | ac.setSession(sess) | ||
| 369 | d.sessions[sess.id] = sess | 369 | d.sessions[sess.id] = sess | ||
| 370 | t.Cleanup(cancel) | 370 | t.Cleanup(cancel) | ||
| @@ -506,7 +506,7 @@ func TestNotifyRoutesToSessionClientOnly(t *testing.T) { | |||||
| 506 | 506 | ||||
| 507 | other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 507 | other := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 508 | other.initOverlays() | 508 | other.initOverlays() | ||
| 509 | - | sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: other} | 509 | + | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: other}, ctx: sess.ctx, cancel: func() {}} |
| 510 | other.setSession(sess2) | 510 | other.setSession(sess2) | ||
| 511 | d.mu.Lock() | 511 | d.mu.Lock() | ||
| 512 | d.sessions[sess2.id] = sess2 | 512 | d.sessions[sess2.id] = sess2 | ||
| @@ -596,7 +596,7 @@ func TestSessionScopedNoticeQueuedWhileDetached(t *testing.T) { | |||||
| 596 | 596 | ||||
| 597 | clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 597 | clientB := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 598 | clientB.initOverlays() | 598 | clientB.initOverlays() | ||
| 599 | - | sessB := &session{id: "manual-2", name: "other", ctx: sessA.ctx, cancel: func() {}} | 599 | + | sessB := &session{sessionCore: sessionCore{id: "manual-2", name: "other"}, ctx: sessA.ctx, cancel: func() {}} |
| 600 | clientB.setSession(sessB) | 600 | clientB.setSession(sessB) | ||
| 601 | d.mu.Lock() | 601 | d.mu.Lock() | ||
| 602 | d.sessions[sessB.id] = sessB | 602 | d.sessions[sessB.id] = sessB | ||
| @@ -649,7 +649,7 @@ func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) { | |||||
| 649 | 649 | ||||
| 650 | second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 650 | second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 651 | second.initOverlays() | 651 | second.initOverlays() | ||
| 652 | - | sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: second} | 652 | + | sess2 := &session{sessionCore: sessionCore{id: "manual-2", name: "other", client: second}, ctx: sess.ctx, cancel: func() {}} |
| 653 | second.setSession(sess2) | 653 | second.setSession(sess2) | ||
| 654 | d.mu.Lock() | 654 | d.mu.Lock() | ||
| 655 | d.sessions[sess2.id] = sess2 | 655 | d.sessions[sess2.id] = sess2 | ||
M internal/usecase/daemon/palette_test.go +13 -13
| @@ -199,8 +199,8 @@ func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | |||
| 199 | 199 | p, release := newBlockingPTY(t) | |
| 200 | 200 | defer release() | |
| 201 | 201 | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |
| 202 | - | d.sessions["active"] = &session{id: "active", name: "active", createdAt: 10} | |
| 203 | - | d.sessions["ephemeral"] = &session{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11} | |
| 202 | + | d.sessions["active"] = &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}} | |
| 203 | + | d.sessions["ephemeral"] = &session{sessionCore: sessionCore{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11}} | |
| 204 | 204 | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 12} | |
| 205 | 205 | d.stopped["purging"] = stoppedSession{name: "purging", createdAt: 13, purging: true} | |
| 206 | 206 | ||
| @@ -224,9 +224,9 @@ func TestPaletteRecentSessionsExcludeEphemeralSessions(t *testing.T) { | |||
| 224 | 224 | p, release := newBlockingPTY(t) | |
| 225 | 225 | defer release() | |
| 226 | 226 | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |
| 227 | - | named := &session{id: "named", name: "named", tabs: []*tab{{}}} | |
| 227 | + | named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}} | |
| 228 | 228 | named.mruAt.Store(1) | |
| 229 | - | ephemeral := &session{id: "ephemeral", name: "1", ephemeral: true, tabs: []*tab{{}}} | |
| 229 | + | ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}} | |
| 230 | 230 | ephemeral.mruAt.Store(2) | |
| 231 | 231 | d.sessions[named.id] = named | |
| 232 | 232 | d.sessions[ephemeral.id] = ephemeral | |
| @@ -243,7 +243,7 @@ func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) { | |||
| 243 | 243 | p, release := newBlockingPTY(t) | |
| 244 | 244 | defer release() | |
| 245 | 245 | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |
| 246 | - | target := &session{id: "active", name: "active", createdAt: 10} | |
| 246 | + | target := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}} | |
| 247 | 247 | d.sessions[target.id] = target | |
| 248 | 248 | ||
| 249 | 249 | d.enterPalette(current, ac) | |
| @@ -255,7 +255,7 @@ func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) { | |||
| 255 | 255 | require.True(t, ok) | |
| 256 | 256 | require.True(t, isSession) | |
| 257 | 257 | d.mu.Lock() | |
| 258 | - | delete(d.sessions, target.id) | |
| 258 | + | delete(d.sessions, target.core().id) | |
| 259 | 259 | d.mu.Unlock() | |
| 260 | 260 | ||
| 261 | 261 | d.handlePaletteInput(ac, []byte("\r")) | |
| @@ -271,7 +271,7 @@ func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) { | |||
| 271 | 271 | func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testing.T) { | |
| 272 | 272 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 273 | 273 | defer releaseAll(releases) | |
| 274 | - | target := d.sessions[domain.SessionID("recent")] | |
| 274 | + | target := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) | |
| 275 | 275 | target.createdAt = 42 | |
| 276 | 276 | ||
| 277 | 277 | d.handleInput(current, ac, []byte("\x1b ")) | |
| @@ -287,7 +287,7 @@ func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testin | |||
| 287 | 287 | func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *testing.T) { | |
| 288 | 288 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 289 | 289 | defer releaseAll(releases) | |
| 290 | - | target := d.sessions[domain.SessionID("recent")] | |
| 290 | + | target := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) | |
| 291 | 291 | target.createdAt = 42 | |
| 292 | 292 | ||
| 293 | 293 | d.handleInput(current, ac, []byte("\x1b ")) | |
| @@ -425,11 +425,11 @@ func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) { | |||
| 425 | 425 | func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) { | |
| 426 | 426 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | |
| 427 | 427 | defer releaseAll(releases) | |
| 428 | - | captured := d.sessions[domain.SessionID("recent")] | |
| 428 | + | captured := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) | |
| 429 | 429 | d.handleInput(current, ac, []byte("\x1b ")) | |
| 430 | 430 | awaitFrame(t, sends, ports.MsgOutput) | |
| 431 | 431 | // Reordering live MRU after opening must not shift rank 1 from its capture. | |
| 432 | - | d.sessions[domain.SessionID("older")].mruAt.Store(100) | |
| 432 | + | d.sessions[domain.SessionID("older")].core().mruAt.Store(100) | |
| 433 | 433 | d.handleInput(current, ac, []byte("JRS 1\r")) | |
| 434 | 434 | ||
| 435 | 435 | require.Same(t, captured, ac.currentSession()) | |
| @@ -467,7 +467,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) { | |||
| 467 | 467 | d.afterActionRoleEffectEnded = func(action string) { | |
| 468 | 468 | if action == "back-session" { | |
| 469 | 469 | d.mu.Lock() | |
| 470 | - | delete(d.sessions, target.id) | |
| 470 | + | delete(d.sessions, target.core().id) | |
| 471 | 471 | d.mu.Unlock() | |
| 472 | 472 | } | |
| 473 | 473 | } | |
| @@ -509,7 +509,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |||
| 509 | 509 | p, release := newBlockingPTY(t) | |
| 510 | 510 | defer release() | |
| 511 | 511 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 512 | - | target := &session{id: "captured", name: "captured"} | |
| 512 | + | target := &session{sessionCore: sessionCore{id: "captured", name: "captured"}} | |
| 513 | 513 | d.sessions[target.id] = target | |
| 514 | 514 | ||
| 515 | 515 | validated := make(chan struct{}) | |
| @@ -527,7 +527,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |||
| 527 | 527 | }() | |
| 528 | 528 | awaitTestCompletion(t, validated, "JRS did not validate its captured target") | |
| 529 | 529 | d.mu.Lock() | |
| 530 | - | delete(d.sessions, target.id) | |
| 530 | + | delete(d.sessions, target.core().id) | |
| 531 | 531 | d.mu.Unlock() | |
| 532 | 532 | close(releaseHandoff) | |
| 533 | 533 | ||
| @@ -199,8 +199,8 @@ func TestPaletteEntryPublishesEligibleNamedSessionResults(t *testing.T) { | |||||
| 199 | p, release := newBlockingPTY(t) | 199 | p, release := newBlockingPTY(t) | ||
| 200 | defer release() | 200 | defer release() | ||
| 201 | d, current, ac, _ := newManualSessionWithPTYs(t, p) | 201 | d, current, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 202 | - | d.sessions["active"] = &session{id: "active", name: "active", createdAt: 10} | 202 | + | d.sessions["active"] = &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}} |
| 203 | - | d.sessions["ephemeral"] = &session{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11} | 203 | + | d.sessions["ephemeral"] = &session{sessionCore: sessionCore{id: "ephemeral", name: "ephemeral", ephemeral: true, createdAt: 11}} |
| 204 | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 12} | 204 | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 12} | ||
| 205 | d.stopped["purging"] = stoppedSession{name: "purging", createdAt: 13, purging: true} | 205 | d.stopped["purging"] = stoppedSession{name: "purging", createdAt: 13, purging: true} | ||
| 206 | 206 | ||||
| @@ -224,9 +224,9 @@ func TestPaletteRecentSessionsExcludeEphemeralSessions(t *testing.T) { | |||||
| 224 | p, release := newBlockingPTY(t) | 224 | p, release := newBlockingPTY(t) | ||
| 225 | defer release() | 225 | defer release() | ||
| 226 | d, current, ac, _ := newManualSessionWithPTYs(t, p) | 226 | d, current, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 227 | - | named := &session{id: "named", name: "named", tabs: []*tab{{}}} | 227 | + | named := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}} |
| 228 | named.mruAt.Store(1) | 228 | named.mruAt.Store(1) | ||
| 229 | - | ephemeral := &session{id: "ephemeral", name: "1", ephemeral: true, tabs: []*tab{{}}} | 229 | + | ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}} |
| 230 | ephemeral.mruAt.Store(2) | 230 | ephemeral.mruAt.Store(2) | ||
| 231 | d.sessions[named.id] = named | 231 | d.sessions[named.id] = named | ||
| 232 | d.sessions[ephemeral.id] = ephemeral | 232 | d.sessions[ephemeral.id] = ephemeral | ||
| @@ -243,7 +243,7 @@ func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) { | |||||
| 243 | p, release := newBlockingPTY(t) | 243 | p, release := newBlockingPTY(t) | ||
| 244 | defer release() | 244 | defer release() | ||
| 245 | d, current, ac, _ := newManualSessionWithPTYs(t, p) | 245 | d, current, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 246 | - | target := &session{id: "active", name: "active", createdAt: 10} | 246 | + | target := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 10}} |
| 247 | d.sessions[target.id] = target | 247 | d.sessions[target.id] = target | ||
| 248 | 248 | ||||
| 249 | d.enterPalette(current, ac) | 249 | d.enterPalette(current, ac) | ||
| @@ -255,7 +255,7 @@ func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) { | |||||
| 255 | require.True(t, ok) | 255 | require.True(t, ok) | ||
| 256 | require.True(t, isSession) | 256 | require.True(t, isSession) | ||
| 257 | d.mu.Lock() | 257 | d.mu.Lock() | ||
| 258 | - | delete(d.sessions, target.id) | 258 | + | delete(d.sessions, target.core().id) |
| 259 | d.mu.Unlock() | 259 | d.mu.Unlock() | ||
| 260 | 260 | ||||
| 261 | d.handlePaletteInput(ac, []byte("\r")) | 261 | d.handlePaletteInput(ac, []byte("\r")) | ||
| @@ -271,7 +271,7 @@ func TestPaletteSessionFailureFeedbackClearsOnQueryChange(t *testing.T) { | |||||
| 271 | func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testing.T) { | 271 | func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testing.T) { | ||
| 272 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | 272 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | ||
| 273 | defer releaseAll(releases) | 273 | defer releaseAll(releases) | ||
| 274 | - | target := d.sessions[domain.SessionID("recent")] | 274 | + | target := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) |
| 275 | target.createdAt = 42 | 275 | target.createdAt = 42 | ||
| 276 | 276 | ||||
| 277 | d.handleInput(current, ac, []byte("\x1b ")) | 277 | d.handleInput(current, ac, []byte("\x1b ")) | ||
| @@ -287,7 +287,7 @@ func TestPaletteEnterFreezesActiveSessionSelectionAgainstTrailingInput(t *testin | |||||
| 287 | func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *testing.T) { | 287 | func TestPaletteSelectedActiveSessionSwitchesWithoutRecordingCommandRecency(t *testing.T) { | ||
| 288 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | 288 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | ||
| 289 | defer releaseAll(releases) | 289 | defer releaseAll(releases) | ||
| 290 | - | target := d.sessions[domain.SessionID("recent")] | 290 | + | target := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) |
| 291 | target.createdAt = 42 | 291 | target.createdAt = 42 | ||
| 292 | 292 | ||||
| 293 | d.handleInput(current, ac, []byte("\x1b ")) | 293 | d.handleInput(current, ac, []byte("\x1b ")) | ||
| @@ -425,11 +425,11 @@ func TestPaletteFuzzySelectedStaticCommandExecutes(t *testing.T) { | |||||
| 425 | func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) { | 425 | func TestPaletteJRSUsesCapturedRankAfterMRUChanges(t *testing.T) { | ||
| 426 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | 426 | d, current, ac, sends, releases := newRecentNavigationTestSessions(t) | ||
| 427 | defer releaseAll(releases) | 427 | defer releaseAll(releases) | ||
| 428 | - | captured := d.sessions[domain.SessionID("recent")] | 428 | + | captured := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) |
| 429 | d.handleInput(current, ac, []byte("\x1b ")) | 429 | d.handleInput(current, ac, []byte("\x1b ")) | ||
| 430 | awaitFrame(t, sends, ports.MsgOutput) | 430 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 431 | // Reordering live MRU after opening must not shift rank 1 from its capture. | 431 | // Reordering live MRU after opening must not shift rank 1 from its capture. | ||
| 432 | - | d.sessions[domain.SessionID("older")].mruAt.Store(100) | 432 | + | d.sessions[domain.SessionID("older")].core().mruAt.Store(100) |
| 433 | d.handleInput(current, ac, []byte("JRS 1\r")) | 433 | d.handleInput(current, ac, []byte("JRS 1\r")) | ||
| 434 | 434 | ||||
| 435 | require.Same(t, captured, ac.currentSession()) | 435 | require.Same(t, captured, ac.currentSession()) | ||
| @@ -467,7 +467,7 @@ func TestPaletteFailedRoleHandoffClosesExecutedInteraction(t *testing.T) { | |||||
| 467 | d.afterActionRoleEffectEnded = func(action string) { | 467 | d.afterActionRoleEffectEnded = func(action string) { | ||
| 468 | if action == "back-session" { | 468 | if action == "back-session" { | ||
| 469 | d.mu.Lock() | 469 | d.mu.Lock() | ||
| 470 | - | delete(d.sessions, target.id) | 470 | + | delete(d.sessions, target.core().id) |
| 471 | d.mu.Unlock() | 471 | d.mu.Unlock() | ||
| 472 | } | 472 | } | ||
| 473 | } | 473 | } | ||
| @@ -509,7 +509,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |||||
| 509 | p, release := newBlockingPTY(t) | 509 | p, release := newBlockingPTY(t) | ||
| 510 | defer release() | 510 | defer release() | ||
| 511 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 511 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 512 | - | target := &session{id: "captured", name: "captured"} | 512 | + | target := &session{sessionCore: sessionCore{id: "captured", name: "captured"}} |
| 513 | d.sessions[target.id] = target | 513 | d.sessions[target.id] = target | ||
| 514 | 514 | ||||
| 515 | validated := make(chan struct{}) | 515 | validated := make(chan struct{}) | ||
| @@ -527,7 +527,7 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |||||
| 527 | }() | 527 | }() | ||
| 528 | awaitTestCompletion(t, validated, "JRS did not validate its captured target") | 528 | awaitTestCompletion(t, validated, "JRS did not validate its captured target") | ||
| 529 | d.mu.Lock() | 529 | d.mu.Lock() | ||
| 530 | - | delete(d.sessions, target.id) | 530 | + | delete(d.sessions, target.core().id) |
| 531 | d.mu.Unlock() | 531 | d.mu.Unlock() | ||
| 532 | close(releaseHandoff) | 532 | close(releaseHandoff) | ||
| 533 | 533 | ||||
M internal/usecase/daemon/pane_actions_test.go +1 -1
| @@ -759,7 +759,7 @@ func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *ports | |||
| 759 | 759 | for _, p := range tb.panes { | |
| 760 | 760 | p.ctx, p.cancel = tctx, cancel | |
| 761 | 761 | } | |
| 762 | - | sess := &session{id: "s", name: "s", cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel} | |
| 762 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel} | |
| 763 | 763 | return d, sess, oldPTY, factory | |
| 764 | 764 | } | |
| 765 | 765 | ||
| @@ -759,7 +759,7 @@ func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *ports | |||||
| 759 | for _, p := range tb.panes { | 759 | for _, p := range tb.panes { | ||
| 760 | p.ctx, p.cancel = tctx, cancel | 760 | p.ctx, p.cancel = tctx, cancel | ||
| 761 | } | 761 | } | ||
| 762 | - | sess := &session{id: "s", name: "s", cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel} | 762 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s"}, cwd: "/work", tabs: []*tab{tb}, ctx: tctx, cancel: cancel} |
| 763 | return d, sess, oldPTY, factory | 763 | return d, sess, oldPTY, factory | ||
| 764 | } | 764 | } | ||
| 765 | 765 | ||||
M internal/usecase/daemon/pane_lifetime_test.go +5 -5
| @@ -95,7 +95,7 @@ func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) { | |||
| 95 | 95 | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 96 | 96 | base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 97 | 97 | base.ctx, base.cancel = context.WithCancel(sessCtx) | |
| 98 | - | sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}} | |
| 98 | + | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}} | |
| 99 | 99 | d.mu.Lock() | |
| 100 | 100 | d.sessions[sess.id] = sess | |
| 101 | 101 | d.mu.Unlock() | |
| @@ -113,7 +113,7 @@ func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) { | |||
| 113 | 113 | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 114 | 114 | tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10}) | |
| 115 | 115 | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | |
| 116 | - | sess := &session{id: "work", name: "work", cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | |
| 116 | + | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | |
| 117 | 117 | err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right) | |
| 118 | 118 | return []*tab{tb}, func() { | |
| 119 | 119 | cancelSession() | |
| @@ -234,7 +234,7 @@ func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) { | |||
| 234 | 234 | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 235 | 235 | base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 236 | 236 | base.ctx, base.cancel = context.WithCancel(sessCtx) | |
| 237 | - | sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}} | |
| 237 | + | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}} | |
| 238 | 238 | d.mu.Lock() | |
| 239 | 239 | d.sessions[sess.id] = sess | |
| 240 | 240 | d.mu.Unlock() | |
| @@ -249,7 +249,7 @@ func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) { | |||
| 249 | 249 | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 250 | 250 | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 251 | 251 | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | |
| 252 | - | sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | |
| 252 | + | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | |
| 253 | 253 | target := tb.focusedPane() | |
| 254 | 254 | require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right)) | |
| 255 | 255 | tb.mu.Lock() | |
| @@ -265,7 +265,7 @@ func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) { | |||
| 265 | 265 | sessCtx, cancelSession := context.WithCancel(t.Context()) | |
| 266 | 266 | tb := newFloatingTestTab(t) | |
| 267 | 267 | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | |
| 268 | - | sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | |
| 268 | + | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | |
| 269 | 269 | tb.mu.Lock() | |
| 270 | 270 | generation := tb.beginFloatingWarmLocked(true) | |
| 271 | 271 | tb.mu.Unlock() | |
| @@ -95,7 +95,7 @@ func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) { | |||||
| 95 | sessCtx, cancelSession := context.WithCancel(t.Context()) | 95 | sessCtx, cancelSession := context.WithCancel(t.Context()) | ||
| 96 | base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | 96 | base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | ||
| 97 | base.ctx, base.cancel = context.WithCancel(sessCtx) | 97 | base.ctx, base.cancel = context.WithCancel(sessCtx) | ||
| 98 | - | sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}} | 98 | + | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}} |
| 99 | d.mu.Lock() | 99 | d.mu.Lock() | ||
| 100 | d.sessions[sess.id] = sess | 100 | d.sessions[sess.id] = sess | ||
| 101 | d.mu.Unlock() | 101 | d.mu.Unlock() | ||
| @@ -113,7 +113,7 @@ func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) { | |||||
| 113 | sessCtx, cancelSession := context.WithCancel(t.Context()) | 113 | sessCtx, cancelSession := context.WithCancel(t.Context()) | ||
| 114 | tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10}) | 114 | tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10}) | ||
| 115 | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | 115 | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | ||
| 116 | - | sess := &session{id: "work", name: "work", cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | 116 | + | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} |
| 117 | err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right) | 117 | err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right) | ||
| 118 | return []*tab{tb}, func() { | 118 | return []*tab{tb}, func() { | ||
| 119 | cancelSession() | 119 | cancelSession() | ||
| @@ -234,7 +234,7 @@ func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) { | |||||
| 234 | sessCtx, cancelSession := context.WithCancel(t.Context()) | 234 | sessCtx, cancelSession := context.WithCancel(t.Context()) | ||
| 235 | base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | 235 | base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | ||
| 236 | base.ctx, base.cancel = context.WithCancel(sessCtx) | 236 | base.ctx, base.cancel = context.WithCancel(sessCtx) | ||
| 237 | - | sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}} | 237 | + | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}} |
| 238 | d.mu.Lock() | 238 | d.mu.Lock() | ||
| 239 | d.sessions[sess.id] = sess | 239 | d.sessions[sess.id] = sess | ||
| 240 | d.mu.Unlock() | 240 | d.mu.Unlock() | ||
| @@ -249,7 +249,7 @@ func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) { | |||||
| 249 | sessCtx, cancelSession := context.WithCancel(t.Context()) | 249 | sessCtx, cancelSession := context.WithCancel(t.Context()) | ||
| 250 | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | 250 | tb := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | ||
| 251 | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | 251 | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | ||
| 252 | - | sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | 252 | + | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} |
| 253 | target := tb.focusedPane() | 253 | target := tb.focusedPane() | ||
| 254 | require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right)) | 254 | require.NoError(t, d.splitPaneAt(sess, tb, target, layout.Right)) | ||
| 255 | tb.mu.Lock() | 255 | tb.mu.Lock() | ||
| @@ -265,7 +265,7 @@ func TestPaneProcessLifetimeIsDaemonRooted(t *testing.T) { | |||||
| 265 | sessCtx, cancelSession := context.WithCancel(t.Context()) | 265 | sessCtx, cancelSession := context.WithCancel(t.Context()) | ||
| 266 | tb := newFloatingTestTab(t) | 266 | tb := newFloatingTestTab(t) | ||
| 267 | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | 267 | tb.ctx, tb.cancel = context.WithCancel(sessCtx) | ||
| 268 | - | sess := &session{id: "work", name: "work", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} | 268 | + | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}} |
| 269 | tb.mu.Lock() | 269 | tb.mu.Lock() | ||
| 270 | generation := tb.beginFloatingWarmLocked(true) | 270 | generation := tb.beginFloatingWarmLocked(true) | ||
| 271 | tb.mu.Unlock() | 271 | tb.mu.Unlock() | ||
M internal/usecase/daemon/pane_owner_reader_test.go +6 -6
| @@ -79,11 +79,11 @@ func TestPTYReaderRoutesEffectsToOwnerPublishedForEachRead(t *testing.T) { | |||
| 79 | 79 | source.snapEligible.Store(true) | |
| 80 | 80 | ||
| 81 | 81 | destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |
| 82 | - | destination := &session{ | |
| 83 | - | id: domain.SessionID("destination"), | |
| 84 | - | name: "destination", | |
| 85 | - | tabs: []*tab{destinationTab}, | |
| 86 | - | client: &attachedClient{}, | |
| 82 | + | destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"), | |
| 83 | + | name: "destination", | |
| 84 | + | ||
| 85 | + | client: &attachedClient{}}, tabs: []*tab{destinationTab}, | |
| 86 | + | ||
| 87 | 87 | snapEligible: atomic.Bool{}, | |
| 88 | 88 | } | |
| 89 | 89 | destination.snapEligible.Store(true) | |
| @@ -134,7 +134,7 @@ func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) { | |||
| 134 | 134 | require.NoError(t, destinationTab.tree.Split(layout.PaneID("pane-1"), layout.Right, true, other.id, domain.Rect{Width: 80, Height: 23})) | |
| 135 | 135 | destinationTab.panes[other.id] = other | |
| 136 | 136 | destinationTab.mu.Unlock() | |
| 137 | - | destination := &session{id: domain.SessionID("destination"), name: "destination", tabs: []*tab{destinationTab}} | |
| 137 | + | destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"), name: "destination"}, tabs: []*tab{destinationTab}} | |
| 138 | 138 | d.sessions[destination.id] = destination | |
| 139 | 139 | ||
| 140 | 140 | // Model a completed owner commit while the one reader remains blocked in | |
| @@ -79,11 +79,11 @@ func TestPTYReaderRoutesEffectsToOwnerPublishedForEachRead(t *testing.T) { | |||||
| 79 | source.snapEligible.Store(true) | 79 | source.snapEligible.Store(true) | ||
| 80 | 80 | ||||
| 81 | destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | 81 | destinationTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | ||
| 82 | - | destination := &session{ | 82 | + | destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"), |
| 83 | - | id: domain.SessionID("destination"), | 83 | + | name: "destination", |
| 84 | - | name: "destination", | 84 | + | |
| 85 | - | tabs: []*tab{destinationTab}, | 85 | + | client: &attachedClient{}}, tabs: []*tab{destinationTab}, |
| 86 | - | client: &attachedClient{}, | 86 | + | |
| 87 | snapEligible: atomic.Bool{}, | 87 | snapEligible: atomic.Bool{}, | ||
| 88 | } | 88 | } | ||
| 89 | destination.snapEligible.Store(true) | 89 | destination.snapEligible.Store(true) | ||
| @@ -134,7 +134,7 @@ func TestPTYReaderExitReapsExactlyCurrentOwnerAfterPublication(t *testing.T) { | |||||
| 134 | require.NoError(t, destinationTab.tree.Split(layout.PaneID("pane-1"), layout.Right, true, other.id, domain.Rect{Width: 80, Height: 23})) | 134 | require.NoError(t, destinationTab.tree.Split(layout.PaneID("pane-1"), layout.Right, true, other.id, domain.Rect{Width: 80, Height: 23})) | ||
| 135 | destinationTab.panes[other.id] = other | 135 | destinationTab.panes[other.id] = other | ||
| 136 | destinationTab.mu.Unlock() | 136 | destinationTab.mu.Unlock() | ||
| 137 | - | destination := &session{id: domain.SessionID("destination"), name: "destination", tabs: []*tab{destinationTab}} | 137 | + | destination := &session{sessionCore: sessionCore{id: domain.SessionID("destination"), name: "destination"}, tabs: []*tab{destinationTab}} |
| 138 | d.sessions[destination.id] = destination | 138 | d.sessions[destination.id] = destination | ||
| 139 | 139 | ||||
| 140 | // Model a completed owner commit while the one reader remains blocked in | 140 | // Model a completed owner commit while the one reader remains blocked in | ||
M internal/usecase/daemon/pane_owner_test.go +4 -4
| @@ -10,9 +10,9 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | func TestPaneOwnerPublicationAdvancesGenerationAndInvalidatesLease(t *testing.T) { | |
| 12 | 12 | p := newPaneWithStableID(layout.PaneID("pane-1"), "p_stable", nil, domain.Size{Cols: 80, Rows: 23}) | |
| 13 | - | firstSession := &session{id: "first"} | |
| 13 | + | firstSession := &session{sessionCore: sessionCore{id: "first"}} | |
| 14 | 14 | firstTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |
| 15 | - | secondSession := &session{id: "second"} | |
| 15 | + | secondSession := &session{sessionCore: sessionCore{id: "second"}} | |
| 16 | 16 | secondTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |
| 17 | 17 | ||
| 18 | 18 | p.mu.Lock() | |
| @@ -51,7 +51,7 @@ func TestPaneOwnerPublicationAdvancesGenerationAndInvalidatesLease(t *testing.T) | |||
| 51 | 51 | ||
| 52 | 52 | func TestFloatingPaneOwnerLeaseRequiresCurrentSlotGeneration(t *testing.T) { | |
| 53 | 53 | p := newPaneWithStableID(layout.PaneID("floating"), "p_float", nil, domain.Size{Cols: 20, Rows: 8}) | |
| 54 | - | sess := &session{id: "owner"} | |
| 54 | + | sess := &session{sessionCore: sessionCore{id: "owner"}} | |
| 55 | 55 | tb := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | |
| 56 | 56 | ||
| 57 | 57 | tb.mu.Lock() | |
| @@ -72,7 +72,7 @@ func TestFloatingPaneOwnerLeaseRequiresCurrentSlotGeneration(t *testing.T) { | |||
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | func TestPublishTiledPaneOwnersInitializesAllPanesBeforePublication(t *testing.T) { | |
| 75 | - | sess := &session{id: "owner"} | |
| 75 | + | sess := &session{sessionCore: sessionCore{id: "owner"}} | |
| 76 | 76 | tb := newTabWithStableID("t_stable", "p_one", nil, domain.Size{Cols: 80, Rows: 23}) | |
| 77 | 77 | second := newPaneWithStableID("pane-2", "p_two", nil, domain.Size{Cols: 40, Rows: 23}) | |
| 78 | 78 | tb.panes[second.id] = second | |
| @@ -10,9 +10,9 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | func TestPaneOwnerPublicationAdvancesGenerationAndInvalidatesLease(t *testing.T) { | 11 | func TestPaneOwnerPublicationAdvancesGenerationAndInvalidatesLease(t *testing.T) { | ||
| 12 | p := newPaneWithStableID(layout.PaneID("pane-1"), "p_stable", nil, domain.Size{Cols: 80, Rows: 23}) | 12 | p := newPaneWithStableID(layout.PaneID("pane-1"), "p_stable", nil, domain.Size{Cols: 80, Rows: 23}) | ||
| 13 | - | firstSession := &session{id: "first"} | 13 | + | firstSession := &session{sessionCore: sessionCore{id: "first"}} |
| 14 | firstTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | 14 | firstTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | ||
| 15 | - | secondSession := &session{id: "second"} | 15 | + | secondSession := &session{sessionCore: sessionCore{id: "second"}} |
| 16 | secondTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | 16 | secondTab := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | ||
| 17 | 17 | ||||
| 18 | p.mu.Lock() | 18 | p.mu.Lock() | ||
| @@ -51,7 +51,7 @@ func TestPaneOwnerPublicationAdvancesGenerationAndInvalidatesLease(t *testing.T) | |||||
| 51 | 51 | ||||
| 52 | func TestFloatingPaneOwnerLeaseRequiresCurrentSlotGeneration(t *testing.T) { | 52 | func TestFloatingPaneOwnerLeaseRequiresCurrentSlotGeneration(t *testing.T) { | ||
| 53 | p := newPaneWithStableID(layout.PaneID("floating"), "p_float", nil, domain.Size{Cols: 20, Rows: 8}) | 53 | p := newPaneWithStableID(layout.PaneID("floating"), "p_float", nil, domain.Size{Cols: 20, Rows: 8}) | ||
| 54 | - | sess := &session{id: "owner"} | 54 | + | sess := &session{sessionCore: sessionCore{id: "owner"}} |
| 55 | tb := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | 55 | tb := newTab(nil, domain.Size{Cols: 80, Rows: 23}) | ||
| 56 | 56 | ||||
| 57 | tb.mu.Lock() | 57 | tb.mu.Lock() | ||
| @@ -72,7 +72,7 @@ func TestFloatingPaneOwnerLeaseRequiresCurrentSlotGeneration(t *testing.T) { | |||||
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | func TestPublishTiledPaneOwnersInitializesAllPanesBeforePublication(t *testing.T) { | 74 | func TestPublishTiledPaneOwnersInitializesAllPanesBeforePublication(t *testing.T) { | ||
| 75 | - | sess := &session{id: "owner"} | 75 | + | sess := &session{sessionCore: sessionCore{id: "owner"}} |
| 76 | tb := newTabWithStableID("t_stable", "p_one", nil, domain.Size{Cols: 80, Rows: 23}) | 76 | tb := newTabWithStableID("t_stable", "p_one", nil, domain.Size{Cols: 80, Rows: 23}) | ||
| 77 | second := newPaneWithStableID("pane-2", "p_two", nil, domain.Size{Cols: 40, Rows: 23}) | 77 | second := newPaneWithStableID("pane-2", "p_two", nil, domain.Size{Cols: 40, Rows: 23}) | ||
| 78 | tb.panes[second.id] = second | 78 | tb.panes[second.id] = second | ||
M internal/usecase/daemon/pane_rearrange_test.go +2 -2
| @@ -84,7 +84,7 @@ func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) { | |||
| 84 | 84 | name: "session", | |
| 85 | 85 | stale: func(h *paneRearrangeHarness) daemonActionTarget { | |
| 86 | 86 | target := h.target("pane-1") | |
| 87 | - | h.daemon.sessions[h.session.id] = &session{id: h.session.id} | |
| 87 | + | h.daemon.sessions[h.session.id] = &session{sessionCore: sessionCore{id: h.session.id}} | |
| 88 | 88 | return target | |
| 89 | 89 | }, | |
| 90 | 90 | }, | |
| @@ -481,7 +481,7 @@ func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree) | |||
| 481 | 481 | for _, p := range tb.panes { | |
| 482 | 482 | p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx) | |
| 483 | 483 | } | |
| 484 | - | sess := &session{id: "s", name: "work", cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}} | |
| 484 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "work"}, cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}} | |
| 485 | 485 | sess.snapEligible.Store(true) | |
| 486 | 486 | d.sessions[sess.id] = sess | |
| 487 | 487 | return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory} | |
| @@ -84,7 +84,7 @@ func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) { | |||||
| 84 | name: "session", | 84 | name: "session", | ||
| 85 | stale: func(h *paneRearrangeHarness) daemonActionTarget { | 85 | stale: func(h *paneRearrangeHarness) daemonActionTarget { | ||
| 86 | target := h.target("pane-1") | 86 | target := h.target("pane-1") | ||
| 87 | - | h.daemon.sessions[h.session.id] = &session{id: h.session.id} | 87 | + | h.daemon.sessions[h.session.id] = &session{sessionCore: sessionCore{id: h.session.id}} |
| 88 | return target | 88 | return target | ||
| 89 | }, | 89 | }, | ||
| 90 | }, | 90 | }, | ||
| @@ -481,7 +481,7 @@ func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree) | |||||
| 481 | for _, p := range tb.panes { | 481 | for _, p := range tb.panes { | ||
| 482 | p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx) | 482 | p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx) | ||
| 483 | } | 483 | } | ||
| 484 | - | sess := &session{id: "s", name: "work", cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}} | 484 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "work"}, cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}} |
| 485 | sess.snapEligible.Store(true) | 485 | sess.snapEligible.Store(true) | ||
| 486 | d.sessions[sess.id] = sess | 486 | d.sessions[sess.id] = sess | ||
| 487 | return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory} | 487 | return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory} | ||
M internal/usecase/daemon/pane_sync_owner_test.go +3 -3
| @@ -27,9 +27,9 @@ func newPaneSyncOwnerFixture(t *testing.T) paneSyncOwnerFixture { | |||
| 27 | 27 | size := domain.Size{Cols: 80, Rows: 23} | |
| 28 | 28 | sourceTab := newTab(newQuietPTY(), size) | |
| 29 | 29 | targetTab := newTab(newQuietPTY(), size) | |
| 30 | - | source := &session{id: "sync-source", tabs: []*tab{sourceTab}, active: 0, client: &attachedClient{}} | |
| 31 | - | destination := &session{id: "sync-destination", tabs: []*tab{targetTab}, active: 0, client: &attachedClient{}} | |
| 32 | - | d := &Daemon{sessions: map[domain.SessionID]*session{source.id: source, destination.id: destination}} | |
| 30 | + | source := &session{sessionCore: sessionCore{id: "sync-source", client: &attachedClient{}}, tabs: []*tab{sourceTab}, active: 0} | |
| 31 | + | destination := &session{sessionCore: sessionCore{id: "sync-destination", client: &attachedClient{}}, tabs: []*tab{targetTab}, active: 0} | |
| 32 | + | d := &Daemon{sessions: map[domain.SessionID]attachmentSession{source.id: source, destination.id: destination}} | |
| 33 | 33 | p := sourceTab.focusedPane() | |
| 34 | 34 | publishPaneOwner(p, source, sourceTab, 0) | |
| 35 | 35 | ||
| @@ -27,9 +27,9 @@ func newPaneSyncOwnerFixture(t *testing.T) paneSyncOwnerFixture { | |||||
| 27 | size := domain.Size{Cols: 80, Rows: 23} | 27 | size := domain.Size{Cols: 80, Rows: 23} | ||
| 28 | sourceTab := newTab(newQuietPTY(), size) | 28 | sourceTab := newTab(newQuietPTY(), size) | ||
| 29 | targetTab := newTab(newQuietPTY(), size) | 29 | targetTab := newTab(newQuietPTY(), size) | ||
| 30 | - | source := &session{id: "sync-source", tabs: []*tab{sourceTab}, active: 0, client: &attachedClient{}} | 30 | + | source := &session{sessionCore: sessionCore{id: "sync-source", client: &attachedClient{}}, tabs: []*tab{sourceTab}, active: 0} |
| 31 | - | destination := &session{id: "sync-destination", tabs: []*tab{targetTab}, active: 0, client: &attachedClient{}} | 31 | + | destination := &session{sessionCore: sessionCore{id: "sync-destination", client: &attachedClient{}}, tabs: []*tab{targetTab}, active: 0} |
| 32 | - | d := &Daemon{sessions: map[domain.SessionID]*session{source.id: source, destination.id: destination}} | 32 | + | d := &Daemon{sessions: map[domain.SessionID]attachmentSession{source.id: source, destination.id: destination}} |
| 33 | p := sourceTab.focusedPane() | 33 | p := sourceTab.focusedPane() | ||
| 34 | publishPaneOwner(p, source, sourceTab, 0) | 34 | publishPaneOwner(p, source, sourceTab, 0) | ||
| 35 | 35 | ||||
M internal/usecase/daemon/picker.go +22 -10
| @@ -422,7 +422,11 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||
| 422 | 422 | } | |
| 423 | 423 | d.mu.Lock() | |
| 424 | 424 | var clients []*attachedClient | |
| 425 | - | for _, sess := range d.sessions { | |
| 425 | + | for _, entry := range d.sessions { | |
| 426 | + | sess, ok := localSession(entry) | |
| 427 | + | if !ok { | |
| 428 | + | continue | |
| 429 | + | } | |
| 426 | 430 | sess.mu.Lock() | |
| 427 | 431 | if sess.client != nil { | |
| 428 | 432 | clients = append(clients, sess.client) | |
| @@ -470,7 +474,7 @@ func pickerTargetsEqual(left, right picker.Target) bool { | |||
| 470 | 474 | return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt | |
| 471 | 475 | } | |
| 472 | 476 | ||
| 473 | - | func (d *Daemon) sessionByID(id domain.SessionID) *session { | |
| 477 | + | func (d *Daemon) sessionByID(id domain.SessionID) attachmentSession { | |
| 474 | 478 | d.mu.Lock() | |
| 475 | 479 | defer d.mu.Unlock() | |
| 476 | 480 | return d.sessions[id] | |
| @@ -513,8 +517,12 @@ func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target pic | |||
| 513 | 517 | } | |
| 514 | 518 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | |
| 515 | 519 | } | |
| 516 | - | d.touchMRU(targetSess) | |
| 517 | - | token.ac.recordPreviousSession(token.sess) | |
| 520 | + | if target, ok := localSession(targetSess); ok { | |
| 521 | + | d.touchMRU(target) | |
| 522 | + | } | |
| 523 | + | if source, ok := localSession(token.sess); ok { | |
| 524 | + | token.ac.recordPreviousSession(source) | |
| 525 | + | } | |
| 518 | 526 | d.deferAttachmentTransitionCleanups(transition) | |
| 519 | 527 | d.firstPaintForTransition(transition.published) | |
| 520 | 528 | return nil | |
| @@ -537,7 +545,11 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker. | |||
| 537 | 545 | } | |
| 538 | 546 | token.effect.bindActionEnd(d, action) | |
| 539 | 547 | token.effect.End() | |
| 540 | - | return d.switchToTargetGuardedForRole(token.sess, token.ac, target, guard, &token, action) | |
| 548 | + | sess, ok := localSession(token.sess) | |
| 549 | + | if !ok { | |
| 550 | + | return errAttachmentTransition | |
| 551 | + | } | |
| 552 | + | return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action) | |
| 541 | 553 | } | |
| 542 | 554 | ||
| 543 | 555 | // switchToTargetGuarded is retained for daemon-internal and headless callers. | |
| @@ -580,7 +592,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient, | |||
| 580 | 592 | targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action) | |
| 581 | 593 | } | |
| 582 | 594 | } | |
| 583 | - | } else if active := d.sessions[target.Session]; active != nil { | |
| 595 | + | } else if active, ok := localSession(d.sessions[target.Session]); ok { | |
| 584 | 596 | targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action) | |
| 585 | 597 | } | |
| 586 | 598 | d.mu.Unlock() | |
| @@ -849,9 +861,9 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR | |||
| 849 | 861 | return d.killPickerTarget(target) | |
| 850 | 862 | } | |
| 851 | 863 | d.mu.Lock() | |
| 852 | - | targetSess := d.sessions[target.Session] | |
| 864 | + | targetSess, ok := localSession(d.sessions[target.Session]) | |
| 853 | 865 | d.mu.Unlock() | |
| 854 | - | if targetSess == nil { | |
| 866 | + | if !ok { | |
| 855 | 867 | return nil | |
| 856 | 868 | } | |
| 857 | 869 | return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete") | |
| @@ -866,9 +878,9 @@ func (d *Daemon) killPickerTarget(target picker.Target) error { | |||
| 866 | 878 | return nil | |
| 867 | 879 | } | |
| 868 | 880 | d.mu.Lock() | |
| 869 | - | targetSess := d.sessions[target.Session] | |
| 881 | + | targetSess, ok := localSession(d.sessions[target.Session]) | |
| 870 | 882 | d.mu.Unlock() | |
| 871 | - | if targetSess != nil { | |
| 883 | + | if ok { | |
| 872 | 884 | return d.killSession(targetSess, ports.ReasonSessionKilled, true) | |
| 873 | 885 | } | |
| 874 | 886 | return nil | |
| @@ -422,7 +422,11 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) { | |||||
| 422 | } | 422 | } | ||
| 423 | d.mu.Lock() | 423 | d.mu.Lock() | ||
| 424 | var clients []*attachedClient | 424 | var clients []*attachedClient | ||
| 425 | - | for _, sess := range d.sessions { | 425 | + | for _, entry := range d.sessions { |
| 426 | + | sess, ok := localSession(entry) | |||
| 427 | + | if !ok { | |||
| 428 | + | continue | |||
| 429 | + | } | |||
| 426 | sess.mu.Lock() | 430 | sess.mu.Lock() | ||
| 427 | if sess.client != nil { | 431 | if sess.client != nil { | ||
| 428 | clients = append(clients, sess.client) | 432 | clients = append(clients, sess.client) | ||
| @@ -470,7 +474,7 @@ func pickerTargetsEqual(left, right picker.Target) bool { | |||||
| 470 | return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt | 474 | return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt | ||
| 471 | } | 475 | } | ||
| 472 | 476 | ||||
| 473 | - | func (d *Daemon) sessionByID(id domain.SessionID) *session { | 477 | + | func (d *Daemon) sessionByID(id domain.SessionID) attachmentSession { |
| 474 | d.mu.Lock() | 478 | d.mu.Lock() | ||
| 475 | defer d.mu.Unlock() | 479 | defer d.mu.Unlock() | ||
| 476 | return d.sessions[id] | 480 | return d.sessions[id] | ||
| @@ -513,8 +517,12 @@ func (d *Daemon) switchActiveTargetForRole(token attachmentRoleToken, target pic | |||||
| 513 | } | 517 | } | ||
| 514 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | 518 | return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err) | ||
| 515 | } | 519 | } | ||
| 516 | - | d.touchMRU(targetSess) | 520 | + | if target, ok := localSession(targetSess); ok { |
| 517 | - | token.ac.recordPreviousSession(token.sess) | 521 | + | d.touchMRU(target) |
| 522 | + | } | |||
| 523 | + | if source, ok := localSession(token.sess); ok { | |||
| 524 | + | token.ac.recordPreviousSession(source) | |||
| 525 | + | } | |||
| 518 | d.deferAttachmentTransitionCleanups(transition) | 526 | d.deferAttachmentTransitionCleanups(transition) | ||
| 519 | d.firstPaintForTransition(transition.published) | 527 | d.firstPaintForTransition(transition.published) | ||
| 520 | return nil | 528 | return nil | ||
| @@ -537,7 +545,11 @@ func (d *Daemon) switchToTargetForRole(token attachmentRoleToken, target picker. | |||||
| 537 | } | 545 | } | ||
| 538 | token.effect.bindActionEnd(d, action) | 546 | token.effect.bindActionEnd(d, action) | ||
| 539 | token.effect.End() | 547 | token.effect.End() | ||
| 540 | - | return d.switchToTargetGuardedForRole(token.sess, token.ac, target, guard, &token, action) | 548 | + | sess, ok := localSession(token.sess) |
| 549 | + | if !ok { | |||
| 550 | + | return errAttachmentTransition | |||
| 551 | + | } | |||
| 552 | + | return d.switchToTargetGuardedForRole(sess, token.ac, target, guard, &token, action) | |||
| 541 | } | 553 | } | ||
| 542 | 554 | ||||
| 543 | // switchToTargetGuarded is retained for daemon-internal and headless callers. | 555 | // switchToTargetGuarded is retained for daemon-internal and headless callers. | ||
| @@ -580,7 +592,7 @@ func (d *Daemon) switchToTargetGuardedForRole(from *session, ac *attachedClient, | |||||
| 580 | targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action) | 592 | targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action) | ||
| 581 | } | 593 | } | ||
| 582 | } | 594 | } | ||
| 583 | - | } else if active := d.sessions[target.Session]; active != nil { | 595 | + | } else if active, ok := localSession(d.sessions[target.Session]); ok { |
| 584 | targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action) | 596 | targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action) | ||
| 585 | } | 597 | } | ||
| 586 | d.mu.Unlock() | 598 | d.mu.Unlock() | ||
| @@ -849,9 +861,9 @@ func (d *Daemon) killPickerTargetForRole(target picker.Target, token attachmentR | |||||
| 849 | return d.killPickerTarget(target) | 861 | return d.killPickerTarget(target) | ||
| 850 | } | 862 | } | ||
| 851 | d.mu.Lock() | 863 | d.mu.Lock() | ||
| 852 | - | targetSess := d.sessions[target.Session] | 864 | + | targetSess, ok := localSession(d.sessions[target.Session]) |
| 853 | d.mu.Unlock() | 865 | d.mu.Unlock() | ||
| 854 | - | if targetSess == nil { | 866 | + | if !ok { |
| 855 | return nil | 867 | return nil | ||
| 856 | } | 868 | } | ||
| 857 | return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete") | 869 | return d.killSessionForRole(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete") | ||
| @@ -866,9 +878,9 @@ func (d *Daemon) killPickerTarget(target picker.Target) error { | |||||
| 866 | return nil | 878 | return nil | ||
| 867 | } | 879 | } | ||
| 868 | d.mu.Lock() | 880 | d.mu.Lock() | ||
| 869 | - | targetSess := d.sessions[target.Session] | 881 | + | targetSess, ok := localSession(d.sessions[target.Session]) |
| 870 | d.mu.Unlock() | 882 | d.mu.Unlock() | ||
| 871 | - | if targetSess != nil { | 883 | + | if ok { |
| 872 | return d.killSession(targetSess, ports.ReasonSessionKilled, true) | 884 | return d.killSession(targetSess, ports.ReasonSessionKilled, true) | ||
| 873 | } | 885 | } | ||
| 874 | return nil | 886 | return nil | ||
M internal/usecase/daemon/picker_test.go +40 -55
| @@ -70,8 +70,8 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 70 | 70 | d := newTestDaemon(t, nil, stubClock{}) | |
| 71 | 71 | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 72 | 72 | defer cancel() | |
| 73 | - | current := &session{id: "s1", name: "alpha", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}} | |
| 74 | - | ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}} | |
| 73 | + | current := &session{sessionCore: sessionCore{id: "s1", name: "alpha"}, ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}} | |
| 74 | + | ringing := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}} | |
| 75 | 75 | ringing.mu.Lock() | |
| 76 | 76 | ringing.tabs[1].attention = true | |
| 77 | 77 | ringing.tabs[1].attentionAt = time.Unix(10, 0) | |
| @@ -99,8 +99,8 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 99 | 99 | ||
| 100 | 100 | func TestPickerViewsIncludesEphemeralSessions(t *testing.T) { | |
| 101 | 101 | d := newTestDaemon(t, nil, stubClock{}) | |
| 102 | - | current := &session{id: "current", name: "current", tabs: []*tab{{}}} | |
| 103 | - | ephemeral := &session{id: "ephemeral", name: "1", ephemeral: true, tabs: []*tab{{}}} | |
| 102 | + | current := &session{sessionCore: sessionCore{id: "current", name: "current"}, tabs: []*tab{{}}} | |
| 103 | + | ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}} | |
| 104 | 104 | d.sessions[current.id] = current | |
| 105 | 105 | d.sessions[ephemeral.id] = ephemeral | |
| 106 | 106 | ||
| @@ -120,11 +120,11 @@ func TestPickerViewsIncludesEphemeralSessions(t *testing.T) { | |||
| 120 | 120 | ||
| 121 | 121 | func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) { | |
| 122 | 122 | d := newTestDaemon(t, nil, stubClock{}) | |
| 123 | - | old := &session{id: "old", name: "alpha", tabs: []*tab{{}}} | |
| 123 | + | old := &session{sessionCore: sessionCore{id: "old", name: "alpha"}, tabs: []*tab{{}}} | |
| 124 | 124 | old.mruAt.Store(1) | |
| 125 | - | eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}} | |
| 125 | + | eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}} | |
| 126 | 126 | eph.mruAt.Store(3) | |
| 127 | - | recent := &session{id: "recent", name: "zeta", tabs: []*tab{{}}} | |
| 127 | + | recent := &session{sessionCore: sessionCore{id: "recent", name: "zeta"}, tabs: []*tab{{}}} | |
| 128 | 128 | recent.mruAt.Store(2) | |
| 129 | 129 | d.sessions[old.id] = old | |
| 130 | 130 | d.sessions[eph.id] = eph | |
| @@ -146,8 +146,8 @@ func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) { | |||
| 146 | 146 | ||
| 147 | 147 | func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) { | |
| 148 | 148 | d := newTestDaemon(t, nil, stubClock{}) | |
| 149 | - | b := &session{id: "b", name: "bravo", tabs: []*tab{{}}} | |
| 150 | - | a := &session{id: "a", name: "alpha", tabs: []*tab{{}}} | |
| 149 | + | b := &session{sessionCore: sessionCore{id: "b", name: "bravo"}, tabs: []*tab{{}}} | |
| 150 | + | a := &session{sessionCore: sessionCore{id: "a", name: "alpha"}, tabs: []*tab{{}}} | |
| 151 | 151 | // Equal mruAt (both zero) must yield a stable alphabetical order. | |
| 152 | 152 | d.sessions[b.id] = b | |
| 153 | 153 | d.sessions[a.id] = a | |
| @@ -161,11 +161,11 @@ func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) { | |||
| 161 | 161 | func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) { | |
| 162 | 162 | d := newTestDaemon(t, nil, stubClock{}) | |
| 163 | 163 | d.pickerSort.Store(uint32(pickerSortGrouped)) | |
| 164 | - | eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}} | |
| 164 | + | eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}} | |
| 165 | 165 | eph.mruAt.Store(5) | |
| 166 | - | named := &session{id: "named", name: "work", tabs: []*tab{{}}} | |
| 166 | + | named := &session{sessionCore: sessionCore{id: "named", name: "work"}, tabs: []*tab{{}}} | |
| 167 | 167 | named.mruAt.Store(2) | |
| 168 | - | named2 := &session{id: "named2", name: "notes", tabs: []*tab{{}}} | |
| 168 | + | named2 := &session{sessionCore: sessionCore{id: "named2", name: "notes"}, tabs: []*tab{{}}} | |
| 169 | 169 | named2.mruAt.Store(3) | |
| 170 | 170 | d.sessions[eph.id] = eph | |
| 171 | 171 | d.sessions[named.id] = named | |
| @@ -206,7 +206,7 @@ func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) { | |||
| 206 | 206 | sess.mruAt.Store(1) | |
| 207 | 207 | // A second live session, ephemeral and more recently used: recent mode | |
| 208 | 208 | // lists it first, grouped mode moves it below the named session. | |
| 209 | - | eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}} | |
| 209 | + | eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}} | |
| 210 | 210 | eph.mruAt.Store(5) | |
| 211 | 211 | d.mu.Lock() | |
| 212 | 212 | d.sessions[eph.id] = eph | |
| @@ -279,7 +279,7 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||
| 279 | 279 | d := newTestDaemon(t, nil, stubClock{}) | |
| 280 | 280 | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 281 | 281 | defer cancel() | |
| 282 | - | active := &session{id: "active", name: "active", createdAt: 23, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 282 | + | active := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 23}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 283 | 283 | d.sessions[active.id] = active | |
| 284 | 284 | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24} | |
| 285 | 285 | ||
| @@ -294,17 +294,13 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||
| 294 | 294 | ||
| 295 | 295 | func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) { | |
| 296 | 296 | d := newTestDaemon(t, nil, stubClock{}) | |
| 297 | - | source := &session{ | |
| 298 | - | id: "source", name: "source", incarnation: domain.IncarnationID{1}, active: 0, | |
| 297 | + | source := &session{sessionCore: sessionCore{id: "source", name: "source", incarnation: domain.IncarnationID{1}}, active: 0, | |
| 299 | 298 | tabs: []*tab{ | |
| 300 | 299 | {stableID: "source-tab", panes: map[layout.PaneID]*pane{}}, | |
| 301 | 300 | {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}}, | |
| 302 | 301 | }, | |
| 303 | 302 | } | |
| 304 | - | destination := &session{ | |
| 305 | - | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |
| 306 | - | tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}, | |
| 307 | - | } | |
| 303 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}} | |
| 308 | 304 | d.sessions[source.id] = source | |
| 309 | 305 | d.sessions[destination.id] = destination | |
| 310 | 306 | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}} | |
| @@ -382,10 +378,7 @@ func TestPickerMovePaneStoresSourceAndCleansPreviewSubscription(t *testing.T) { | |||
| 382 | 378 | d.sessions[sourceSession.id] = sourceSession | |
| 383 | 379 | targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel) | |
| 384 | 380 | targetTab.stableID = "destination-tab" | |
| 385 | - | targetSession := &session{ | |
| 386 | - | id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |
| 387 | - | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab}, | |
| 388 | - | } | |
| 381 | + | targetSession := &session{sessionCore: sessionCore{id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |
| 389 | 382 | d.sessions[targetSession.id] = targetSession | |
| 390 | 383 | sourceTab := sourceSession.tabs[0] | |
| 391 | 384 | sourceTab.mu.Lock() | |
| @@ -452,8 +445,7 @@ func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing | |||
| 452 | 445 | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1} | |
| 453 | 446 | delete(d.sessions, domain.SessionID("manual")) | |
| 454 | 447 | d.sessions[sourceSession.id] = sourceSession | |
| 455 | - | target := &session{ | |
| 456 | - | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, active: 1, | |
| 448 | + | target := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, active: 1, | |
| 457 | 449 | ctx: sourceSession.ctx, cancel: func() {}, | |
| 458 | 450 | tabs: []*tab{ | |
| 459 | 451 | newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel), | |
| @@ -499,10 +491,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) { | |||
| 499 | 491 | firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel) | |
| 500 | 492 | secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel) | |
| 501 | 493 | firstTab.stableID, secondTab.stableID = "first", "selected" | |
| 502 | - | destination := &session{ | |
| 503 | - | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |
| 504 | - | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab}, | |
| 505 | - | } | |
| 494 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab}} | |
| 506 | 495 | d.sessions[destination.id] = destination | |
| 507 | 496 | source := moveSourceLocator{ | |
| 508 | 497 | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | |
| @@ -550,10 +539,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) { | |||
| 550 | 539 | releases = append(releases, release) | |
| 551 | 540 | tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel) | |
| 552 | 541 | tb.stableID = string(destination.id) + "-tab" | |
| 553 | - | d.sessions[destination.id] = &session{ | |
| 554 | - | id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true, | |
| 555 | - | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb}, | |
| 556 | - | } | |
| 542 | + | d.sessions[destination.id] = &session{sessionCore: sessionCore{id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb}} | |
| 557 | 543 | } | |
| 558 | 544 | defer releaseAll(releases) | |
| 559 | 545 | source := moveSourceLocator{ | |
| @@ -586,7 +572,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||
| 586 | 572 | d, current, ac, _ := newManualSessionWithPTYs(t, p) | |
| 587 | 573 | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 588 | 574 | defer cancel() | |
| 589 | - | original := &session{id: "ephemeral-1", name: "2", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 575 | + | original := &session{sessionCore: sessionCore{id: "ephemeral-1", name: "2", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 590 | 576 | d.sessions[original.id] = original | |
| 591 | 577 | ||
| 592 | 578 | views, _ := d.pickerViews(current) | |
| @@ -602,7 +588,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||
| 602 | 588 | require.True(t, ok) | |
| 603 | 589 | ||
| 604 | 590 | delete(d.sessions, original.id) | |
| 605 | - | replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 591 | + | replacement := &session{sessionCore: sessionCore{id: "ephemeral-2", name: original.name, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | |
| 606 | 592 | d.sessions[replacement.id] = replacement | |
| 607 | 593 | ||
| 608 | 594 | require.Error(t, d.switchToTarget(current, ac, target)) | |
| @@ -709,7 +695,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) { | |||
| 709 | 695 | ||
| 710 | 696 | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 711 | 697 | defer cancel() | |
| 712 | - | target := &session{id: "restored", name: record.Name, createdAt: record.CreatedAt, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}} | |
| 698 | + | target := &session{sessionCore: sessionCore{id: "restored", name: record.Name, createdAt: record.CreatedAt}, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}} | |
| 713 | 699 | d.mu.Lock() | |
| 714 | 700 | delete(d.stopped, record.Name) | |
| 715 | 701 | d.sessions[target.id] = target | |
| @@ -929,8 +915,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 929 | 915 | source.id, source.name = "source", "source" | |
| 930 | 916 | delete(d.sessions, domain.SessionID("manual")) | |
| 931 | 917 | d.sessions[source.id] = source | |
| 932 | - | target := &session{ | |
| 933 | - | id: "target", name: "target", ctx: source.ctx, cancel: func() {}, | |
| 918 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, | |
| 934 | 919 | tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})}, | |
| 935 | 920 | } | |
| 936 | 921 | d.sessions[target.id] = target | |
| @@ -977,8 +962,8 @@ func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) { | |||
| 977 | 962 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 978 | 963 | defer cancel1() | |
| 979 | 964 | defer cancel2() | |
| 980 | - | sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1} | |
| 981 | - | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | |
| 965 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 966 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 982 | 967 | ac1.setSession(sess1) | |
| 983 | 968 | ac2.setSession(sess2) | |
| 984 | 969 | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil) | |
| @@ -1031,8 +1016,8 @@ func TestPickerDisplacementCancelsSupersededResize(t *testing.T) { | |||
| 1031 | 1016 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1032 | 1017 | defer cancel1() | |
| 1033 | 1018 | defer cancel2() | |
| 1034 | - | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1} | |
| 1035 | - | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | |
| 1019 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1020 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1036 | 1021 | ac1.setSession(sess1) | |
| 1037 | 1022 | ac2.setSession(sess2) | |
| 1038 | 1023 | d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2 | |
| @@ -1066,8 +1051,8 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||
| 1066 | 1051 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1067 | 1052 | defer cancel1() | |
| 1068 | 1053 | defer cancel2() | |
| 1069 | - | sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1} | |
| 1070 | - | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | |
| 1054 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1055 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1071 | 1056 | ac1.setSession(sess1) | |
| 1072 | 1057 | ac2.setSession(sess2) | |
| 1073 | 1058 | d.sessions[sess1.id] = sess1 | |
| @@ -1117,8 +1102,8 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing | |||
| 1117 | 1102 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1118 | 1103 | defer cancel1() | |
| 1119 | 1104 | defer cancel2() | |
| 1120 | - | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac} | |
| 1121 | - | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1105 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} | |
| 1106 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | |
| 1122 | 1107 | ac.setSession(sess1) | |
| 1123 | 1108 | d.sessions[sess1.id] = sess1 | |
| 1124 | 1109 | d.sessions[sess2.id] = sess2 | |
| @@ -1197,8 +1182,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||
| 1197 | 1182 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 1198 | 1183 | defer cancel1() | |
| 1199 | 1184 | defer cancel2() | |
| 1200 | - | sess1 := &session{id: "s1", name: "alpha", cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}} | |
| 1201 | - | sess2 := &session{id: "s2", name: "beta", cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}} | |
| 1185 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}} | |
| 1186 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}} | |
| 1202 | 1187 | ac1.setSession(sess1) | |
| 1203 | 1188 | ac2.setSession(sess2) | |
| 1204 | 1189 | d.sessions[sess1.id] = sess1 | |
| @@ -1554,7 +1539,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 1554 | 1539 | sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr) | |
| 1555 | 1540 | require.NoError(t, err) | |
| 1556 | 1541 | d.resumeStoppedAndSwitch(sess, ac, picker.Target{Name: "old", Stopped: true}) | |
| 1557 | - | got := ac.sess.Get() | |
| 1542 | + | got := ac.currentSession() | |
| 1558 | 1543 | require.NotNil(t, got) | |
| 1559 | 1544 | got.mu.Lock() | |
| 1560 | 1545 | require.True(t, got.terminal.TrueColor) | |
| @@ -1664,14 +1649,14 @@ func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) { | |||
| 1664 | 1649 | defer releaseOther() | |
| 1665 | 1650 | otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel) | |
| 1666 | 1651 | otherTab.stableID = "other-tab" | |
| 1667 | - | other := &session{id: "other", name: tt.otherName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | |
| 1652 | + | other := &session{sessionCore: sessionCore{id: "other", name: tt.otherName, ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | |
| 1668 | 1653 | d.sessions[other.id] = other | |
| 1669 | 1654 | ||
| 1670 | 1655 | targetPTY, releaseTarget := newBlockingPTY(t) | |
| 1671 | 1656 | defer releaseTarget() | |
| 1672 | 1657 | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | |
| 1673 | 1658 | targetTab.stableID = "target-tab" | |
| 1674 | - | target := &session{id: "target", name: tt.targetName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |
| 1659 | + | target := &session{sessionCore: sessionCore{id: "target", name: tt.targetName, ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |
| 1675 | 1660 | d.sessions[target.id] = target | |
| 1676 | 1661 | ||
| 1677 | 1662 | d.enterPicker(current, ac) | |
| @@ -1712,7 +1697,7 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) | |||
| 1712 | 1697 | targetPTY, releaseTarget := newBlockingPTY(t) | |
| 1713 | 1698 | defer releaseTarget() | |
| 1714 | 1699 | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | |
| 1715 | - | target := &session{id: "target", name: "a-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |
| 1700 | + | target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | |
| 1716 | 1701 | d.sessions[target.id] = target | |
| 1717 | 1702 | current.mu.Lock() | |
| 1718 | 1703 | current.client = ac | |
| @@ -1746,7 +1731,7 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||
| 1746 | 1731 | defer releaseAll(releases) | |
| 1747 | 1732 | cause := errors.New("snapshot delete refused") | |
| 1748 | 1733 | WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause})(d) | |
| 1749 | - | target := d.sessions[domain.SessionID("recent")] | |
| 1734 | + | target := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) | |
| 1750 | 1735 | store, _ := newMockStore(t) | |
| 1751 | 1736 | WithStore(t, store)(d) | |
| 1752 | 1737 | target.mu.Lock() | |
| @@ -1792,7 +1777,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||
| 1792 | 1777 | defer releaseOther() | |
| 1793 | 1778 | otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel) | |
| 1794 | 1779 | otherTab.stableID = "other-tab" | |
| 1795 | - | other := &session{id: "other", name: "a-other", ephemeral: true, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | |
| 1780 | + | other := &session{sessionCore: sessionCore{id: "other", name: "a-other", ephemeral: true}, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | |
| 1796 | 1781 | d.sessions[other.id] = other | |
| 1797 | 1782 | cause := errors.New("delete failed") | |
| 1798 | 1783 | store, state := newMockStore(t) | |
| @@ -70,8 +70,8 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||||
| 70 | d := newTestDaemon(t, nil, stubClock{}) | 70 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 71 | ctx, cancel := context.WithCancel(d.serveCtx) | 71 | ctx, cancel := context.WithCancel(d.serveCtx) | ||
| 72 | defer cancel() | 72 | defer cancel() | ||
| 73 | - | current := &session{id: "s1", name: "alpha", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}} | 73 | + | current := &session{sessionCore: sessionCore{id: "s1", name: "alpha"}, ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}} |
| 74 | - | ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}} | 74 | + | ringing := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}} |
| 75 | ringing.mu.Lock() | 75 | ringing.mu.Lock() | ||
| 76 | ringing.tabs[1].attention = true | 76 | ringing.tabs[1].attention = true | ||
| 77 | ringing.tabs[1].attentionAt = time.Unix(10, 0) | 77 | ringing.tabs[1].attentionAt = time.Unix(10, 0) | ||
| @@ -99,8 +99,8 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||||
| 99 | 99 | ||||
| 100 | func TestPickerViewsIncludesEphemeralSessions(t *testing.T) { | 100 | func TestPickerViewsIncludesEphemeralSessions(t *testing.T) { | ||
| 101 | d := newTestDaemon(t, nil, stubClock{}) | 101 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 102 | - | current := &session{id: "current", name: "current", tabs: []*tab{{}}} | 102 | + | current := &session{sessionCore: sessionCore{id: "current", name: "current"}, tabs: []*tab{{}}} |
| 103 | - | ephemeral := &session{id: "ephemeral", name: "1", ephemeral: true, tabs: []*tab{{}}} | 103 | + | ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}} |
| 104 | d.sessions[current.id] = current | 104 | d.sessions[current.id] = current | ||
| 105 | d.sessions[ephemeral.id] = ephemeral | 105 | d.sessions[ephemeral.id] = ephemeral | ||
| 106 | 106 | ||||
| @@ -120,11 +120,11 @@ func TestPickerViewsIncludesEphemeralSessions(t *testing.T) { | |||||
| 120 | 120 | ||||
| 121 | func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) { | 121 | func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) { | ||
| 122 | d := newTestDaemon(t, nil, stubClock{}) | 122 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 123 | - | old := &session{id: "old", name: "alpha", tabs: []*tab{{}}} | 123 | + | old := &session{sessionCore: sessionCore{id: "old", name: "alpha"}, tabs: []*tab{{}}} |
| 124 | old.mruAt.Store(1) | 124 | old.mruAt.Store(1) | ||
| 125 | - | eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}} | 125 | + | eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}} |
| 126 | eph.mruAt.Store(3) | 126 | eph.mruAt.Store(3) | ||
| 127 | - | recent := &session{id: "recent", name: "zeta", tabs: []*tab{{}}} | 127 | + | recent := &session{sessionCore: sessionCore{id: "recent", name: "zeta"}, tabs: []*tab{{}}} |
| 128 | recent.mruAt.Store(2) | 128 | recent.mruAt.Store(2) | ||
| 129 | d.sessions[old.id] = old | 129 | d.sessions[old.id] = old | ||
| 130 | d.sessions[eph.id] = eph | 130 | d.sessions[eph.id] = eph | ||
| @@ -146,8 +146,8 @@ func TestPickerViewsOrdersByMRUWithEphemeralInterleaved(t *testing.T) { | |||||
| 146 | 146 | ||||
| 147 | func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) { | 147 | func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) { | ||
| 148 | d := newTestDaemon(t, nil, stubClock{}) | 148 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 149 | - | b := &session{id: "b", name: "bravo", tabs: []*tab{{}}} | 149 | + | b := &session{sessionCore: sessionCore{id: "b", name: "bravo"}, tabs: []*tab{{}}} |
| 150 | - | a := &session{id: "a", name: "alpha", tabs: []*tab{{}}} | 150 | + | a := &session{sessionCore: sessionCore{id: "a", name: "alpha"}, tabs: []*tab{{}}} |
| 151 | // Equal mruAt (both zero) must yield a stable alphabetical order. | 151 | // Equal mruAt (both zero) must yield a stable alphabetical order. | ||
| 152 | d.sessions[b.id] = b | 152 | d.sessions[b.id] = b | ||
| 153 | d.sessions[a.id] = a | 153 | d.sessions[a.id] = a | ||
| @@ -161,11 +161,11 @@ func TestPickerViewsMRUTieBreaksAlphabetically(t *testing.T) { | |||||
| 161 | func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) { | 161 | func TestPickerViewsGroupedModePutsNamedBeforeEphemeral(t *testing.T) { | ||
| 162 | d := newTestDaemon(t, nil, stubClock{}) | 162 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 163 | d.pickerSort.Store(uint32(pickerSortGrouped)) | 163 | d.pickerSort.Store(uint32(pickerSortGrouped)) | ||
| 164 | - | eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}} | 164 | + | eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}} |
| 165 | eph.mruAt.Store(5) | 165 | eph.mruAt.Store(5) | ||
| 166 | - | named := &session{id: "named", name: "work", tabs: []*tab{{}}} | 166 | + | named := &session{sessionCore: sessionCore{id: "named", name: "work"}, tabs: []*tab{{}}} |
| 167 | named.mruAt.Store(2) | 167 | named.mruAt.Store(2) | ||
| 168 | - | named2 := &session{id: "named2", name: "notes", tabs: []*tab{{}}} | 168 | + | named2 := &session{sessionCore: sessionCore{id: "named2", name: "notes"}, tabs: []*tab{{}}} |
| 169 | named2.mruAt.Store(3) | 169 | named2.mruAt.Store(3) | ||
| 170 | d.sessions[eph.id] = eph | 170 | d.sessions[eph.id] = eph | ||
| 171 | d.sessions[named.id] = named | 171 | d.sessions[named.id] = named | ||
| @@ -206,7 +206,7 @@ func TestPickerSortToggleFlipsModeAndKeepsSelection(t *testing.T) { | |||||
| 206 | sess.mruAt.Store(1) | 206 | sess.mruAt.Store(1) | ||
| 207 | // A second live session, ephemeral and more recently used: recent mode | 207 | // A second live session, ephemeral and more recently used: recent mode | ||
| 208 | // lists it first, grouped mode moves it below the named session. | 208 | // lists it first, grouped mode moves it below the named session. | ||
| 209 | - | eph := &session{id: "eph", name: "1", ephemeral: true, tabs: []*tab{{}}} | 209 | + | eph := &session{sessionCore: sessionCore{id: "eph", name: "1", ephemeral: true}, tabs: []*tab{{}}} |
| 210 | eph.mruAt.Store(5) | 210 | eph.mruAt.Store(5) | ||
| 211 | d.mu.Lock() | 211 | d.mu.Lock() | ||
| 212 | d.sessions[eph.id] = eph | 212 | d.sessions[eph.id] = eph | ||
| @@ -279,7 +279,7 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||||
| 279 | d := newTestDaemon(t, nil, stubClock{}) | 279 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 280 | ctx, cancel := context.WithCancel(d.serveCtx) | 280 | ctx, cancel := context.WithCancel(d.serveCtx) | ||
| 281 | defer cancel() | 281 | defer cancel() | ||
| 282 | - | active := &session{id: "active", name: "active", createdAt: 23, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | 282 | + | active := &session{sessionCore: sessionCore{id: "active", name: "active", createdAt: 23}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} |
| 283 | d.sessions[active.id] = active | 283 | d.sessions[active.id] = active | ||
| 284 | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24} | 284 | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 24} | ||
| 285 | 285 | ||||
| @@ -294,17 +294,13 @@ func TestPickerViewsCarryNamedLifecycleIdentity(t *testing.T) { | |||||
| 294 | 294 | ||||
| 295 | func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) { | 295 | func TestPickerCanonicalViewsLeaveMoveFilteringToModel(t *testing.T) { | ||
| 296 | d := newTestDaemon(t, nil, stubClock{}) | 296 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 297 | - | source := &session{ | 297 | + | source := &session{sessionCore: sessionCore{id: "source", name: "source", incarnation: domain.IncarnationID{1}}, active: 0, |
| 298 | - | id: "source", name: "source", incarnation: domain.IncarnationID{1}, active: 0, | |||
| 299 | tabs: []*tab{ | 298 | tabs: []*tab{ | ||
| 300 | {stableID: "source-tab", panes: map[layout.PaneID]*pane{}}, | 299 | {stableID: "source-tab", panes: map[layout.PaneID]*pane{}}, | ||
| 301 | {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}}, | 300 | {stableID: "sibling-tab", panes: map[layout.PaneID]*pane{}}, | ||
| 302 | }, | 301 | }, | ||
| 303 | } | 302 | } | ||
| 304 | - | destination := &session{ | 303 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}} |
| 305 | - | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |||
| 306 | - | tabs: []*tab{{stableID: "destination-tab", panes: map[layout.PaneID]*pane{}}}, | |||
| 307 | - | } | |||
| 308 | d.sessions[source.id] = source | 304 | d.sessions[source.id] = source | ||
| 309 | d.sessions[destination.id] = destination | 305 | d.sessions[destination.id] = destination | ||
| 310 | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}} | 306 | d.stopped["stopped"] = stoppedSession{name: "stopped", createdAt: 9, incarnation: domain.IncarnationID{9}} | ||
| @@ -382,10 +378,7 @@ func TestPickerMovePaneStoresSourceAndCleansPreviewSubscription(t *testing.T) { | |||||
| 382 | d.sessions[sourceSession.id] = sourceSession | 378 | d.sessions[sourceSession.id] = sourceSession | ||
| 383 | targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel) | 379 | targetTab := newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel) | ||
| 384 | targetTab.stableID = "destination-tab" | 380 | targetTab.stableID = "destination-tab" | ||
| 385 | - | targetSession := &session{ | 381 | + | targetSession := &session{sessionCore: sessionCore{id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab}} |
| 386 | - | id: "destination", name: "a-destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |||
| 387 | - | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{targetTab}, | |||
| 388 | - | } | |||
| 389 | d.sessions[targetSession.id] = targetSession | 382 | d.sessions[targetSession.id] = targetSession | ||
| 390 | sourceTab := sourceSession.tabs[0] | 383 | sourceTab := sourceSession.tabs[0] | ||
| 391 | sourceTab.mu.Lock() | 384 | sourceTab.mu.Lock() | ||
| @@ -452,8 +445,7 @@ func TestPickerMoveTabPreviewsDestinationActiveTabWithoutActivatingIt(t *testing | |||||
| 452 | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1} | 445 | sourceSession.id, sourceSession.name, sourceSession.incarnation = "source", "source", domain.IncarnationID{1} | ||
| 453 | delete(d.sessions, domain.SessionID("manual")) | 446 | delete(d.sessions, domain.SessionID("manual")) | ||
| 454 | d.sessions[sourceSession.id] = sourceSession | 447 | d.sessions[sourceSession.id] = sourceSession | ||
| 455 | - | target := &session{ | 448 | + | target := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, active: 1, |
| 456 | - | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, active: 1, | |||
| 457 | ctx: sourceSession.ctx, cancel: func() {}, | 449 | ctx: sourceSession.ctx, cancel: func() {}, | ||
| 458 | tabs: []*tab{ | 450 | tabs: []*tab{ | ||
| 459 | newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel), | 451 | newTestTabWithContext(p2, sourceSession.ctx, sourceSession.cancel), | ||
| @@ -499,10 +491,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) { | |||||
| 499 | firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel) | 491 | firstTab := newTestTabWithContext(firstPTY, sourceSession.ctx, sourceSession.cancel) | ||
| 500 | secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel) | 492 | secondTab := newTestTabWithContext(secondPTY, sourceSession.ctx, sourceSession.cancel) | ||
| 501 | firstTab.stableID, secondTab.stableID = "first", "selected" | 493 | firstTab.stableID, secondTab.stableID = "first", "selected" | ||
| 502 | - | destination := &session{ | 494 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab}} |
| 503 | - | id: "destination", name: "destination", incarnation: domain.IncarnationID{2}, ephemeral: true, | |||
| 504 | - | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{firstTab, secondTab}, | |||
| 505 | - | } | |||
| 506 | d.sessions[destination.id] = destination | 495 | d.sessions[destination.id] = destination | ||
| 507 | source := moveSourceLocator{ | 496 | source := moveSourceLocator{ | ||
| 508 | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | 497 | Session: moveSessionLocator{ID: sourceSession.id, Incarnation: sourceSession.incarnation, Name: sourceSession.name}, | ||
| @@ -550,10 +539,7 @@ func TestPickerMoveRefreshPreservesSelectedDestination(t *testing.T) { | |||||
| 550 | releases = append(releases, release) | 539 | releases = append(releases, release) | ||
| 551 | tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel) | 540 | tb := newTestTabWithContext(pty, sourceSession.ctx, sourceSession.cancel) | ||
| 552 | tb.stableID = string(destination.id) + "-tab" | 541 | tb.stableID = string(destination.id) + "-tab" | ||
| 553 | - | d.sessions[destination.id] = &session{ | 542 | + | d.sessions[destination.id] = &session{sessionCore: sessionCore{id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true}, ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb}} |
| 554 | - | id: destination.id, name: destination.name, incarnation: domain.IncarnationID{byte(i + 2)}, ephemeral: true, | |||
| 555 | - | ctx: sourceSession.ctx, cancel: func() {}, tabs: []*tab{tb}, | |||
| 556 | - | } | |||
| 557 | } | 543 | } | ||
| 558 | defer releaseAll(releases) | 544 | defer releaseAll(releases) | ||
| 559 | source := moveSourceLocator{ | 545 | source := moveSourceLocator{ | ||
| @@ -586,7 +572,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||||
| 586 | d, current, ac, _ := newManualSessionWithPTYs(t, p) | 572 | d, current, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 587 | ctx, cancel := context.WithCancel(d.serveCtx) | 573 | ctx, cancel := context.WithCancel(d.serveCtx) | ||
| 588 | defer cancel() | 574 | defer cancel() | ||
| 589 | - | original := &session{id: "ephemeral-1", name: "2", ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | 575 | + | original := &session{sessionCore: sessionCore{id: "ephemeral-1", name: "2", ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} |
| 590 | d.sessions[original.id] = original | 576 | d.sessions[original.id] = original | ||
| 591 | 577 | ||||
| 592 | views, _ := d.pickerViews(current) | 578 | views, _ := d.pickerViews(current) | ||
| @@ -602,7 +588,7 @@ func TestPickerRejectsRecreatedEphemeralTargetFromStaleSelection(t *testing.T) { | |||||
| 602 | require.True(t, ok) | 588 | require.True(t, ok) | ||
| 603 | 589 | ||||
| 604 | delete(d.sessions, original.id) | 590 | delete(d.sessions, original.id) | ||
| 605 | - | replacement := &session{id: "ephemeral-2", name: original.name, ephemeral: true, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} | 591 | + | replacement := &session{sessionCore: sessionCore{id: "ephemeral-2", name: original.name, ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{{}}} |
| 606 | d.sessions[replacement.id] = replacement | 592 | d.sessions[replacement.id] = replacement | ||
| 607 | 593 | ||||
| 608 | require.Error(t, d.switchToTarget(current, ac, target)) | 594 | require.Error(t, d.switchToTarget(current, ac, target)) | ||
| @@ -709,7 +695,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) { | |||||
| 709 | 695 | ||||
| 710 | ctx, cancel := context.WithCancel(d.serveCtx) | 696 | ctx, cancel := context.WithCancel(d.serveCtx) | ||
| 711 | defer cancel() | 697 | defer cancel() | ||
| 712 | - | target := &session{id: "restored", name: record.Name, createdAt: record.CreatedAt, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}} | 698 | + | target := &session{sessionCore: sessionCore{id: "restored", name: record.Name, createdAt: record.CreatedAt}, ctx: ctx, cancel: cancel, tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})}} |
| 713 | d.mu.Lock() | 699 | d.mu.Lock() | ||
| 714 | delete(d.stopped, record.Name) | 700 | delete(d.stopped, record.Name) | ||
| 715 | d.sessions[target.id] = target | 701 | d.sessions[target.id] = target | ||
| @@ -929,8 +915,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||||
| 929 | source.id, source.name = "source", "source" | 915 | source.id, source.name = "source", "source" | ||
| 930 | delete(d.sessions, domain.SessionID("manual")) | 916 | delete(d.sessions, domain.SessionID("manual")) | ||
| 931 | d.sessions[source.id] = source | 917 | d.sessions[source.id] = source | ||
| 932 | - | target := &session{ | 918 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, |
| 933 | - | id: "target", name: "target", ctx: source.ctx, cancel: func() {}, | |||
| 934 | tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})}, | 919 | tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 22})}, | ||
| 935 | } | 920 | } | ||
| 936 | d.sessions[target.id] = target | 921 | d.sessions[target.id] = target | ||
| @@ -977,8 +962,8 @@ func TestPickerCrossSessionSwitchSnatchesExistingClient(t *testing.T) { | |||||
| 977 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 962 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| 978 | defer cancel1() | 963 | defer cancel1() | ||
| 979 | defer cancel2() | 964 | defer cancel2() | ||
| 980 | - | sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1} | 965 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} |
| 981 | - | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | 966 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} |
| 982 | ac1.setSession(sess1) | 967 | ac1.setSession(sess1) | ||
| 983 | ac2.setSession(sess2) | 968 | ac2.setSession(sess2) | ||
| 984 | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil) | 969 | ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1}, nil) | ||
| @@ -1031,8 +1016,8 @@ func TestPickerDisplacementCancelsSupersededResize(t *testing.T) { | |||||
| 1031 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 1016 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| 1032 | defer cancel1() | 1017 | defer cancel1() | ||
| 1033 | defer cancel2() | 1018 | defer cancel2() | ||
| 1034 | - | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1} | 1019 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} |
| 1035 | - | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | 1020 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} |
| 1036 | ac1.setSession(sess1) | 1021 | ac1.setSession(sess1) | ||
| 1037 | ac2.setSession(sess2) | 1022 | ac2.setSession(sess2) | ||
| 1038 | d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2 | 1023 | d.sessions[sess1.id], d.sessions[sess2.id] = sess1, sess2 | ||
| @@ -1066,8 +1051,8 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||||
| 1066 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 1051 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| 1067 | defer cancel1() | 1052 | defer cancel1() | ||
| 1068 | defer cancel2() | 1053 | defer cancel2() | ||
| 1069 | - | sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1} | 1054 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", ephemeral: true, client: ac1}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} |
| 1070 | - | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2} | 1055 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} |
| 1071 | ac1.setSession(sess1) | 1056 | ac1.setSession(sess1) | ||
| 1072 | ac2.setSession(sess2) | 1057 | ac2.setSession(sess2) | ||
| 1073 | d.sessions[sess1.id] = sess1 | 1058 | d.sessions[sess1.id] = sess1 | ||
| @@ -1117,8 +1102,8 @@ func TestPickerSessionSwitchPublishesBeforeInFlightPaintSendCompletes(t *testing | |||||
| 1117 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 1102 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| 1118 | defer cancel1() | 1103 | defer cancel1() | ||
| 1119 | defer cancel2() | 1104 | defer cancel2() | ||
| 1120 | - | sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac} | 1105 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac}, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}} |
| 1121 | - | sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} | 1106 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta"}, ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}} |
| 1122 | ac.setSession(sess1) | 1107 | ac.setSession(sess1) | ||
| 1123 | d.sessions[sess1.id] = sess1 | 1108 | d.sessions[sess1.id] = sess1 | ||
| 1124 | d.sessions[sess2.id] = sess2 | 1109 | d.sessions[sess2.id] = sess2 | ||
| @@ -1197,8 +1182,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||||
| 1197 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 1182 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| 1198 | defer cancel1() | 1183 | defer cancel1() | ||
| 1199 | defer cancel2() | 1184 | defer cancel2() | ||
| 1200 | - | sess1 := &session{id: "s1", name: "alpha", cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}} | 1185 | + | sess1 := &session{sessionCore: sessionCore{id: "s1", name: "alpha", client: ac1}, cwd: t.TempDir(), ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, terminal: terminalEnv{}, env: []string{"SECRET=from-active-session", "SHELL=/usr/bin/fish", "PAIR=a=b"}} |
| 1201 | - | sess2 := &session{id: "s2", name: "beta", cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}} | 1186 | + | sess2 := &session{sessionCore: sessionCore{id: "s2", name: "beta", client: ac2}, cwd: t.TempDir(), ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, terminal: terminalEnv{TrueColor: true}, env: []string{"STALE=destination-only", "SHELL=/bin/stale", "TERM=xterm-direct", "COLORTERM=truecolor", "TERM_PROGRAM=stale", "VEV=stale"}} |
| 1202 | ac1.setSession(sess1) | 1187 | ac1.setSession(sess1) | ||
| 1203 | ac2.setSession(sess2) | 1188 | ac2.setSession(sess2) | ||
| 1204 | d.sessions[sess1.id] = sess1 | 1189 | d.sessions[sess1.id] = sess1 | ||
| @@ -1554,7 +1539,7 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 1554 | sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr) | 1539 | sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: sz, TrueColor: true}, tr) | ||
| 1555 | require.NoError(t, err) | 1540 | require.NoError(t, err) | ||
| 1556 | d.resumeStoppedAndSwitch(sess, ac, picker.Target{Name: "old", Stopped: true}) | 1541 | d.resumeStoppedAndSwitch(sess, ac, picker.Target{Name: "old", Stopped: true}) | ||
| 1557 | - | got := ac.sess.Get() | 1542 | + | got := ac.currentSession() |
| 1558 | require.NotNil(t, got) | 1543 | require.NotNil(t, got) | ||
| 1559 | got.mu.Lock() | 1544 | got.mu.Lock() | ||
| 1560 | require.True(t, got.terminal.TrueColor) | 1545 | require.True(t, got.terminal.TrueColor) | ||
| @@ -1664,14 +1649,14 @@ func TestPickerNavigationRefreshAfterDeleteSelectsReplacingRow(t *testing.T) { | |||||
| 1664 | defer releaseOther() | 1649 | defer releaseOther() | ||
| 1665 | otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel) | 1650 | otherTab := newTestTabWithContext(otherPTY, current.ctx, current.cancel) | ||
| 1666 | otherTab.stableID = "other-tab" | 1651 | otherTab.stableID = "other-tab" | ||
| 1667 | - | other := &session{id: "other", name: tt.otherName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | 1652 | + | other := &session{sessionCore: sessionCore{id: "other", name: tt.otherName, ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{otherTab}} |
| 1668 | d.sessions[other.id] = other | 1653 | d.sessions[other.id] = other | ||
| 1669 | 1654 | ||||
| 1670 | targetPTY, releaseTarget := newBlockingPTY(t) | 1655 | targetPTY, releaseTarget := newBlockingPTY(t) | ||
| 1671 | defer releaseTarget() | 1656 | defer releaseTarget() | ||
| 1672 | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | 1657 | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | ||
| 1673 | targetTab.stableID = "target-tab" | 1658 | targetTab.stableID = "target-tab" | ||
| 1674 | - | target := &session{id: "target", name: tt.targetName, ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | 1659 | + | target := &session{sessionCore: sessionCore{id: "target", name: tt.targetName, ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} |
| 1675 | d.sessions[target.id] = target | 1660 | d.sessions[target.id] = target | ||
| 1676 | 1661 | ||||
| 1677 | d.enterPicker(current, ac) | 1662 | d.enterPicker(current, ac) | ||
| @@ -1712,7 +1697,7 @@ func TestPickerRoleEffectDeleteRemovesSelectedSessionAndRefreshes(t *testing.T) | |||||
| 1712 | targetPTY, releaseTarget := newBlockingPTY(t) | 1697 | targetPTY, releaseTarget := newBlockingPTY(t) | ||
| 1713 | defer releaseTarget() | 1698 | defer releaseTarget() | ||
| 1714 | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | 1699 | targetTab := newTestTabWithContext(targetPTY, current.ctx, current.cancel) | ||
| 1715 | - | target := &session{id: "target", name: "a-target", ephemeral: true, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} | 1700 | + | target := &session{sessionCore: sessionCore{id: "target", name: "a-target", ephemeral: true}, ctx: current.ctx, cancel: func() {}, tabs: []*tab{targetTab}} |
| 1716 | d.sessions[target.id] = target | 1701 | d.sessions[target.id] = target | ||
| 1717 | current.mu.Lock() | 1702 | current.mu.Lock() | ||
| 1718 | current.client = ac | 1703 | current.client = ac | ||
| @@ -1746,7 +1731,7 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t | |||||
| 1746 | defer releaseAll(releases) | 1731 | defer releaseAll(releases) | ||
| 1747 | cause := errors.New("snapshot delete refused") | 1732 | cause := errors.New("snapshot delete refused") | ||
| 1748 | WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause})(d) | 1733 | WithSnapshotRepository(refusingSnapshotDeleteRepository{err: cause})(d) | ||
| 1749 | - | target := d.sessions[domain.SessionID("recent")] | 1734 | + | target := mustLocalSession(t, d.sessions[domain.SessionID("recent")]) |
| 1750 | store, _ := newMockStore(t) | 1735 | store, _ := newMockStore(t) | ||
| 1751 | WithStore(t, store)(d) | 1736 | WithStore(t, store)(d) | ||
| 1752 | target.mu.Lock() | 1737 | target.mu.Lock() | ||
| @@ -1792,7 +1777,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry | |||||
| 1792 | defer releaseOther() | 1777 | defer releaseOther() | ||
| 1793 | otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel) | 1778 | otherTab := newTestTabWithContext(otherPTY, from.ctx, from.cancel) | ||
| 1794 | otherTab.stableID = "other-tab" | 1779 | otherTab.stableID = "other-tab" | ||
| 1795 | - | other := &session{id: "other", name: "a-other", ephemeral: true, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}} | 1780 | + | other := &session{sessionCore: sessionCore{id: "other", name: "a-other", ephemeral: true}, ctx: from.ctx, cancel: func() {}, tabs: []*tab{otherTab}} |
| 1796 | d.sessions[other.id] = other | 1781 | d.sessions[other.id] = other | ||
| 1797 | cause := errors.New("delete failed") | 1782 | cause := errors.New("delete failed") | ||
| 1798 | store, state := newMockStore(t) | 1783 | store, state := newMockStore(t) | ||
M internal/usecase/daemon/prompt_test.go +2 -2
| @@ -66,7 +66,7 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) { | |||
| 66 | 66 | defer release2() | |
| 67 | 67 | ctx, cancel := context.WithCancel(context.Background()) | |
| 68 | 68 | defer cancel() | |
| 69 | - | d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}} | |
| 69 | + | d.sessions["other"] = &session{sessionCore: sessionCore{id: "other", name: "taken"}, ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}} | |
| 70 | 70 | ||
| 71 | 71 | d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) }) | |
| 72 | 72 | awaitFrame(t, sends, ports.MsgOutput) | |
| @@ -123,7 +123,7 @@ func TestPromptSubmitValidationErrorStaysInlineOnly(t *testing.T) { | |||
| 123 | 123 | input: []byte("taken\r"), | |
| 124 | 124 | wantErr: errSessionNameInUse, | |
| 125 | 125 | setup: func(d *Daemon) { | |
| 126 | - | d.sessions["taken"] = &session{id: "taken", name: "taken"} | |
| 126 | + | d.sessions["taken"] = &session{sessionCore: sessionCore{id: "taken", name: "taken"}} | |
| 127 | 127 | }, | |
| 128 | 128 | }, | |
| 129 | 129 | } | |
| @@ -66,7 +66,7 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) { | |||||
| 66 | defer release2() | 66 | defer release2() | ||
| 67 | ctx, cancel := context.WithCancel(context.Background()) | 67 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 68 | defer cancel() | 68 | defer cancel() | ||
| 69 | - | d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}} | 69 | + | d.sessions["other"] = &session{sessionCore: sessionCore{id: "other", name: "taken"}, ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}} |
| 70 | 70 | ||||
| 71 | d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) }) | 71 | d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) }) | ||
| 72 | awaitFrame(t, sends, ports.MsgOutput) | 72 | awaitFrame(t, sends, ports.MsgOutput) | ||
| @@ -123,7 +123,7 @@ func TestPromptSubmitValidationErrorStaysInlineOnly(t *testing.T) { | |||||
| 123 | input: []byte("taken\r"), | 123 | input: []byte("taken\r"), | ||
| 124 | wantErr: errSessionNameInUse, | 124 | wantErr: errSessionNameInUse, | ||
| 125 | setup: func(d *Daemon) { | 125 | setup: func(d *Daemon) { | ||
| 126 | - | d.sessions["taken"] = &session{id: "taken", name: "taken"} | 126 | + | d.sessions["taken"] = &session{sessionCore: sessionCore{id: "taken", name: "taken"}} |
| 127 | }, | 127 | }, | ||
| 128 | }, | 128 | }, | ||
| 129 | } | 129 | } | ||
M internal/usecase/daemon/render.go +13 -9
| @@ -60,10 +60,7 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool { | |||
| 60 | 60 | return false | |
| 61 | 61 | } | |
| 62 | 62 | d.mu.Lock() | |
| 63 | - | sessions := make([]*session, 0, len(d.sessions)) | |
| 64 | - | for _, sess := range d.sessions { | |
| 65 | - | sessions = append(sessions, sess) | |
| 66 | - | } | |
| 63 | + | sessions := localSessionsSnapshot(d.sessions) | |
| 67 | 64 | d.mu.Unlock() | |
| 68 | 65 | for _, sess := range sessions { | |
| 69 | 66 | sess.mu.Lock() | |
| @@ -294,10 +291,17 @@ func (b *runtimeMarkBatch) flush() { | |||
| 294 | 291 | } | |
| 295 | 292 | } | |
| 296 | 293 | ||
| 297 | - | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) { | |
| 294 | + | func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) { | |
| 295 | + | // Phase 1 has only the local implementation. Keep all local preparation | |
| 296 | + | // byte-identical while routing the attachment-facing capture through the | |
| 297 | + | // interface; proxy-specific preparation is added with proxy rendering. | |
| 298 | + | sess, ok := localSession(entry) | |
| 299 | + | if !ok { | |
| 300 | + | return | |
| 301 | + | } | |
| 298 | 302 | marks := d.newRuntimeMarkBatch() | |
| 299 | 303 | if lease != nil { | |
| 300 | - | token := sess.attachmentToken(ac, ac.transport()) | |
| 304 | + | token := attachmentToken(entry, ac, ac.transport()) | |
| 301 | 305 | token.lease = lease | |
| 302 | 306 | ticket, admitted := ac.beginRoleEffect(token) | |
| 303 | 307 | if !admitted { | |
| @@ -318,12 +322,12 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||
| 318 | 322 | sess.mu.Lock() | |
| 319 | 323 | owned := sess.client == ac | |
| 320 | 324 | sess.mu.Unlock() | |
| 321 | - | if !owned || ac.currentSession() != sess { | |
| 325 | + | if !owned || ac.currentAttachmentSession() != entry { | |
| 322 | 326 | ac.sendMu.Unlock() | |
| 323 | 327 | return | |
| 324 | 328 | } | |
| 325 | 329 | if lease != nil { | |
| 326 | - | rc := sess.renderCoordinator() | |
| 330 | + | rc := attachmentRenderCoordinator(entry) | |
| 327 | 331 | if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) { | |
| 328 | 332 | ac.sendMu.Unlock() | |
| 329 | 333 | return | |
| @@ -397,7 +401,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||
| 397 | 401 | resizeActive: overlays.resizeActive, statusFeedback: statusFeedback, | |
| 398 | 402 | } | |
| 399 | 403 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | |
| 400 | - | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | |
| 404 | + | state, ok := entry.capturePrimary(ac, primaryCaptureRequest{ | |
| 401 | 405 | bars: bars, | |
| 402 | 406 | overlays: capturedOverlays, | |
| 403 | 407 | preview: preview, | |
| @@ -60,10 +60,7 @@ func (d *Daemon) tabIsPickerPreview(tb *tab) bool { | |||||
| 60 | return false | 60 | return false | ||
| 61 | } | 61 | } | ||
| 62 | d.mu.Lock() | 62 | d.mu.Lock() | ||
| 63 | - | sessions := make([]*session, 0, len(d.sessions)) | 63 | + | sessions := localSessionsSnapshot(d.sessions) |
| 64 | - | for _, sess := range d.sessions { | |||
| 65 | - | sessions = append(sessions, sess) | |||
| 66 | - | } | |||
| 67 | d.mu.Unlock() | 64 | d.mu.Unlock() | ||
| 68 | for _, sess := range sessions { | 65 | for _, sess := range sessions { | ||
| 69 | sess.mu.Lock() | 66 | sess.mu.Lock() | ||
| @@ -294,10 +291,17 @@ func (b *runtimeMarkBatch) flush() { | |||||
| 294 | } | 291 | } | ||
| 295 | } | 292 | } | ||
| 296 | 293 | ||||
| 297 | - | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *attachmentLease) { | 294 | + | func (d *Daemon) paint(entry attachmentSession, ac *attachedClient, reset bool, lease *attachmentLease) { |
| 295 | + | // Phase 1 has only the local implementation. Keep all local preparation | |||
| 296 | + | // byte-identical while routing the attachment-facing capture through the | |||
| 297 | + | // interface; proxy-specific preparation is added with proxy rendering. | |||
| 298 | + | sess, ok := localSession(entry) | |||
| 299 | + | if !ok { | |||
| 300 | + | return | |||
| 301 | + | } | |||
| 298 | marks := d.newRuntimeMarkBatch() | 302 | marks := d.newRuntimeMarkBatch() | ||
| 299 | if lease != nil { | 303 | if lease != nil { | ||
| 300 | - | token := sess.attachmentToken(ac, ac.transport()) | 304 | + | token := attachmentToken(entry, ac, ac.transport()) |
| 301 | token.lease = lease | 305 | token.lease = lease | ||
| 302 | ticket, admitted := ac.beginRoleEffect(token) | 306 | ticket, admitted := ac.beginRoleEffect(token) | ||
| 303 | if !admitted { | 307 | if !admitted { | ||
| @@ -318,12 +322,12 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||||
| 318 | sess.mu.Lock() | 322 | sess.mu.Lock() | ||
| 319 | owned := sess.client == ac | 323 | owned := sess.client == ac | ||
| 320 | sess.mu.Unlock() | 324 | sess.mu.Unlock() | ||
| 321 | - | if !owned || ac.currentSession() != sess { | 325 | + | if !owned || ac.currentAttachmentSession() != entry { |
| 322 | ac.sendMu.Unlock() | 326 | ac.sendMu.Unlock() | ||
| 323 | return | 327 | return | ||
| 324 | } | 328 | } | ||
| 325 | if lease != nil { | 329 | if lease != nil { | ||
| 326 | - | rc := sess.renderCoordinator() | 330 | + | rc := attachmentRenderCoordinator(entry) |
| 327 | if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) { | 331 | if rc == nil || lease.attachment != ac || !rc.leaseCurrent(lease, true) { | ||
| 328 | ac.sendMu.Unlock() | 332 | ac.sendMu.Unlock() | ||
| 329 | return | 333 | return | ||
| @@ -397,7 +401,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||||
| 397 | resizeActive: overlays.resizeActive, statusFeedback: statusFeedback, | 401 | resizeActive: overlays.resizeActive, statusFeedback: statusFeedback, | ||
| 398 | } | 402 | } | ||
| 399 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | 403 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | ||
| 400 | - | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | 404 | + | state, ok := entry.capturePrimary(ac, primaryCaptureRequest{ |
| 401 | bars: bars, | 405 | bars: bars, | ||
| 402 | overlays: capturedOverlays, | 406 | overlays: capturedOverlays, | ||
| 403 | preview: preview, | 407 | preview: preview, | ||
M internal/usecase/daemon/render_capture.go +9 -3
| @@ -190,11 +190,11 @@ type primaryCaptureRequest struct { | |||
| 190 | 190 | lease *attachmentLease | |
| 191 | 191 | } | |
| 192 | 192 | ||
| 193 | - | // capturePrimaryRenderState is the ownership boundary for a primary render | |
| 194 | - | // transaction. Callers hold attachment sendMu; this function then follows | |
| 193 | + | // captureLocalPrimaryRenderState is the ownership boundary for a local primary | |
| 194 | + | // render transaction. Callers hold attachment sendMu; this function then follows | |
| 195 | 195 | // session -> tab -> pane lock order. ACK-blocked capture returns before touching | |
| 196 | 196 | // VT damage, and every captured pane records a receipt for successful emission. | |
| 197 | - | func capturePrimaryRenderState( | |
| 197 | + | func captureLocalPrimaryRenderState( | |
| 198 | 198 | sess *session, | |
| 199 | 199 | ac *attachedClient, | |
| 200 | 200 | request primaryCaptureRequest, | |
| @@ -321,6 +321,12 @@ func capturePrimaryRenderState( | |||
| 321 | 321 | return state, true | |
| 322 | 322 | } | |
| 323 | 323 | ||
| 324 | + | // capturePrimaryRenderState preserves the focused local test seam. Production | |
| 325 | + | // rendering delegates through attachmentSession.capturePrimary. | |
| 326 | + | func capturePrimaryRenderState(sess *session, ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) { | |
| 327 | + | return captureLocalPrimaryRenderState(sess, ac, request) | |
| 328 | + | } | |
| 329 | + | ||
| 324 | 330 | // copyRankedRecentInto preserves the non-nil empty slice that selects | |
| 325 | 331 | // contextual recent-session mode while retaining scratch capacity for reuse. | |
| 326 | 332 | func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent { | |
| @@ -190,11 +190,11 @@ type primaryCaptureRequest struct { | |||||
| 190 | lease *attachmentLease | 190 | lease *attachmentLease | ||
| 191 | } | 191 | } | ||
| 192 | 192 | ||||
| 193 | - | // capturePrimaryRenderState is the ownership boundary for a primary render | 193 | + | // captureLocalPrimaryRenderState is the ownership boundary for a local primary |
| 194 | - | // transaction. Callers hold attachment sendMu; this function then follows | 194 | + | // render transaction. Callers hold attachment sendMu; this function then follows |
| 195 | // session -> tab -> pane lock order. ACK-blocked capture returns before touching | 195 | // session -> tab -> pane lock order. ACK-blocked capture returns before touching | ||
| 196 | // VT damage, and every captured pane records a receipt for successful emission. | 196 | // VT damage, and every captured pane records a receipt for successful emission. | ||
| 197 | - | func capturePrimaryRenderState( | 197 | + | func captureLocalPrimaryRenderState( |
| 198 | sess *session, | 198 | sess *session, | ||
| 199 | ac *attachedClient, | 199 | ac *attachedClient, | ||
| 200 | request primaryCaptureRequest, | 200 | request primaryCaptureRequest, | ||
| @@ -321,6 +321,12 @@ func capturePrimaryRenderState( | |||||
| 321 | return state, true | 321 | return state, true | ||
| 322 | } | 322 | } | ||
| 323 | 323 | ||||
| 324 | + | // capturePrimaryRenderState preserves the focused local test seam. Production | |||
| 325 | + | // rendering delegates through attachmentSession.capturePrimary. | |||
| 326 | + | func capturePrimaryRenderState(sess *session, ac *attachedClient, request primaryCaptureRequest) (*capturedRenderState, bool) { | |||
| 327 | + | return captureLocalPrimaryRenderState(sess, ac, request) | |||
| 328 | + | } | |||
| 329 | + | ||||
| 324 | // copyRankedRecentInto preserves the non-nil empty slice that selects | 330 | // copyRankedRecentInto preserves the non-nil empty slice that selects | ||
| 325 | // contextual recent-session mode while retaining scratch capacity for reuse. | 331 | // contextual recent-session mode while retaining scratch capacity for reuse. | ||
| 326 | func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent { | 332 | func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent { | ||
M internal/usecase/daemon/render_coordinator.go +11 -15
| @@ -745,31 +745,27 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() } | |||
| 745 | 745 | // invalidateRender is the sole producer fan-in. In tests and transitional | |
| 746 | 746 | // headless paths without an attached coordinator it retains the old private | |
| 747 | 747 | // compositor; attached sessions always schedule through their coordinator. | |
| 748 | - | func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, producer string) { | |
| 749 | - | if sess != nil { | |
| 750 | - | if rc := sess.renderCoordinator(); rc != nil { | |
| 751 | - | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) | |
| 752 | - | return | |
| 753 | - | } | |
| 748 | + | func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) { | |
| 749 | + | if rc := attachmentRenderCoordinator(entry); rc != nil { | |
| 750 | + | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) | |
| 751 | + | return | |
| 754 | 752 | } | |
| 755 | 753 | if ac != nil { | |
| 756 | - | d.paint(sess, ac, reset, nil) | |
| 754 | + | d.paint(entry, ac, reset, nil) | |
| 757 | 755 | } | |
| 758 | 756 | } | |
| 759 | 757 | ||
| 760 | 758 | // invalidateRenderNow publishes through the coordinator but immediately | |
| 761 | 759 | // flushes the wake when the client can accept state. Attach uses this path so | |
| 762 | 760 | // the required first full frame never depends on a debounce timer. | |
| 763 | - | func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bool, producer string) { | |
| 764 | - | if sess != nil { | |
| 765 | - | if rc := sess.renderCoordinator(); rc != nil { | |
| 766 | - | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) | |
| 767 | - | rc.fireCurrent(false) | |
| 768 | - | return | |
| 769 | - | } | |
| 761 | + | func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) { | |
| 762 | + | if rc := attachmentRenderCoordinator(entry); rc != nil { | |
| 763 | + | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) | |
| 764 | + | rc.fireCurrent(false) | |
| 765 | + | return | |
| 770 | 766 | } | |
| 771 | 767 | if ac != nil { | |
| 772 | - | d.paint(sess, ac, reset, nil) | |
| 768 | + | d.paint(entry, ac, reset, nil) | |
| 773 | 769 | } | |
| 774 | 770 | } | |
| 775 | 771 | ||
| @@ -745,31 +745,27 @@ func (c *renderCoordinator) waitForTimerWorkers() { c.supervisor.wait() } | |||||
| 745 | // invalidateRender is the sole producer fan-in. In tests and transitional | 745 | // invalidateRender is the sole producer fan-in. In tests and transitional | ||
| 746 | // headless paths without an attached coordinator it retains the old private | 746 | // headless paths without an attached coordinator it retains the old private | ||
| 747 | // compositor; attached sessions always schedule through their coordinator. | 747 | // compositor; attached sessions always schedule through their coordinator. | ||
| 748 | - | func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, producer string) { | 748 | + | func (d *Daemon) invalidateRender(entry attachmentSession, ac *attachedClient, reset bool, producer string) { |
| 749 | - | if sess != nil { | 749 | + | if rc := attachmentRenderCoordinator(entry); rc != nil { |
| 750 | - | if rc := sess.renderCoordinator(); rc != nil { | 750 | + | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) |
| 751 | - | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) | 751 | + | return |
| 752 | - | return | |||
| 753 | - | } | |||
| 754 | } | 752 | } | ||
| 755 | if ac != nil { | 753 | if ac != nil { | ||
| 756 | - | d.paint(sess, ac, reset, nil) | 754 | + | d.paint(entry, ac, reset, nil) |
| 757 | } | 755 | } | ||
| 758 | } | 756 | } | ||
| 759 | 757 | ||||
| 760 | // invalidateRenderNow publishes through the coordinator but immediately | 758 | // invalidateRenderNow publishes through the coordinator but immediately | ||
| 761 | // flushes the wake when the client can accept state. Attach uses this path so | 759 | // flushes the wake when the client can accept state. Attach uses this path so | ||
| 762 | // the required first full frame never depends on a debounce timer. | 760 | // the required first full frame never depends on a debounce timer. | ||
| 763 | - | func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bool, producer string) { | 761 | + | func (d *Daemon) invalidateRenderNow(entry attachmentSession, ac *attachedClient, reset bool, producer string) { |
| 764 | - | if sess != nil { | 762 | + | if rc := attachmentRenderCoordinator(entry); rc != nil { |
| 765 | - | if rc := sess.renderCoordinator(); rc != nil { | 763 | + | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) |
| 766 | - | rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer}) | 764 | + | rc.fireCurrent(false) |
| 767 | - | rc.fireCurrent(false) | 765 | + | return |
| 768 | - | return | |||
| 769 | - | } | |||
| 770 | } | 766 | } | ||
| 771 | if ac != nil { | 767 | if ac != nil { | ||
| 772 | - | d.paint(sess, ac, reset, nil) | 768 | + | d.paint(entry, ac, reset, nil) |
| 773 | } | 769 | } | ||
| 774 | } | 770 | } | ||
| 775 | 771 | ||||
M internal/usecase/daemon/render_coordinator_support.go +26 -4
| @@ -13,11 +13,33 @@ func (c *renderCoordinator) burstMetricsSnapshot() renderCoordinatorBurstMetrics | |||
| 13 | 13 | } | |
| 14 | 14 | } | |
| 15 | 15 | ||
| 16 | - | // renderCoordinator returns the coordinator installed for this session. | |
| 17 | - | func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() } | |
| 16 | + | // attachmentRenderCoordinator returns the coordinator installed for the exact | |
| 17 | + | // attachment-session identity. | |
| 18 | + | func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator { | |
| 19 | + | if entry == nil || entry.core() == nil { | |
| 20 | + | return nil | |
| 21 | + | } | |
| 22 | + | return entry.core().coordinator.Load() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // installAttachmentRenderCoordinator publishes rc for the exact | |
| 26 | + | // attachment-session identity. | |
| 27 | + | func installAttachmentRenderCoordinator(entry attachmentSession, rc *renderCoordinator) { | |
| 28 | + | if entry == nil || entry.core() == nil { | |
| 29 | + | return | |
| 30 | + | } | |
| 31 | + | entry.core().coordinator.Store(rc) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // renderCoordinator retains the local-only method used by PTY and tab owners. | |
| 35 | + | func (s *session) renderCoordinator() *renderCoordinator { | |
| 36 | + | return attachmentRenderCoordinator(s) | |
| 37 | + | } | |
| 18 | 38 | ||
| 19 | - | // installRenderCoordinator publishes rc as the session's coordinator. | |
| 20 | - | func (s *session) installRenderCoordinator(rc *renderCoordinator) { s.coordinator.Store(rc) } | |
| 39 | + | // installRenderCoordinator retains the local-only setup seam used by tests. | |
| 40 | + | func (s *session) installRenderCoordinator(rc *renderCoordinator) { | |
| 41 | + | installAttachmentRenderCoordinator(s, rc) | |
| 42 | + | } | |
| 21 | 43 | ||
| 22 | 44 | func (c *renderCoordinator) fireCurrent(watchdog bool) { | |
| 23 | 45 | c.mu.Lock() | |
| @@ -13,11 +13,33 @@ func (c *renderCoordinator) burstMetricsSnapshot() renderCoordinatorBurstMetrics | |||||
| 13 | } | 13 | } | ||
| 14 | } | 14 | } | ||
| 15 | 15 | ||||
| 16 | - | // renderCoordinator returns the coordinator installed for this session. | 16 | + | // attachmentRenderCoordinator returns the coordinator installed for the exact |
| 17 | - | func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() } | 17 | + | // attachment-session identity. |
| 18 | + | func attachmentRenderCoordinator(entry attachmentSession) *renderCoordinator { | |||
| 19 | + | if entry == nil || entry.core() == nil { | |||
| 20 | + | return nil | |||
| 21 | + | } | |||
| 22 | + | return entry.core().coordinator.Load() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // installAttachmentRenderCoordinator publishes rc for the exact | |||
| 26 | + | // attachment-session identity. | |||
| 27 | + | func installAttachmentRenderCoordinator(entry attachmentSession, rc *renderCoordinator) { | |||
| 28 | + | if entry == nil || entry.core() == nil { | |||
| 29 | + | return | |||
| 30 | + | } | |||
| 31 | + | entry.core().coordinator.Store(rc) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // renderCoordinator retains the local-only method used by PTY and tab owners. | |||
| 35 | + | func (s *session) renderCoordinator() *renderCoordinator { | |||
| 36 | + | return attachmentRenderCoordinator(s) | |||
| 37 | + | } | |||
| 18 | 38 | ||||
| 19 | - | // installRenderCoordinator publishes rc as the session's coordinator. | 39 | + | // installRenderCoordinator retains the local-only setup seam used by tests. |
| 20 | - | func (s *session) installRenderCoordinator(rc *renderCoordinator) { s.coordinator.Store(rc) } | 40 | + | func (s *session) installRenderCoordinator(rc *renderCoordinator) { |
| 41 | + | installAttachmentRenderCoordinator(s, rc) | |||
| 42 | + | } | |||
| 21 | 43 | ||||
| 22 | func (c *renderCoordinator) fireCurrent(watchdog bool) { | 44 | func (c *renderCoordinator) fireCurrent(watchdog bool) { | ||
| 23 | c.mu.Lock() | 45 | c.mu.Lock() | ||
M internal/usecase/daemon/render_test.go +7 -7
| @@ -491,7 +491,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||
| 491 | 491 | viewer.overlays.pickerMu.Lock() | |
| 492 | 492 | viewer.overlays.pickerPreview = target.tabs[0] | |
| 493 | 493 | viewer.overlays.pickerMu.Unlock() | |
| 494 | - | d.sessions["viewer"] = &session{id: "viewer", client: viewer} | |
| 494 | + | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}} | |
| 495 | 495 | previews := make(chan renderWake, 2) | |
| 496 | 496 | rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w }) | |
| 497 | 497 | ||
| @@ -673,7 +673,7 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) { | |||
| 673 | 673 | viewer.overlays.pickerMu.Lock() | |
| 674 | 674 | viewer.overlays.pickerPreview = tb | |
| 675 | 675 | viewer.overlays.pickerMu.Unlock() | |
| 676 | - | d.sessions["viewer"] = &session{id: "viewer", client: viewer} | |
| 676 | + | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}} | |
| 677 | 677 | p.screen.Write([]byte("preview")) | |
| 678 | 678 | _ = d.paneRenderable(sess, tb, p) | |
| 679 | 679 | require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition") | |
| @@ -687,7 +687,7 @@ func TestPTYWriteErrorIsLogged(t *testing.T) { | |||
| 687 | 687 | p := portsmocks.NewMockPTY(t) | |
| 688 | 688 | p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once() | |
| 689 | 689 | win := newTab(p, domain.Size{Cols: 80, Rows: 23}) | |
| 690 | - | sess := &session{id: "manual", name: "work", tabs: []*tab{win}} | |
| 690 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work"}, tabs: []*tab{win}} | |
| 691 | 691 | ac := &attachedClient{} | |
| 692 | 692 | ac.initOverlays() | |
| 693 | 693 | ac.setSession(sess) | |
| @@ -802,7 +802,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||
| 802 | 802 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 803 | 803 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 804 | 804 | ac.initOverlays() | |
| 805 | - | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} | |
| 805 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}} | |
| 806 | 806 | ac.setSession(sess) | |
| 807 | 807 | ||
| 808 | 808 | // Client rows are one more than the equivalent case in a single-bar | |
| @@ -1062,7 +1062,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||
| 1062 | 1062 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 1063 | 1063 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 1064 | 1064 | ac.initOverlays() | |
| 1065 | - | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} | |
| 1065 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}} | |
| 1066 | 1066 | ac.setSession(sess) | |
| 1067 | 1067 | ||
| 1068 | 1068 | d.resize(sess, ac, newSize) | |
| @@ -1091,7 +1091,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||
| 1091 | 1091 | sctx, cancel := context.WithCancel(context.Background()) | |
| 1092 | 1092 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 1093 | 1093 | ac.initOverlays() | |
| 1094 | - | sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 1094 | + | sess := &session{sessionCore: sessionCore{id: "e", name: "0", ephemeral: true, client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 1095 | 1095 | ac.setSession(sess) | |
| 1096 | 1096 | d.sessions[sess.id] = sess | |
| 1097 | 1097 | ||
| @@ -1135,7 +1135,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |||
| 1135 | 1135 | win := newTestTabWithContext(p, sctx, cancel) | |
| 1136 | 1136 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 1137 | 1137 | ac.initOverlays() | |
| 1138 | - | sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 1138 | + | sess := &session{sessionCore: sessionCore{id: "query", name: "query", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} | |
| 1139 | 1139 | ac.setSession(sess) | |
| 1140 | 1140 | d.sessions[sess.id] = sess | |
| 1141 | 1141 | d.sessWg.Add(1) | |
| @@ -491,7 +491,7 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t | |||||
| 491 | viewer.overlays.pickerMu.Lock() | 491 | viewer.overlays.pickerMu.Lock() | ||
| 492 | viewer.overlays.pickerPreview = target.tabs[0] | 492 | viewer.overlays.pickerPreview = target.tabs[0] | ||
| 493 | viewer.overlays.pickerMu.Unlock() | 493 | viewer.overlays.pickerMu.Unlock() | ||
| 494 | - | d.sessions["viewer"] = &session{id: "viewer", client: viewer} | 494 | + | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}} |
| 495 | previews := make(chan renderWake, 2) | 495 | previews := make(chan renderWake, 2) | ||
| 496 | rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w }) | 496 | rc.subscribePreviewFor(viewer, 1, func(w renderWake) { previews <- w }) | ||
| 497 | 497 | ||||
| @@ -673,7 +673,7 @@ func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) { | |||||
| 673 | viewer.overlays.pickerMu.Lock() | 673 | viewer.overlays.pickerMu.Lock() | ||
| 674 | viewer.overlays.pickerPreview = tb | 674 | viewer.overlays.pickerPreview = tb | ||
| 675 | viewer.overlays.pickerMu.Unlock() | 675 | viewer.overlays.pickerMu.Unlock() | ||
| 676 | - | d.sessions["viewer"] = &session{id: "viewer", client: viewer} | 676 | + | d.sessions["viewer"] = &session{sessionCore: sessionCore{id: "viewer", client: viewer}} |
| 677 | p.screen.Write([]byte("preview")) | 677 | p.screen.Write([]byte("preview")) | ||
| 678 | _ = d.paneRenderable(sess, tb, p) | 678 | _ = d.paneRenderable(sess, tb, p) | ||
| 679 | require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition") | 679 | require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition") | ||
| @@ -687,7 +687,7 @@ func TestPTYWriteErrorIsLogged(t *testing.T) { | |||||
| 687 | p := portsmocks.NewMockPTY(t) | 687 | p := portsmocks.NewMockPTY(t) | ||
| 688 | p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once() | 688 | p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once() | ||
| 689 | win := newTab(p, domain.Size{Cols: 80, Rows: 23}) | 689 | win := newTab(p, domain.Size{Cols: 80, Rows: 23}) | ||
| 690 | - | sess := &session{id: "manual", name: "work", tabs: []*tab{win}} | 690 | + | sess := &session{sessionCore: sessionCore{id: "manual", name: "work"}, tabs: []*tab{win}} |
| 691 | ac := &attachedClient{} | 691 | ac := &attachedClient{} | ||
| 692 | ac.initOverlays() | 692 | ac.initOverlays() | ||
| 693 | ac.setSession(sess) | 693 | ac.setSession(sess) | ||
| @@ -802,7 +802,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||||
| 802 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | 802 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 803 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 803 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 804 | ac.initOverlays() | 804 | ac.initOverlays() | ||
| 805 | - | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} | 805 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}} |
| 806 | ac.setSession(sess) | 806 | ac.setSession(sess) | ||
| 807 | 807 | ||||
| 808 | // Client rows are one more than the equivalent case in a single-bar | 808 | // Client rows are one more than the equivalent case in a single-bar | ||
| @@ -1062,7 +1062,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||||
| 1062 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | 1062 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 1063 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 1063 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 1064 | ac.initOverlays() | 1064 | ac.initOverlays() | ||
| 1065 | - | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} | 1065 | + | sess := &session{sessionCore: sessionCore{id: "s", name: "s", client: ac}, tabs: []*tab{win}} |
| 1066 | ac.setSession(sess) | 1066 | ac.setSession(sess) | ||
| 1067 | 1067 | ||||
| 1068 | d.resize(sess, ac, newSize) | 1068 | d.resize(sess, ac, newSize) | ||
| @@ -1091,7 +1091,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||||
| 1091 | sctx, cancel := context.WithCancel(context.Background()) | 1091 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 1092 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 1092 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 1093 | ac.initOverlays() | 1093 | ac.initOverlays() | ||
| 1094 | - | sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 1094 | + | sess := &session{sessionCore: sessionCore{id: "e", name: "0", ephemeral: true, client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} |
| 1095 | ac.setSession(sess) | 1095 | ac.setSession(sess) | ||
| 1096 | d.sessions[sess.id] = sess | 1096 | d.sessions[sess.id] = sess | ||
| 1097 | 1097 | ||||
| @@ -1135,7 +1135,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |||||
| 1135 | win := newTestTabWithContext(p, sctx, cancel) | 1135 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 1136 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 1136 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 1137 | ac.initOverlays() | 1137 | ac.initOverlays() | ||
| 1138 | - | sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 1138 | + | sess := &session{sessionCore: sessionCore{id: "query", name: "query", client: ac}, tabs: []*tab{win}, ctx: sctx, cancel: cancel} |
| 1139 | ac.setSession(sess) | 1139 | ac.setSession(sess) | ||
| 1140 | d.sessions[sess.id] = sess | 1140 | d.sessions[sess.id] = sess | ||
| 1141 | d.sessWg.Add(1) | 1141 | d.sessWg.Add(1) | ||
M internal/usecase/daemon/resume_test.go +2 -2
| @@ -1648,7 +1648,7 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) { | |||
| 1648 | 1648 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 1649 | 1649 | require.NoError(t, err) | |
| 1650 | 1650 | ||
| 1651 | - | previous := &session{id: "previous"} | |
| 1651 | + | previous := &session{sessionCore: sessionCore{id: "previous"}} | |
| 1652 | 1652 | ac.previousSession.Set(previous) | |
| 1653 | 1653 | d.clientGone(sess, ac, ac.transport(), false) | |
| 1654 | 1654 | ||
| @@ -1694,7 +1694,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | |||
| 1694 | 1694 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 1695 | 1695 | require.NoError(t, err) | |
| 1696 | 1696 | ||
| 1697 | - | ac.previousSession.Set(&session{id: "previous"}) | |
| 1697 | + | ac.previousSession.Set(&session{sessionCore: sessionCore{id: "previous"}}) | |
| 1698 | 1698 | d.clientGone(sess, ac, ac.transport(), false) | |
| 1699 | 1699 | ||
| 1700 | 1700 | d.mu.Lock() | |
| @@ -1648,7 +1648,7 @@ func TestLiveParkAndResumeRetainsPreviousSession(t *testing.T) { | |||||
| 1648 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | 1648 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | ||
| 1649 | require.NoError(t, err) | 1649 | require.NoError(t, err) | ||
| 1650 | 1650 | ||||
| 1651 | - | previous := &session{id: "previous"} | 1651 | + | previous := &session{sessionCore: sessionCore{id: "previous"}} |
| 1652 | ac.previousSession.Set(previous) | 1652 | ac.previousSession.Set(previous) | ||
| 1653 | d.clientGone(sess, ac, ac.transport(), false) | 1653 | d.clientGone(sess, ac, ac.transport(), false) | ||
| 1654 | 1654 | ||||
| @@ -1694,7 +1694,7 @@ func TestDiscardingParkedAttachmentClearsPreviousSession(t *testing.T) { | |||||
| 1694 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | 1694 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | ||
| 1695 | require.NoError(t, err) | 1695 | require.NoError(t, err) | ||
| 1696 | 1696 | ||||
| 1697 | - | ac.previousSession.Set(&session{id: "previous"}) | 1697 | + | ac.previousSession.Set(&session{sessionCore: sessionCore{id: "previous"}}) |
| 1698 | d.clientGone(sess, ac, ac.transport(), false) | 1698 | d.clientGone(sess, ac, ac.transport(), false) | ||
| 1699 | 1699 | ||||
| 1700 | d.mu.Lock() | 1700 | d.mu.Lock() | ||
M internal/usecase/daemon/role_effect_gate.go +1 -1
| @@ -18,7 +18,7 @@ const ( | |||
| 18 | 18 | // effect admission. It deliberately contains no independently mutable state: | |
| 19 | 19 | // a ticket is admitted only when every field matches the frame's token. | |
| 20 | 20 | type roleCapability struct { | |
| 21 | - | sess *session | |
| 21 | + | sess attachmentSession | |
| 22 | 22 | role attachmentRole | |
| 23 | 23 | generation uint64 | |
| 24 | 24 | transport transportSnapshot | |
| @@ -18,7 +18,7 @@ const ( | |||||
| 18 | // effect admission. It deliberately contains no independently mutable state: | 18 | // effect admission. It deliberately contains no independently mutable state: | ||
| 19 | // a ticket is admitted only when every field matches the frame's token. | 19 | // a ticket is admitted only when every field matches the frame's token. | ||
| 20 | type roleCapability struct { | 20 | type roleCapability struct { | ||
| 21 | - | sess *session | 21 | + | sess attachmentSession |
| 22 | role attachmentRole | 22 | role attachmentRole | ||
| 23 | generation uint64 | 23 | generation uint64 | ||
| 24 | transport transportSnapshot | 24 | transport transportSnapshot | ||
M internal/usecase/daemon/role_effect_gate_test.go +7 -8
| @@ -287,7 +287,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||
| 287 | 287 | unrelatedTransport := &closeTrackingTransport{} | |
| 288 | 288 | unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size} | |
| 289 | 289 | unrelatedClient.initOverlays() | |
| 290 | - | unrelated := &session{id: "unrelated", name: "unrelated", ctx: sess.ctx, cancel: func() {}, client: unrelatedClient} | |
| 290 | + | unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", client: unrelatedClient}, ctx: sess.ctx, cancel: func() {}} | |
| 291 | 291 | unrelatedClient.setSession(unrelated) | |
| 292 | 292 | d.mu.Lock() | |
| 293 | 293 | d.sessions[unrelated.id] = unrelated | |
| @@ -873,7 +873,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |||
| 873 | 873 | defer release2() | |
| 874 | 874 | defer release3() | |
| 875 | 875 | d, source, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 876 | - | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 876 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 877 | 877 | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | |
| 878 | 878 | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | |
| 879 | 879 | }} | |
| @@ -900,7 +900,7 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T | |||
| 900 | 900 | defer release2() | |
| 901 | 901 | defer release3() | |
| 902 | 902 | d, source, old, _ := newManualSessionWithPTYs(t, p1) | |
| 903 | - | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 903 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | |
| 904 | 904 | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | |
| 905 | 905 | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | |
| 906 | 906 | }} | |
| @@ -1003,7 +1003,7 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi | |||
| 1003 | 1003 | defer release2() | |
| 1004 | 1004 | d, source, old, _ := newManualSessionWithPTYs(t, p1) | |
| 1005 | 1005 | source.name = "source" | |
| 1006 | - | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} | |
| 1006 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} | |
| 1007 | 1007 | d.sessions[target.id] = target | |
| 1008 | 1008 | d.enterPicker(source, old) | |
| 1009 | 1009 | old.overlays.pickerMu.Lock() | |
| @@ -1269,7 +1269,7 @@ func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) | |||
| 1269 | 1269 | bTransport := newDatagramTestTransport() | |
| 1270 | 1270 | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | |
| 1271 | 1271 | b.initOverlays() | |
| 1272 | - | second := &session{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})} | |
| 1272 | + | second := &session{sessionCore: sessionCore{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})}} | |
| 1273 | 1273 | b.setSession(second) | |
| 1274 | 1274 | d.mu.Lock() | |
| 1275 | 1275 | d.sessions[second.id] = second | |
| @@ -1326,9 +1326,8 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) { | |||
| 1326 | 1326 | bTransport := newDatagramTestTransport() | |
| 1327 | 1327 | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | |
| 1328 | 1328 | b.initOverlays() | |
| 1329 | - | target := &session{ | |
| 1330 | - | id: "picker-delete-target", name: "picker-delete-target", ctx: source.ctx, cancel: func() {}, | |
| 1331 | - | client: b, snatched: make(map[*attachedClient]struct{}), | |
| 1329 | + | target := &session{sessionCore: sessionCore{id: "picker-delete-target", name: "picker-delete-target", | |
| 1330 | + | client: b, snatched: make(map[*attachedClient]struct{})}, ctx: source.ctx, cancel: func() {}, | |
| 1332 | 1331 | } | |
| 1333 | 1332 | b.setSession(target) | |
| 1334 | 1333 | d.mu.Lock() | |
| @@ -287,7 +287,7 @@ func TestKillSessionInterruptsOnlyExactParticipantBlockedSends(t *testing.T) { | |||||
| 287 | unrelatedTransport := &closeTrackingTransport{} | 287 | unrelatedTransport := &closeTrackingTransport{} | ||
| 288 | unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size} | 288 | unrelatedClient := &attachedClient{tr: unrelatedTransport, output: newOutputStateStream(), size: active.size} | ||
| 289 | unrelatedClient.initOverlays() | 289 | unrelatedClient.initOverlays() | ||
| 290 | - | unrelated := &session{id: "unrelated", name: "unrelated", ctx: sess.ctx, cancel: func() {}, client: unrelatedClient} | 290 | + | unrelated := &session{sessionCore: sessionCore{id: "unrelated", name: "unrelated", client: unrelatedClient}, ctx: sess.ctx, cancel: func() {}} |
| 291 | unrelatedClient.setSession(unrelated) | 291 | unrelatedClient.setSession(unrelated) | ||
| 292 | d.mu.Lock() | 292 | d.mu.Lock() | ||
| 293 | d.sessions[unrelated.id] = unrelated | 293 | d.sessions[unrelated.id] = unrelated | ||
| @@ -873,7 +873,7 @@ func TestJumpAttentionAdmittedHandoffCrossesSessions(t *testing.T) { | |||||
| 873 | defer release2() | 873 | defer release2() | ||
| 874 | defer release3() | 874 | defer release3() | ||
| 875 | d, source, ac, _ := newManualSessionWithPTYs(t, p1) | 875 | d, source, ac, _ := newManualSessionWithPTYs(t, p1) | ||
| 876 | - | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | 876 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ |
| 877 | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | 877 | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | ||
| 878 | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | 878 | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | ||
| 879 | }} | 879 | }} | ||
| @@ -900,7 +900,7 @@ func TestJumpAttentionHandoffRevalidatesInitiatorAfterAdmissionEnds(t *testing.T | |||||
| 900 | defer release2() | 900 | defer release2() | ||
| 901 | defer release3() | 901 | defer release3() | ||
| 902 | d, source, old, _ := newManualSessionWithPTYs(t, p1) | 902 | d, source, old, _ := newManualSessionWithPTYs(t, p1) | ||
| 903 | - | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{ | 903 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{ |
| 904 | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | 904 | newTab(p2, domain.Size{Cols: 80, Rows: 23}), | ||
| 905 | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | 905 | newTab(p3, domain.Size{Cols: 80, Rows: 23}), | ||
| 906 | }} | 906 | }} | ||
| @@ -1003,7 +1003,7 @@ func TestPickerDeleteOtherSessionRevalidatesInitiatorAfterAdmissionEnds(t *testi | |||||
| 1003 | defer release2() | 1003 | defer release2() | ||
| 1004 | d, source, old, _ := newManualSessionWithPTYs(t, p1) | 1004 | d, source, old, _ := newManualSessionWithPTYs(t, p1) | ||
| 1005 | source.name = "source" | 1005 | source.name = "source" | ||
| 1006 | - | target := &session{id: "target", name: "target", ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} | 1006 | + | target := &session{sessionCore: sessionCore{id: "target", name: "target"}, ctx: source.ctx, cancel: func() {}, tabs: []*tab{newTab(p2, domain.Size{Cols: 80, Rows: 23})}} |
| 1007 | d.sessions[target.id] = target | 1007 | d.sessions[target.id] = target | ||
| 1008 | d.enterPicker(source, old) | 1008 | d.enterPicker(source, old) | ||
| 1009 | old.overlays.pickerMu.Lock() | 1009 | old.overlays.pickerMu.Lock() | ||
| @@ -1269,7 +1269,7 @@ func TestRoleEffectGateReversedConcurrentTransitionsDoNotDeadlock(t *testing.T) | |||||
| 1269 | bTransport := newDatagramTestTransport() | 1269 | bTransport := newDatagramTestTransport() | ||
| 1270 | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | 1270 | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | ||
| 1271 | b.initOverlays() | 1271 | b.initOverlays() | ||
| 1272 | - | second := &session{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})} | 1272 | + | second := &session{sessionCore: sessionCore{id: "second", name: "second", client: b, snatched: make(map[*attachedClient]struct{})}} |
| 1273 | b.setSession(second) | 1273 | b.setSession(second) | ||
| 1274 | d.mu.Lock() | 1274 | d.mu.Lock() | ||
| 1275 | d.sessions[second.id] = second | 1275 | d.sessions[second.id] = second | ||
| @@ -1326,9 +1326,8 @@ func TestPickerDeleteReversedWithTargetTransitionDoesNotDeadlock(t *testing.T) { | |||||
| 1326 | bTransport := newDatagramTestTransport() | 1326 | bTransport := newDatagramTestTransport() | ||
| 1327 | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | 1327 | b := &attachedClient{tr: bTransport, output: newOutputStateStream(), size: a.size} | ||
| 1328 | b.initOverlays() | 1328 | b.initOverlays() | ||
| 1329 | - | target := &session{ | 1329 | + | target := &session{sessionCore: sessionCore{id: "picker-delete-target", name: "picker-delete-target", |
| 1330 | - | id: "picker-delete-target", name: "picker-delete-target", ctx: source.ctx, cancel: func() {}, | 1330 | + | client: b, snatched: make(map[*attachedClient]struct{})}, ctx: source.ctx, cancel: func() {}, |
| 1331 | - | client: b, snatched: make(map[*attachedClient]struct{}), | |||
| 1332 | } | 1331 | } | ||
| 1333 | b.setSession(target) | 1332 | b.setSession(target) | ||
| 1334 | d.mu.Lock() | 1333 | d.mu.Lock() | ||
M internal/usecase/daemon/session.go +100 -58
| @@ -29,11 +29,7 @@ type terminalEnv struct { | |||
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 31 | type session struct { | |
| 32 | - | id domain.SessionID | |
| 33 | - | name string | |
| 34 | - | ephemeral bool | |
| 35 | - | // caps is immutable after construction; see sessionCapabilities. | |
| 36 | - | caps sessionCapabilities | |
| 32 | + | sessionCore | |
| 37 | 33 | ||
| 38 | 34 | ctx context.Context | |
| 39 | 35 | cancel context.CancelFunc | |
| @@ -55,15 +51,14 @@ type session struct { | |||
| 55 | 51 | teardownWaiters uint | |
| 56 | 52 | teardownChanged chan struct{} | |
| 57 | 53 | lifecycleStopOnce sync.Once | |
| 58 | - | mu sync.Mutex // guards tabs, active, client, snatched, restoreDone, clipFiles, and clipboard queue state | |
| 54 | + | // sessionCore.mu guards tabs, active, attachment membership, restoreDone, | |
| 55 | + | // clipFiles, and clipboard queue state. | |
| 59 | 56 | // restoreDone remains attached to a restored session after it is published in | |
| 60 | 57 | // the live registry, so racing attaches cannot bypass restoration completion. | |
| 61 | 58 | restoreDone chan struct{} | |
| 62 | 59 | themeMu sync.Mutex | |
| 63 | 60 | tabs []*tab | |
| 64 | 61 | active int | |
| 65 | - | client *attachedClient | |
| 66 | - | snatched map[*attachedClient]struct{} | |
| 67 | 62 | clipboardQueue []clipboardForward | |
| 68 | 63 | clipboardWorkerRunning bool | |
| 69 | 64 | cwd string | |
| @@ -71,9 +66,6 @@ type session struct { | |||
| 71 | 66 | // env is the authoritative immutable environment snapshot for future PTY children. | |
| 72 | 67 | // It is guarded by mu and is always copied on ingress and egress. | |
| 73 | 68 | env []string | |
| 74 | - | createdAt int64 | |
| 75 | - | incarnation domain.IncarnationID | |
| 76 | - | mruAt atomic.Uint64 | |
| 77 | 69 | snapDirty atomic.Bool | |
| 78 | 70 | snapEligible atomic.Bool | |
| 79 | 71 | // snapshotMu serializes mutation revisions and repository publication | |
| @@ -110,8 +102,6 @@ type session struct { | |||
| 110 | 102 | // syncGen makes synchronized-output watchdog generations unique across all | |
| 111 | 103 | // panes in this session. | |
| 112 | 104 | syncGen atomic.Uint64 | |
| 113 | - | // coordinator fans in this session's producer render invalidations. | |
| 114 | - | coordinator atomic.Pointer[renderCoordinator] | |
| 115 | 105 | // layoutApplyMu serializes whole-session resize prepare/apply/admit/publish | |
| 116 | 106 | // transactions. It is deliberately not an architecture lock and may span | |
| 117 | 107 | // external PTY.Resize calls. | |
| @@ -293,17 +283,19 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 293 | 283 | lastUsedSeq = d.mruSeq.Add(1) | |
| 294 | 284 | } | |
| 295 | 285 | sess := &session{ | |
| 296 | - | id: id, | |
| 297 | - | name: name, | |
| 298 | - | ephemeral: ephemeral, | |
| 286 | + | sessionCore: sessionCore{ | |
| 287 | + | id: id, | |
| 288 | + | name: name, | |
| 289 | + | ephemeral: ephemeral, | |
| 290 | + | createdAt: createdAt, | |
| 291 | + | incarnation: incarnation, | |
| 292 | + | }, | |
| 299 | 293 | ctx: sctx, | |
| 300 | 294 | cancel: cancel, | |
| 301 | 295 | tabs: tabs, | |
| 302 | 296 | cwd: cwd, | |
| 303 | 297 | terminal: term, | |
| 304 | 298 | env: env, | |
| 305 | - | createdAt: createdAt, | |
| 306 | - | incarnation: incarnation, | |
| 307 | 299 | snapshotWake: d.snapshotWake, | |
| 308 | 300 | } | |
| 309 | 301 | if !ephemeral && name != "" { | |
| @@ -316,38 +308,68 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 316 | 308 | for _, tb := range tabs { | |
| 317 | 309 | publishTiledPaneOwners(sess, tb) | |
| 318 | 310 | } | |
| 319 | - | if !ephemeral { | |
| 320 | - | if d.persistEnabled { | |
| 321 | - | record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names} | |
| 322 | - | if d.recovery == nil { | |
| 323 | - | closeTabs(tabs) | |
| 324 | - | cancel() | |
| 325 | - | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("durable session authority is not configured")) | |
| 311 | + | var rollbackDurable func() error | |
| 312 | + | if !ephemeral && d.persistEnabled { | |
| 313 | + | record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names} | |
| 314 | + | if d.recovery == nil { | |
| 315 | + | closeTabs(tabs) | |
| 316 | + | cancel() | |
| 317 | + | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("durable session authority is not configured")) | |
| 318 | + | } | |
| 319 | + | var err error | |
| 320 | + | if resuming && authoritativeExists { | |
| 321 | + | err = d.catalogue.UpdateMetadata(record.MetadataUpdate()) | |
| 322 | + | if err == nil { | |
| 323 | + | rollbackDurable = func() error { return d.catalogue.Replace(name, authoritative) } | |
| 324 | + | err = d.catalogue.Sync() | |
| 326 | 325 | } | |
| 327 | - | var err error | |
| 328 | - | if resuming && authoritativeExists { | |
| 329 | - | err = d.catalogue.UpdateMetadata(record.MetadataUpdate()) | |
| 330 | - | if err == nil { | |
| 331 | - | err = d.catalogue.Sync() | |
| 326 | + | } else { | |
| 327 | + | record, err = d.recovery.Create(d.serveCtx, record) | |
| 328 | + | if err == nil { | |
| 329 | + | rollbackDurable = func() error { | |
| 330 | + | current, ok, recordErr := d.catalogue.Record(name) | |
| 331 | + | if recordErr != nil { | |
| 332 | + | return recordErr | |
| 333 | + | } | |
| 334 | + | if !ok { | |
| 335 | + | return nil | |
| 336 | + | } | |
| 337 | + | if current.IncarnationID != record.IncarnationID { | |
| 338 | + | return errors.New("daemon: created session authority changed before rollback") | |
| 339 | + | } | |
| 340 | + | return d.catalogue.Delete(name) | |
| 332 | 341 | } | |
| 333 | - | } else { | |
| 334 | - | record, err = d.recovery.Create(d.serveCtx, record) | |
| 335 | - | } | |
| 336 | - | if err != nil { | |
| 337 | - | closeTabs(tabs) | |
| 338 | - | cancel() | |
| 339 | - | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) | |
| 340 | 342 | } | |
| 341 | - | sess.incarnation = record.IncarnationID | |
| 342 | - | if resuming && authoritativeExists && authoritative.Committed != nil && authoritative.IncarnationID == sess.incarnation { | |
| 343 | - | checkpoint := *authoritative.Committed | |
| 344 | - | sess.snapshotPublishedGeneration = checkpoint.Generation | |
| 345 | - | sess.snapshotPublishedCheckpoint = &checkpoint | |
| 343 | + | } | |
| 344 | + | if err != nil { | |
| 345 | + | if rollbackDurable != nil { | |
| 346 | + | err = errors.Join(err, rollbackDurable()) | |
| 346 | 347 | } | |
| 348 | + | closeTabs(tabs) | |
| 349 | + | cancel() | |
| 350 | + | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) | |
| 347 | 351 | } | |
| 352 | + | sess.incarnation = record.IncarnationID | |
| 353 | + | if resuming && authoritativeExists && authoritative.Committed != nil && authoritative.IncarnationID == sess.incarnation { | |
| 354 | + | checkpoint := *authoritative.Committed | |
| 355 | + | sess.snapshotPublishedGeneration = checkpoint.Generation | |
| 356 | + | sess.snapshotPublishedCheckpoint = &checkpoint | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | if !d.registerSessionLocked(sess) { | |
| 360 | + | err := errors.New("daemon: session registry rejected local session") | |
| 361 | + | if rollbackDurable != nil { | |
| 362 | + | err = errors.Join(err, rollbackDurable()) | |
| 363 | + | } | |
| 364 | + | closeTabs(tabs) | |
| 365 | + | cancel() | |
| 366 | + | return nil, err | |
| 367 | + | } | |
| 368 | + | if !ephemeral { | |
| 369 | + | // Keep stopped lifecycle authority intact until runtime publication and | |
| 370 | + | // its durable side effects have both succeeded. | |
| 348 | 371 | delete(d.stopped, name) | |
| 349 | 372 | } | |
| 350 | - | d.sessions[id] = sess | |
| 351 | 373 | d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral) | |
| 352 | 374 | for i, tb := range tabs { | |
| 353 | 375 | d.log.Info("tab created", "session", name, "tab", i) | |
| @@ -442,15 +464,19 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s | |||
| 442 | 464 | expectedTransport: token.transport, sourceToken: &token, action: "create-session", | |
| 443 | 465 | copySourceEnvironment: true, ready: true, | |
| 444 | 466 | createTargetLocked: func() (*session, error) { | |
| 467 | + | source, ok := localSession(token.sess) | |
| 468 | + | if !ok { | |
| 469 | + | return nil, errAttachmentTransition | |
| 470 | + | } | |
| 445 | 471 | if d.closing { | |
| 446 | 472 | return nil, errors.New("daemon is shutting down") | |
| 447 | 473 | } | |
| 448 | 474 | if d.nameLiveOrStoppedLocked(name) { | |
| 449 | 475 | return nil, errSessionNameInUse | |
| 450 | 476 | } | |
| 451 | - | token.sess.mu.Lock() | |
| 452 | - | cwd, term, env := token.sess.cwd, token.sess.terminal, copyEnvironment(token.sess.env) | |
| 453 | - | token.sess.mu.Unlock() | |
| 477 | + | source.mu.Lock() | |
| 478 | + | cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env) | |
| 479 | + | source.mu.Unlock() | |
| 454 | 480 | var createErr error | |
| 455 | 481 | created, createErr = d.createSessionLocked(name, false, cwd, token.ac.size, term, env) | |
| 456 | 482 | return created, createErr | |
| @@ -464,7 +490,9 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s | |||
| 464 | 490 | } | |
| 465 | 491 | ||
| 466 | 492 | d.touchMRU(created) | |
| 467 | - | token.ac.recordPreviousSession(token.sess) | |
| 493 | + | if source, ok := localSession(token.sess); ok { | |
| 494 | + | token.ac.recordPreviousSession(source) | |
| 495 | + | } | |
| 468 | 496 | d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable) | |
| 469 | 497 | d.deferAttachmentTransitionCleanups(transition) | |
| 470 | 498 | d.firstPaintForTransition(transition.published) | |
| @@ -729,13 +757,14 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||
| 729 | 757 | d.afterDetachRoleEffectsFrozen() | |
| 730 | 758 | } | |
| 731 | 759 | d.mu.Lock() | |
| 732 | - | if d.sessions[token.sess.id] != token.sess { | |
| 760 | + | core := token.sess.core() | |
| 761 | + | if core == nil || d.sessions[core.id] != token.sess { | |
| 733 | 762 | d.mu.Unlock() | |
| 734 | 763 | return false | |
| 735 | 764 | } | |
| 736 | 765 | d.notices.routingMu.Lock() | |
| 737 | - | token.sess.mu.Lock() | |
| 738 | - | coordinator := token.sess.renderCoordinator() | |
| 766 | + | core.mu.Lock() | |
| 767 | + | coordinator := core.coordinator.Load() | |
| 739 | 768 | if coordinator != nil { | |
| 740 | 769 | coordinator.mu.Lock() | |
| 741 | 770 | } | |
| @@ -745,7 +774,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||
| 745 | 774 | } | |
| 746 | 775 | current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | |
| 747 | 776 | if current { | |
| 748 | - | token.sess.client = nil | |
| 777 | + | core.client = nil | |
| 749 | 778 | token.ac.roleGeneration.Add(1) | |
| 750 | 779 | token.ac.setSession(nil) | |
| 751 | 780 | token.ac.invalidateFrozenRoleCapability() | |
| @@ -753,7 +782,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||
| 753 | 782 | if coordinator != nil { | |
| 754 | 783 | coordinator.mu.Unlock() | |
| 755 | 784 | } | |
| 756 | - | token.sess.mu.Unlock() | |
| 785 | + | core.mu.Unlock() | |
| 757 | 786 | d.notices.routingMu.Unlock() | |
| 758 | 787 | d.mu.Unlock() | |
| 759 | 788 | return current | |
| @@ -1107,7 +1136,11 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||
| 1107 | 1136 | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil { | |
| 1108 | 1137 | return sessionKillParticipants{}, false | |
| 1109 | 1138 | } | |
| 1110 | - | snapshot.source = token.sess | |
| 1139 | + | var ok bool | |
| 1140 | + | snapshot.source, ok = localSession(token.sess) | |
| 1141 | + | if !ok { | |
| 1142 | + | return sessionKillParticipants{}, false | |
| 1143 | + | } | |
| 1111 | 1144 | snapshot.sourceToken = &admission.token | |
| 1112 | 1145 | } | |
| 1113 | 1146 | ||
| @@ -1481,7 +1514,13 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1481 | 1514 | // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held | |
| 1482 | 1515 | // here. Registry removal and every role invalidation are one publication | |
| 1483 | 1516 | // while the complete immutable gate set remains frozen. | |
| 1484 | - | delete(d.sessions, sess.id) | |
| 1517 | + | if !d.unregisterSessionLocked(sess) { | |
| 1518 | + | unlockCoordinators() | |
| 1519 | + | unlockSessions() | |
| 1520 | + | d.notices.routingMu.Unlock() | |
| 1521 | + | d.mu.Unlock() | |
| 1522 | + | return errAttachmentTransition | |
| 1523 | + | } | |
| 1485 | 1524 | d.clearBarScriptsForSession(sess.id) | |
| 1486 | 1525 | d.purgeParkingForSessionLocked(sess) | |
| 1487 | 1526 | parkedRetirements := d.purgeParkedForSessionLocked(sess) | |
| @@ -1591,8 +1630,10 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||
| 1591 | 1630 | // d.mu. | |
| 1592 | 1631 | func (d *Daemon) allocEphemeralNameLocked() string { | |
| 1593 | 1632 | used := make(map[string]struct{}, len(d.sessions)+len(d.stopped)) | |
| 1594 | - | for _, s := range d.sessions { | |
| 1595 | - | used[s.name] = struct{}{} | |
| 1633 | + | for _, entry := range d.sessions { | |
| 1634 | + | if s, ok := localSession(entry); ok { | |
| 1635 | + | used[s.name] = struct{}{} | |
| 1636 | + | } | |
| 1596 | 1637 | } | |
| 1597 | 1638 | for name := range d.stopped { | |
| 1598 | 1639 | used[name] = struct{}{} | |
| @@ -1606,8 +1647,9 @@ func (d *Daemon) allocEphemeralNameLocked() string { | |||
| 1606 | 1647 | } | |
| 1607 | 1648 | ||
| 1608 | 1649 | func (d *Daemon) findByNameLocked(name string) *session { | |
| 1609 | - | for _, s := range d.sessions { | |
| 1610 | - | if s.name == name { | |
| 1650 | + | for _, entry := range d.sessions { | |
| 1651 | + | s, ok := localSession(entry) | |
| 1652 | + | if ok && s.name == name { | |
| 1611 | 1653 | return s | |
| 1612 | 1654 | } | |
| 1613 | 1655 | } | |
| @@ -29,11 +29,7 @@ type terminalEnv struct { | |||||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | type session struct { | 31 | type session struct { | ||
| 32 | - | id domain.SessionID | 32 | + | sessionCore |
| 33 | - | name string | |||
| 34 | - | ephemeral bool | |||
| 35 | - | // caps is immutable after construction; see sessionCapabilities. | |||
| 36 | - | caps sessionCapabilities | |||
| 37 | 33 | ||||
| 38 | ctx context.Context | 34 | ctx context.Context | ||
| 39 | cancel context.CancelFunc | 35 | cancel context.CancelFunc | ||
| @@ -55,15 +51,14 @@ type session struct { | |||||
| 55 | teardownWaiters uint | 51 | teardownWaiters uint | ||
| 56 | teardownChanged chan struct{} | 52 | teardownChanged chan struct{} | ||
| 57 | lifecycleStopOnce sync.Once | 53 | lifecycleStopOnce sync.Once | ||
| 58 | - | mu sync.Mutex // guards tabs, active, client, snatched, restoreDone, clipFiles, and clipboard queue state | 54 | + | // sessionCore.mu guards tabs, active, attachment membership, restoreDone, |
| 55 | + | // clipFiles, and clipboard queue state. | |||
| 59 | // restoreDone remains attached to a restored session after it is published in | 56 | // restoreDone remains attached to a restored session after it is published in | ||
| 60 | // the live registry, so racing attaches cannot bypass restoration completion. | 57 | // the live registry, so racing attaches cannot bypass restoration completion. | ||
| 61 | restoreDone chan struct{} | 58 | restoreDone chan struct{} | ||
| 62 | themeMu sync.Mutex | 59 | themeMu sync.Mutex | ||
| 63 | tabs []*tab | 60 | tabs []*tab | ||
| 64 | active int | 61 | active int | ||
| 65 | - | client *attachedClient | |||
| 66 | - | snatched map[*attachedClient]struct{} | |||
| 67 | clipboardQueue []clipboardForward | 62 | clipboardQueue []clipboardForward | ||
| 68 | clipboardWorkerRunning bool | 63 | clipboardWorkerRunning bool | ||
| 69 | cwd string | 64 | cwd string | ||
| @@ -71,9 +66,6 @@ type session struct { | |||||
| 71 | // env is the authoritative immutable environment snapshot for future PTY children. | 66 | // env is the authoritative immutable environment snapshot for future PTY children. | ||
| 72 | // It is guarded by mu and is always copied on ingress and egress. | 67 | // It is guarded by mu and is always copied on ingress and egress. | ||
| 73 | env []string | 68 | env []string | ||
| 74 | - | createdAt int64 | |||
| 75 | - | incarnation domain.IncarnationID | |||
| 76 | - | mruAt atomic.Uint64 | |||
| 77 | snapDirty atomic.Bool | 69 | snapDirty atomic.Bool | ||
| 78 | snapEligible atomic.Bool | 70 | snapEligible atomic.Bool | ||
| 79 | // snapshotMu serializes mutation revisions and repository publication | 71 | // snapshotMu serializes mutation revisions and repository publication | ||
| @@ -110,8 +102,6 @@ type session struct { | |||||
| 110 | // syncGen makes synchronized-output watchdog generations unique across all | 102 | // syncGen makes synchronized-output watchdog generations unique across all | ||
| 111 | // panes in this session. | 103 | // panes in this session. | ||
| 112 | syncGen atomic.Uint64 | 104 | syncGen atomic.Uint64 | ||
| 113 | - | // coordinator fans in this session's producer render invalidations. | |||
| 114 | - | coordinator atomic.Pointer[renderCoordinator] | |||
| 115 | // layoutApplyMu serializes whole-session resize prepare/apply/admit/publish | 105 | // layoutApplyMu serializes whole-session resize prepare/apply/admit/publish | ||
| 116 | // transactions. It is deliberately not an architecture lock and may span | 106 | // transactions. It is deliberately not an architecture lock and may span | ||
| 117 | // external PTY.Resize calls. | 107 | // external PTY.Resize calls. | ||
| @@ -293,17 +283,19 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 293 | lastUsedSeq = d.mruSeq.Add(1) | 283 | lastUsedSeq = d.mruSeq.Add(1) | ||
| 294 | } | 284 | } | ||
| 295 | sess := &session{ | 285 | sess := &session{ | ||
| 296 | - | id: id, | 286 | + | sessionCore: sessionCore{ |
| 297 | - | name: name, | 287 | + | id: id, |
| 298 | - | ephemeral: ephemeral, | 288 | + | name: name, |
| 289 | + | ephemeral: ephemeral, | |||
| 290 | + | createdAt: createdAt, | |||
| 291 | + | incarnation: incarnation, | |||
| 292 | + | }, | |||
| 299 | ctx: sctx, | 293 | ctx: sctx, | ||
| 300 | cancel: cancel, | 294 | cancel: cancel, | ||
| 301 | tabs: tabs, | 295 | tabs: tabs, | ||
| 302 | cwd: cwd, | 296 | cwd: cwd, | ||
| 303 | terminal: term, | 297 | terminal: term, | ||
| 304 | env: env, | 298 | env: env, | ||
| 305 | - | createdAt: createdAt, | |||
| 306 | - | incarnation: incarnation, | |||
| 307 | snapshotWake: d.snapshotWake, | 299 | snapshotWake: d.snapshotWake, | ||
| 308 | } | 300 | } | ||
| 309 | if !ephemeral && name != "" { | 301 | if !ephemeral && name != "" { | ||
| @@ -316,38 +308,68 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||||
| 316 | for _, tb := range tabs { | 308 | for _, tb := range tabs { | ||
| 317 | publishTiledPaneOwners(sess, tb) | 309 | publishTiledPaneOwners(sess, tb) | ||
| 318 | } | 310 | } | ||
| 319 | - | if !ephemeral { | 311 | + | var rollbackDurable func() error |
| 320 | - | if d.persistEnabled { | 312 | + | if !ephemeral && d.persistEnabled { |
| 321 | - | record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names} | 313 | + | record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names} |
| 322 | - | if d.recovery == nil { | 314 | + | if d.recovery == nil { |
| 323 | - | closeTabs(tabs) | 315 | + | closeTabs(tabs) |
| 324 | - | cancel() | 316 | + | cancel() |
| 325 | - | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("durable session authority is not configured")) | 317 | + | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("durable session authority is not configured")) |
| 318 | + | } | |||
| 319 | + | var err error | |||
| 320 | + | if resuming && authoritativeExists { | |||
| 321 | + | err = d.catalogue.UpdateMetadata(record.MetadataUpdate()) | |||
| 322 | + | if err == nil { | |||
| 323 | + | rollbackDurable = func() error { return d.catalogue.Replace(name, authoritative) } | |||
| 324 | + | err = d.catalogue.Sync() | |||
| 326 | } | 325 | } | ||
| 327 | - | var err error | 326 | + | } else { |
| 328 | - | if resuming && authoritativeExists { | 327 | + | record, err = d.recovery.Create(d.serveCtx, record) |
| 329 | - | err = d.catalogue.UpdateMetadata(record.MetadataUpdate()) | 328 | + | if err == nil { |
| 330 | - | if err == nil { | 329 | + | rollbackDurable = func() error { |
| 331 | - | err = d.catalogue.Sync() | 330 | + | current, ok, recordErr := d.catalogue.Record(name) |
| 331 | + | if recordErr != nil { | |||
| 332 | + | return recordErr | |||
| 333 | + | } | |||
| 334 | + | if !ok { | |||
| 335 | + | return nil | |||
| 336 | + | } | |||
| 337 | + | if current.IncarnationID != record.IncarnationID { | |||
| 338 | + | return errors.New("daemon: created session authority changed before rollback") | |||
| 339 | + | } | |||
| 340 | + | return d.catalogue.Delete(name) | |||
| 332 | } | 341 | } | ||
| 333 | - | } else { | |||
| 334 | - | record, err = d.recovery.Create(d.serveCtx, record) | |||
| 335 | - | } | |||
| 336 | - | if err != nil { | |||
| 337 | - | closeTabs(tabs) | |||
| 338 | - | cancel() | |||
| 339 | - | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) | |||
| 340 | } | 342 | } | ||
| 341 | - | sess.incarnation = record.IncarnationID | 343 | + | } |
| 342 | - | if resuming && authoritativeExists && authoritative.Committed != nil && authoritative.IncarnationID == sess.incarnation { | 344 | + | if err != nil { |
| 343 | - | checkpoint := *authoritative.Committed | 345 | + | if rollbackDurable != nil { |
| 344 | - | sess.snapshotPublishedGeneration = checkpoint.Generation | 346 | + | err = errors.Join(err, rollbackDurable()) |
| 345 | - | sess.snapshotPublishedCheckpoint = &checkpoint | |||
| 346 | } | 347 | } | ||
| 348 | + | closeTabs(tabs) | |||
| 349 | + | cancel() | |||
| 350 | + | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err) | |||
| 347 | } | 351 | } | ||
| 352 | + | sess.incarnation = record.IncarnationID | |||
| 353 | + | if resuming && authoritativeExists && authoritative.Committed != nil && authoritative.IncarnationID == sess.incarnation { | |||
| 354 | + | checkpoint := *authoritative.Committed | |||
| 355 | + | sess.snapshotPublishedGeneration = checkpoint.Generation | |||
| 356 | + | sess.snapshotPublishedCheckpoint = &checkpoint | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | if !d.registerSessionLocked(sess) { | |||
| 360 | + | err := errors.New("daemon: session registry rejected local session") | |||
| 361 | + | if rollbackDurable != nil { | |||
| 362 | + | err = errors.Join(err, rollbackDurable()) | |||
| 363 | + | } | |||
| 364 | + | closeTabs(tabs) | |||
| 365 | + | cancel() | |||
| 366 | + | return nil, err | |||
| 367 | + | } | |||
| 368 | + | if !ephemeral { | |||
| 369 | + | // Keep stopped lifecycle authority intact until runtime publication and | |||
| 370 | + | // its durable side effects have both succeeded. | |||
| 348 | delete(d.stopped, name) | 371 | delete(d.stopped, name) | ||
| 349 | } | 372 | } | ||
| 350 | - | d.sessions[id] = sess | |||
| 351 | d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral) | 373 | d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral) | ||
| 352 | for i, tb := range tabs { | 374 | for i, tb := range tabs { | ||
| 353 | d.log.Info("tab created", "session", name, "tab", i) | 375 | d.log.Info("tab created", "session", name, "tab", i) | ||
| @@ -442,15 +464,19 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s | |||||
| 442 | expectedTransport: token.transport, sourceToken: &token, action: "create-session", | 464 | expectedTransport: token.transport, sourceToken: &token, action: "create-session", | ||
| 443 | copySourceEnvironment: true, ready: true, | 465 | copySourceEnvironment: true, ready: true, | ||
| 444 | createTargetLocked: func() (*session, error) { | 466 | createTargetLocked: func() (*session, error) { | ||
| 467 | + | source, ok := localSession(token.sess) | |||
| 468 | + | if !ok { | |||
| 469 | + | return nil, errAttachmentTransition | |||
| 470 | + | } | |||
| 445 | if d.closing { | 471 | if d.closing { | ||
| 446 | return nil, errors.New("daemon is shutting down") | 472 | return nil, errors.New("daemon is shutting down") | ||
| 447 | } | 473 | } | ||
| 448 | if d.nameLiveOrStoppedLocked(name) { | 474 | if d.nameLiveOrStoppedLocked(name) { | ||
| 449 | return nil, errSessionNameInUse | 475 | return nil, errSessionNameInUse | ||
| 450 | } | 476 | } | ||
| 451 | - | token.sess.mu.Lock() | 477 | + | source.mu.Lock() |
| 452 | - | cwd, term, env := token.sess.cwd, token.sess.terminal, copyEnvironment(token.sess.env) | 478 | + | cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env) |
| 453 | - | token.sess.mu.Unlock() | 479 | + | source.mu.Unlock() |
| 454 | var createErr error | 480 | var createErr error | ||
| 455 | created, createErr = d.createSessionLocked(name, false, cwd, token.ac.size, term, env) | 481 | created, createErr = d.createSessionLocked(name, false, cwd, token.ac.size, term, env) | ||
| 456 | return created, createErr | 482 | return created, createErr | ||
| @@ -464,7 +490,9 @@ func (d *Daemon) createSessionAndSwitchForRole(token attachmentRoleToken, name s | |||||
| 464 | } | 490 | } | ||
| 465 | 491 | ||||
| 466 | d.touchMRU(created) | 492 | d.touchMRU(created) | ||
| 467 | - | token.ac.recordPreviousSession(token.sess) | 493 | + | if source, ok := localSession(token.sess); ok { |
| 494 | + | token.ac.recordPreviousSession(source) | |||
| 495 | + | } | |||
| 468 | d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable) | 496 | d.log.Info("client attached", "session", created.name, "resume", token.ac.resumeCapable) | ||
| 469 | d.deferAttachmentTransitionCleanups(transition) | 497 | d.deferAttachmentTransitionCleanups(transition) | ||
| 470 | d.firstPaintForTransition(transition.published) | 498 | d.firstPaintForTransition(transition.published) | ||
| @@ -729,13 +757,14 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||||
| 729 | d.afterDetachRoleEffectsFrozen() | 757 | d.afterDetachRoleEffectsFrozen() | ||
| 730 | } | 758 | } | ||
| 731 | d.mu.Lock() | 759 | d.mu.Lock() | ||
| 732 | - | if d.sessions[token.sess.id] != token.sess { | 760 | + | core := token.sess.core() |
| 761 | + | if core == nil || d.sessions[core.id] != token.sess { | |||
| 733 | d.mu.Unlock() | 762 | d.mu.Unlock() | ||
| 734 | return false | 763 | return false | ||
| 735 | } | 764 | } | ||
| 736 | d.notices.routingMu.Lock() | 765 | d.notices.routingMu.Lock() | ||
| 737 | - | token.sess.mu.Lock() | 766 | + | core.mu.Lock() |
| 738 | - | coordinator := token.sess.renderCoordinator() | 767 | + | coordinator := core.coordinator.Load() |
| 739 | if coordinator != nil { | 768 | if coordinator != nil { | ||
| 740 | coordinator.mu.Lock() | 769 | coordinator.mu.Lock() | ||
| 741 | } | 770 | } | ||
| @@ -745,7 +774,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||||
| 745 | } | 774 | } | ||
| 746 | current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | 775 | current := coordinator != nil && transitionSourceTokenCurrentLocked(token, token.sess, coordinator, req) | ||
| 747 | if current { | 776 | if current { | ||
| 748 | - | token.sess.client = nil | 777 | + | core.client = nil |
| 749 | token.ac.roleGeneration.Add(1) | 778 | token.ac.roleGeneration.Add(1) | ||
| 750 | token.ac.setSession(nil) | 779 | token.ac.setSession(nil) | ||
| 751 | token.ac.invalidateFrozenRoleCapability() | 780 | token.ac.invalidateFrozenRoleCapability() | ||
| @@ -753,7 +782,7 @@ func (d *Daemon) detachIfRoleCurrentUntil(token attachmentRoleToken, done func() | |||||
| 753 | if coordinator != nil { | 782 | if coordinator != nil { | ||
| 754 | coordinator.mu.Unlock() | 783 | coordinator.mu.Unlock() | ||
| 755 | } | 784 | } | ||
| 756 | - | token.sess.mu.Unlock() | 785 | + | core.mu.Unlock() |
| 757 | d.notices.routingMu.Unlock() | 786 | d.notices.routingMu.Unlock() | ||
| 758 | d.mu.Unlock() | 787 | d.mu.Unlock() | ||
| 759 | return current | 788 | return current | ||
| @@ -1107,7 +1136,11 @@ func (d *Daemon) snapshotSessionKillParticipants(target *session, admission *ses | |||||
| 1107 | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil { | 1136 | if token.sess == nil || token.ac == nil || token.role != attachmentActive || token.transport.transport == nil { | ||
| 1108 | return sessionKillParticipants{}, false | 1137 | return sessionKillParticipants{}, false | ||
| 1109 | } | 1138 | } | ||
| 1110 | - | snapshot.source = token.sess | 1139 | + | var ok bool |
| 1140 | + | snapshot.source, ok = localSession(token.sess) | |||
| 1141 | + | if !ok { | |||
| 1142 | + | return sessionKillParticipants{}, false | |||
| 1143 | + | } | |||
| 1111 | snapshot.sourceToken = &admission.token | 1144 | snapshot.sourceToken = &admission.token | ||
| 1112 | } | 1145 | } | ||
| 1113 | 1146 | ||||
| @@ -1481,7 +1514,13 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||||
| 1481 | // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held | 1514 | // d.mu -> routingMu -> ordered sessions -> ordered coordinators are held | ||
| 1482 | // here. Registry removal and every role invalidation are one publication | 1515 | // here. Registry removal and every role invalidation are one publication | ||
| 1483 | // while the complete immutable gate set remains frozen. | 1516 | // while the complete immutable gate set remains frozen. | ||
| 1484 | - | delete(d.sessions, sess.id) | 1517 | + | if !d.unregisterSessionLocked(sess) { |
| 1518 | + | unlockCoordinators() | |||
| 1519 | + | unlockSessions() | |||
| 1520 | + | d.notices.routingMu.Unlock() | |||
| 1521 | + | d.mu.Unlock() | |||
| 1522 | + | return errAttachmentTransition | |||
| 1523 | + | } | |||
| 1485 | d.clearBarScriptsForSession(sess.id) | 1524 | d.clearBarScriptsForSession(sess.id) | ||
| 1486 | d.purgeParkingForSessionLocked(sess) | 1525 | d.purgeParkingForSessionLocked(sess) | ||
| 1487 | parkedRetirements := d.purgeParkedForSessionLocked(sess) | 1526 | parkedRetirements := d.purgeParkedForSessionLocked(sess) | ||
| @@ -1591,8 +1630,10 @@ func (d *Daemon) killSessionWithSnapshotDeadline(sess *session, reason uint8, pu | |||||
| 1591 | // d.mu. | 1630 | // d.mu. | ||
| 1592 | func (d *Daemon) allocEphemeralNameLocked() string { | 1631 | func (d *Daemon) allocEphemeralNameLocked() string { | ||
| 1593 | used := make(map[string]struct{}, len(d.sessions)+len(d.stopped)) | 1632 | used := make(map[string]struct{}, len(d.sessions)+len(d.stopped)) | ||
| 1594 | - | for _, s := range d.sessions { | 1633 | + | for _, entry := range d.sessions { |
| 1595 | - | used[s.name] = struct{}{} | 1634 | + | if s, ok := localSession(entry); ok { |
| 1635 | + | used[s.name] = struct{}{} | |||
| 1636 | + | } | |||
| 1596 | } | 1637 | } | ||
| 1597 | for name := range d.stopped { | 1638 | for name := range d.stopped { | ||
| 1598 | used[name] = struct{}{} | 1639 | used[name] = struct{}{} | ||
| @@ -1606,8 +1647,9 @@ func (d *Daemon) allocEphemeralNameLocked() string { | |||||
| 1606 | } | 1647 | } | ||
| 1607 | 1648 | ||||
| 1608 | func (d *Daemon) findByNameLocked(name string) *session { | 1649 | func (d *Daemon) findByNameLocked(name string) *session { | ||
| 1609 | - | for _, s := range d.sessions { | 1650 | + | for _, entry := range d.sessions { |
| 1610 | - | if s.name == name { | 1651 | + | s, ok := localSession(entry) |
| 1652 | + | if ok && s.name == name { | |||
| 1611 | return s | 1653 | return s | ||
| 1612 | } | 1654 | } | ||
| 1613 | } | 1655 | } | ||
M internal/usecase/daemon/session_back.go +7 -7
| @@ -9,11 +9,11 @@ func (d *Daemon) backSessionForRole(token attachmentRoleToken) error { | |||
| 9 | 9 | return nil | |
| 10 | 10 | } | |
| 11 | 11 | target := token.ac.previousSession.Get() | |
| 12 | - | if target == nil || target == token.sess || d.sessionByID(target.id) != target { | |
| 12 | + | if target == nil || target == token.sess || target.core() == nil || d.sessionByID(target.core().id) != target { | |
| 13 | 13 | token.ac.clearPreviousSessionIf(target) | |
| 14 | 14 | return nil | |
| 15 | 15 | } | |
| 16 | - | return d.switchToTargetForRole(token, picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "back-session") | |
| 16 | + | return d.switchToTargetForRole(token, picker.Target{Session: target.core().id, TabIndex: -1}, sessionHandoffGuard{}, "back-session") | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | func (d *Daemon) backSession(current *session, ac *attachedClient) { | |
| @@ -21,23 +21,23 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) { | |||
| 21 | 21 | return | |
| 22 | 22 | } | |
| 23 | 23 | target := ac.previousSession.Get() | |
| 24 | - | if target == nil || target == current || d.sessionByID(target.id) != target { | |
| 24 | + | if target == nil || target == current || target.core() == nil || d.sessionByID(target.core().id) != target { | |
| 25 | 25 | ac.clearPreviousSessionIf(target) | |
| 26 | 26 | d.invalidateRender(current, ac, true, "session_back.go") | |
| 27 | 27 | return | |
| 28 | 28 | } | |
| 29 | - | if err := d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil { | |
| 29 | + | if err := d.switchToTarget(current, ac, picker.Target{Session: target.core().id, TabIndex: -1}); err != nil { | |
| 30 | 30 | d.reportError(current, err) | |
| 31 | 31 | } | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | 34 | // clearPreviousSessionIf clears target only if it has not been replaced since | |
| 35 | 35 | // the caller observed it. This preserves a concurrent successful hand-off. | |
| 36 | - | func (ac *attachedClient) clearPreviousSessionIf(target *session) { | |
| 36 | + | func (ac *attachedClient) clearPreviousSessionIf(target attachmentSession) { | |
| 37 | 37 | if ac == nil { | |
| 38 | 38 | return | |
| 39 | 39 | } | |
| 40 | - | ac.previousSession.With(func(previous **session) { | |
| 40 | + | ac.previousSession.With(func(previous *attachmentSession) { | |
| 41 | 41 | if *previous == target { | |
| 42 | 42 | *previous = nil | |
| 43 | 43 | } | |
| @@ -47,7 +47,7 @@ func (ac *attachedClient) clearPreviousSessionIf(target *session) { | |||
| 47 | 47 | // recordPreviousSession is called only after a cross-session hand-off has | |
| 48 | 48 | // committed. Keeping this after the commit makes failed/displaced switches | |
| 49 | 49 | // leave the toggle pair intact. | |
| 50 | - | func (ac *attachedClient) recordPreviousSession(origin *session) { | |
| 50 | + | func (ac *attachedClient) recordPreviousSession(origin attachmentSession) { | |
| 51 | 51 | if ac != nil && origin != nil { | |
| 52 | 52 | ac.previousSession.Set(origin) | |
| 53 | 53 | } | |
| @@ -9,11 +9,11 @@ func (d *Daemon) backSessionForRole(token attachmentRoleToken) error { | |||||
| 9 | return nil | 9 | return nil | ||
| 10 | } | 10 | } | ||
| 11 | target := token.ac.previousSession.Get() | 11 | target := token.ac.previousSession.Get() | ||
| 12 | - | if target == nil || target == token.sess || d.sessionByID(target.id) != target { | 12 | + | if target == nil || target == token.sess || target.core() == nil || d.sessionByID(target.core().id) != target { |
| 13 | token.ac.clearPreviousSessionIf(target) | 13 | token.ac.clearPreviousSessionIf(target) | ||
| 14 | return nil | 14 | return nil | ||
| 15 | } | 15 | } | ||
| 16 | - | return d.switchToTargetForRole(token, picker.Target{Session: target.id, TabIndex: -1}, sessionHandoffGuard{}, "back-session") | 16 | + | return d.switchToTargetForRole(token, picker.Target{Session: target.core().id, TabIndex: -1}, sessionHandoffGuard{}, "back-session") |
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | func (d *Daemon) backSession(current *session, ac *attachedClient) { | 19 | func (d *Daemon) backSession(current *session, ac *attachedClient) { | ||
| @@ -21,23 +21,23 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) { | |||||
| 21 | return | 21 | return | ||
| 22 | } | 22 | } | ||
| 23 | target := ac.previousSession.Get() | 23 | target := ac.previousSession.Get() | ||
| 24 | - | if target == nil || target == current || d.sessionByID(target.id) != target { | 24 | + | if target == nil || target == current || target.core() == nil || d.sessionByID(target.core().id) != target { |
| 25 | ac.clearPreviousSessionIf(target) | 25 | ac.clearPreviousSessionIf(target) | ||
| 26 | d.invalidateRender(current, ac, true, "session_back.go") | 26 | d.invalidateRender(current, ac, true, "session_back.go") | ||
| 27 | return | 27 | return | ||
| 28 | } | 28 | } | ||
| 29 | - | if err := d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1}); err != nil { | 29 | + | if err := d.switchToTarget(current, ac, picker.Target{Session: target.core().id, TabIndex: -1}); err != nil { |
| 30 | d.reportError(current, err) | 30 | d.reportError(current, err) | ||
| 31 | } | 31 | } | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | // clearPreviousSessionIf clears target only if it has not been replaced since | 34 | // clearPreviousSessionIf clears target only if it has not been replaced since | ||
| 35 | // the caller observed it. This preserves a concurrent successful hand-off. | 35 | // the caller observed it. This preserves a concurrent successful hand-off. | ||
| 36 | - | func (ac *attachedClient) clearPreviousSessionIf(target *session) { | 36 | + | func (ac *attachedClient) clearPreviousSessionIf(target attachmentSession) { |
| 37 | if ac == nil { | 37 | if ac == nil { | ||
| 38 | return | 38 | return | ||
| 39 | } | 39 | } | ||
| 40 | - | ac.previousSession.With(func(previous **session) { | 40 | + | ac.previousSession.With(func(previous *attachmentSession) { |
| 41 | if *previous == target { | 41 | if *previous == target { | ||
| 42 | *previous = nil | 42 | *previous = nil | ||
| 43 | } | 43 | } | ||
| @@ -47,7 +47,7 @@ func (ac *attachedClient) clearPreviousSessionIf(target *session) { | |||||
| 47 | // recordPreviousSession is called only after a cross-session hand-off has | 47 | // recordPreviousSession is called only after a cross-session hand-off has | ||
| 48 | // committed. Keeping this after the commit makes failed/displaced switches | 48 | // committed. Keeping this after the commit makes failed/displaced switches | ||
| 49 | // leave the toggle pair intact. | 49 | // leave the toggle pair intact. | ||
| 50 | - | func (ac *attachedClient) recordPreviousSession(origin *session) { | 50 | + | func (ac *attachedClient) recordPreviousSession(origin attachmentSession) { |
| 51 | if ac != nil && origin != nil { | 51 | if ac != nil && origin != nil { | ||
| 52 | ac.previousSession.Set(origin) | 52 | ac.previousSession.Set(origin) | ||
| 53 | } | 53 | } | ||
M internal/usecase/daemon/session_capabilities_test.go +2 -2
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) { | |
| 11 | - | s := &session{id: domain.SessionID("s1"), name: "alpha"} | |
| 11 | + | s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha"}} | |
| 12 | 12 | caps := s.capabilities() | |
| 13 | 13 | if caps.cannotAcceptMoves || caps.cannotYieldMoves || !caps.yieldsMoves() { | |
| 14 | 14 | t.Fatalf("zero-value capabilities = %+v, want fully capable", caps) | |
| @@ -26,7 +26,7 @@ func TestSnapshotViewCarriesCannotAcceptMoves(t *testing.T) { | |||
| 26 | 26 | } | |
| 27 | 27 | for _, tt := range tests { | |
| 28 | 28 | t.Run(tt.name, func(t *testing.T) { | |
| 29 | - | s := &session{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps} | |
| 29 | + | s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps}} | |
| 30 | 30 | if got := s.snapshotView(viewOptions{}).cannotAcceptMoves; got != tt.want { | |
| 31 | 31 | t.Fatalf("snapshotView().cannotAcceptMoves = %v, want %v", got, tt.want) | |
| 32 | 32 | } | |
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) { | 10 | func TestCapabilitiesZeroValueIsFullyCapable(t *testing.T) { | ||
| 11 | - | s := &session{id: domain.SessionID("s1"), name: "alpha"} | 11 | + | s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha"}} |
| 12 | caps := s.capabilities() | 12 | caps := s.capabilities() | ||
| 13 | if caps.cannotAcceptMoves || caps.cannotYieldMoves || !caps.yieldsMoves() { | 13 | if caps.cannotAcceptMoves || caps.cannotYieldMoves || !caps.yieldsMoves() { | ||
| 14 | t.Fatalf("zero-value capabilities = %+v, want fully capable", caps) | 14 | t.Fatalf("zero-value capabilities = %+v, want fully capable", caps) | ||
| @@ -26,7 +26,7 @@ func TestSnapshotViewCarriesCannotAcceptMoves(t *testing.T) { | |||||
| 26 | } | 26 | } | ||
| 27 | for _, tt := range tests { | 27 | for _, tt := range tests { | ||
| 28 | t.Run(tt.name, func(t *testing.T) { | 28 | t.Run(tt.name, func(t *testing.T) { | ||
| 29 | - | s := &session{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps} | 29 | + | s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), name: "alpha", caps: tt.caps}} |
| 30 | if got := s.snapshotView(viewOptions{}).cannotAcceptMoves; got != tt.want { | 30 | if got := s.snapshotView(viewOptions{}).cannotAcceptMoves; got != tt.want { | ||
| 31 | t.Fatalf("snapshotView().cannotAcceptMoves = %v, want %v", got, tt.want) | 31 | t.Fatalf("snapshotView().cannotAcceptMoves = %v, want %v", got, tt.want) | ||
| 32 | } | 32 | } | ||
A internal/usecase/daemon/session_core.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "sync/atomic" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // attachmentSession is the private attachment-facing session contract. Local | |
| 11 | + | // process, tab, persistence, and teardown behavior remains on *session. | |
| 12 | + | type attachmentSession interface { | |
| 13 | + | core() *sessionCore | |
| 14 | + | snapshotView(viewOptions) sessionView | |
| 15 | + | statusSegments(includeTerminalTitle bool) statusSnapshot | |
| 16 | + | capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool) | |
| 17 | + | activateTargetLocked(tabIndex int) bool | |
| 18 | + | isProxy() bool | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // sessionCore is the state shared by every attachment-capable session. It is | |
| 22 | + | // embedded as the first field of session so the existing s.mu selector remains | |
| 23 | + | // the one lock guarding both attachment membership and local tab state. | |
| 24 | + | type sessionCore struct { | |
| 25 | + | mu sync.Mutex | |
| 26 | + | ||
| 27 | + | id domain.SessionID | |
| 28 | + | name string | |
| 29 | + | ephemeral bool | |
| 30 | + | caps sessionCapabilities | |
| 31 | + | client *attachedClient | |
| 32 | + | snatched map[*attachedClient]struct{} | |
| 33 | + | createdAt int64 | |
| 34 | + | incarnation domain.IncarnationID | |
| 35 | + | mruAt atomic.Uint64 | |
| 36 | + | coordinator atomic.Pointer[renderCoordinator] | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (s *session) core() *sessionCore { | |
| 40 | + | if s == nil { | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | return &s.sessionCore | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (s *session) isProxy() bool { return false } | |
| 47 | + | ||
| 48 | + | func (s *session) capturePrimary(ac *attachedClient, req primaryCaptureRequest) (*capturedRenderState, bool) { | |
| 49 | + | return captureLocalPrimaryRenderState(s, ac, req) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // activateTargetLocked validates and selects a local tab. Caller holds s.mu. | |
| 53 | + | func (s *session) activateTargetLocked(tabIndex int) bool { | |
| 54 | + | if tabIndex < 0 { | |
| 55 | + | return true | |
| 56 | + | } | |
| 57 | + | if tabIndex >= len(s.tabs) { | |
| 58 | + | return false | |
| 59 | + | } | |
| 60 | + | s.active = tabIndex | |
| 61 | + | return true | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // localSession narrows an attachment entry for operations that own local PTYs, | |
| 65 | + | // tabs, snapshots, or durable lifecycle state. | |
| 66 | + | func localSession(entry attachmentSession) (*session, bool) { | |
| 67 | + | sess, ok := entry.(*session) | |
| 68 | + | return sess, ok && sess != nil | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func localSessionsSnapshot(entries map[domain.SessionID]attachmentSession) []*session { | |
| 72 | + | locals := make([]*session, 0, len(entries)) | |
| 73 | + | for _, entry := range entries { | |
| 74 | + | if sess, ok := localSession(entry); ok { | |
| 75 | + | locals = append(locals, sess) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | return locals | |
| 79 | + | } | |
| @@ -0,0 +1,79 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | "sync/atomic" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // attachmentSession is the private attachment-facing session contract. Local | |||
| 11 | + | // process, tab, persistence, and teardown behavior remains on *session. | |||
| 12 | + | type attachmentSession interface { | |||
| 13 | + | core() *sessionCore | |||
| 14 | + | snapshotView(viewOptions) sessionView | |||
| 15 | + | statusSegments(includeTerminalTitle bool) statusSnapshot | |||
| 16 | + | capturePrimary(*attachedClient, primaryCaptureRequest) (*capturedRenderState, bool) | |||
| 17 | + | activateTargetLocked(tabIndex int) bool | |||
| 18 | + | isProxy() bool | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // sessionCore is the state shared by every attachment-capable session. It is | |||
| 22 | + | // embedded as the first field of session so the existing s.mu selector remains | |||
| 23 | + | // the one lock guarding both attachment membership and local tab state. | |||
| 24 | + | type sessionCore struct { | |||
| 25 | + | mu sync.Mutex | |||
| 26 | + | ||||
| 27 | + | id domain.SessionID | |||
| 28 | + | name string | |||
| 29 | + | ephemeral bool | |||
| 30 | + | caps sessionCapabilities | |||
| 31 | + | client *attachedClient | |||
| 32 | + | snatched map[*attachedClient]struct{} | |||
| 33 | + | createdAt int64 | |||
| 34 | + | incarnation domain.IncarnationID | |||
| 35 | + | mruAt atomic.Uint64 | |||
| 36 | + | coordinator atomic.Pointer[renderCoordinator] | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (s *session) core() *sessionCore { | |||
| 40 | + | if s == nil { | |||
| 41 | + | return nil | |||
| 42 | + | } | |||
| 43 | + | return &s.sessionCore | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (s *session) isProxy() bool { return false } | |||
| 47 | + | ||||
| 48 | + | func (s *session) capturePrimary(ac *attachedClient, req primaryCaptureRequest) (*capturedRenderState, bool) { | |||
| 49 | + | return captureLocalPrimaryRenderState(s, ac, req) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // activateTargetLocked validates and selects a local tab. Caller holds s.mu. | |||
| 53 | + | func (s *session) activateTargetLocked(tabIndex int) bool { | |||
| 54 | + | if tabIndex < 0 { | |||
| 55 | + | return true | |||
| 56 | + | } | |||
| 57 | + | if tabIndex >= len(s.tabs) { | |||
| 58 | + | return false | |||
| 59 | + | } | |||
| 60 | + | s.active = tabIndex | |||
| 61 | + | return true | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // localSession narrows an attachment entry for operations that own local PTYs, | |||
| 65 | + | // tabs, snapshots, or durable lifecycle state. | |||
| 66 | + | func localSession(entry attachmentSession) (*session, bool) { | |||
| 67 | + | sess, ok := entry.(*session) | |||
| 68 | + | return sess, ok && sess != nil | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func localSessionsSnapshot(entries map[domain.SessionID]attachmentSession) []*session { | |||
| 72 | + | locals := make([]*session, 0, len(entries)) | |||
| 73 | + | for _, entry := range entries { | |||
| 74 | + | if sess, ok := localSession(entry); ok { | |||
| 75 | + | locals = append(locals, sess) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | return locals | |||
| 79 | + | } | |||
A internal/usecase/daemon/session_core_test.go +179 -0
| @@ -0,0 +1,179 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) { | |
| 13 | + | sess := &session{} | |
| 14 | + | sess.id = domain.SessionID("local") | |
| 15 | + | sess.name = "work" | |
| 16 | + | sess.tabs = []*tab{{name: "shell"}} | |
| 17 | + | ac := &attachedClient{} | |
| 18 | + | ||
| 19 | + | core := sess.core() | |
| 20 | + | require.Same(t, &sess.sessionCore, core) | |
| 21 | + | require.Same(t, sess, mustLocalSession(t, attachmentSession(sess))) | |
| 22 | + | require.False(t, sess.isProxy()) | |
| 23 | + | require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities") | |
| 24 | + | ||
| 25 | + | core.mu.Lock() | |
| 26 | + | require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu") | |
| 27 | + | core.client = ac | |
| 28 | + | require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac)) | |
| 29 | + | sess.tabs = append(sess.tabs, &tab{name: "logs"}) | |
| 30 | + | require.Len(t, sess.tabs, 2) | |
| 31 | + | core.mu.Unlock() | |
| 32 | + | ||
| 33 | + | require.True(t, sess.mu.TryLock()) | |
| 34 | + | sess.mu.Unlock() | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.T) { | |
| 38 | + | sess := &session{ | |
| 39 | + | sessionCore: sessionCore{id: domain.SessionID("local"), name: "work"}, | |
| 40 | + | tabs: []*tab{{name: "shell"}}, | |
| 41 | + | } | |
| 42 | + | ac := &attachedClient{} | |
| 43 | + | ||
| 44 | + | // Mutate local-only tab state and shared attachment role state under the | |
| 45 | + | // promoted core mutex. snapshotView must observe the same mutex before it | |
| 46 | + | // can publish either field. | |
| 47 | + | sess.core().mu.Lock() | |
| 48 | + | sess.tabs = append(sess.tabs, &tab{name: "logs"}) | |
| 49 | + | sess.active = 1 | |
| 50 | + | sess.client = ac | |
| 51 | + | ||
| 52 | + | preCall := make(chan struct{}) | |
| 53 | + | allowCall := make(chan struct{}) | |
| 54 | + | snapshotted := make(chan sessionView, 1) | |
| 55 | + | go func() { | |
| 56 | + | // The barrier places the goroutine immediately before snapshotView, so | |
| 57 | + | // the lock is definitely held before the call can begin. | |
| 58 | + | close(preCall) | |
| 59 | + | <-allowCall | |
| 60 | + | snapshotted <- sess.snapshotView(viewOptions{}) | |
| 61 | + | }() | |
| 62 | + | awaitTestCompletion(t, preCall, "snapshot goroutine did not reach the pre-call barrier") | |
| 63 | + | close(allowCall) | |
| 64 | + | ||
| 65 | + | // Give the released call a bounded opportunity to acquire the mutex; it | |
| 66 | + | // must remain blocked until the owner releases it below. | |
| 67 | + | timer := time.NewTimer(50 * time.Millisecond) | |
| 68 | + | defer timer.Stop() | |
| 69 | + | select { | |
| 70 | + | case <-snapshotted: | |
| 71 | + | t.Fatal("snapshotView completed while sessionCore.mu guarded local session state") | |
| 72 | + | case <-timer.C: | |
| 73 | + | } | |
| 74 | + | require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac)) | |
| 75 | + | sess.core().mu.Unlock() | |
| 76 | + | ||
| 77 | + | view := awaitTestValue(t, snapshotted, "snapshotView did not complete after sessionCore.mu was released") | |
| 78 | + | require.True(t, view.attached) | |
| 79 | + | require.Equal(t, 2, view.tabCount) | |
| 80 | + | require.Equal(t, 1, view.active) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) { | |
| 84 | + | pty, releasePTY := newBlockingPTY(t) | |
| 85 | + | defer releasePTY() | |
| 86 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 87 | + | ||
| 88 | + | sess, err := createSessionForTest(d, "0", true, "/tmp", defaultSize, terminalEnv{}, nil) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | d.mu.Lock() | |
| 91 | + | registered := d.sessions[sess.id] | |
| 92 | + | d.mu.Unlock() | |
| 93 | + | require.Same(t, sess, registered) | |
| 94 | + | ||
| 95 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 96 | + | d.mu.Lock() | |
| 97 | + | _, live := d.sessions[sess.id] | |
| 98 | + | d.mu.Unlock() | |
| 99 | + | require.False(t, live, "killed local session must not remain in the live registry") | |
| 100 | + | d.sessWg.Wait() | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestAttachmentSessionRegistryUsesExactIdentity(t *testing.T) { | |
| 104 | + | d := &Daemon{sessions: make(map[domain.SessionID]attachmentSession)} | |
| 105 | + | first := &session{} | |
| 106 | + | first.id = domain.SessionID("same") | |
| 107 | + | second := &session{} | |
| 108 | + | second.id = first.id | |
| 109 | + | ||
| 110 | + | require.True(t, d.registerSessionLocked(first)) | |
| 111 | + | require.False(t, d.registerSessionLocked(first), "duplicate registration must be rejected") | |
| 112 | + | require.False(t, d.registerSessionLocked(second), "a different pointer with the same ID must be rejected") | |
| 113 | + | require.False(t, d.unregisterSessionLocked(second), "unregister must not remove a replacement pointer") | |
| 114 | + | require.Same(t, first, d.sessions[first.id]) | |
| 115 | + | require.True(t, d.unregisterSessionLocked(first)) | |
| 116 | + | require.NotContains(t, d.sessions, first.id) | |
| 117 | + | require.False(t, d.unregisterSessionLocked(first)) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) { | |
| 121 | + | first := &session{} | |
| 122 | + | first.id = domain.SessionID("a") | |
| 123 | + | second := &session{} | |
| 124 | + | second.id = domain.SessionID("z") | |
| 125 | + | ||
| 126 | + | unlock := lockAttachmentSessions(second, first) | |
| 127 | + | require.False(t, first.core().mu.TryLock()) | |
| 128 | + | require.False(t, second.core().mu.TryLock()) | |
| 129 | + | unlock() | |
| 130 | + | ||
| 131 | + | require.True(t, first.core().mu.TryLock()) | |
| 132 | + | first.core().mu.Unlock() | |
| 133 | + | require.True(t, second.core().mu.TryLock()) | |
| 134 | + | second.core().mu.Unlock() | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestProxiedInitialMetadataSkipsInvalidSnapshot(t *testing.T) { | |
| 138 | + | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |
| 139 | + | hello := ports.Hello{ | |
| 140 | + | Version: ports.ProtocolVersion, | |
| 141 | + | Intent: ports.IntentNew, | |
| 142 | + | Proxied: true, | |
| 143 | + | Name: "remote-work", | |
| 144 | + | Size: defaultSize, | |
| 145 | + | } | |
| 146 | + | tr := newWelcomeBlockingTransport(t) | |
| 147 | + | done := make(chan struct{}) | |
| 148 | + | go func() { | |
| 149 | + | d.handleHello(tr.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |
| 150 | + | close(done) | |
| 151 | + | }() | |
| 152 | + | ||
| 153 | + | awaitTestCompletion(t, tr.welcomeEntered, "proxied handshake did not send Welcome") | |
| 154 | + | sess := firstSession(d) | |
| 155 | + | require.NotNil(t, sess) | |
| 156 | + | sess.mu.Lock() | |
| 157 | + | sess.active = len(sess.tabs) | |
| 158 | + | sess.mu.Unlock() | |
| 159 | + | tr.release() | |
| 160 | + | awaitTestCompletion(t, done, "invalid metadata snapshot did not end the handshake") | |
| 161 | + | ||
| 162 | + | welcome := <-tr.sends | |
| 163 | + | require.Equal(t, ports.MsgWelcome, welcome.Type) | |
| 164 | + | select { | |
| 165 | + | case frame := <-tr.sends: | |
| 166 | + | t.Fatalf("invalid metadata snapshot was sent as frame type %d", frame.Type) | |
| 167 | + | default: | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |
| 171 | + | d.sessWg.Wait() | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func mustLocalSession(t *testing.T, entry attachmentSession) *session { | |
| 175 | + | t.Helper() | |
| 176 | + | sess, ok := localSession(entry) | |
| 177 | + | require.True(t, ok) | |
| 178 | + | return sess | |
| 179 | + | } | |
| @@ -0,0 +1,179 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestSessionCorePreservesLocalIdentityAndPromotedMutex(t *testing.T) { | |||
| 13 | + | sess := &session{} | |||
| 14 | + | sess.id = domain.SessionID("local") | |||
| 15 | + | sess.name = "work" | |||
| 16 | + | sess.tabs = []*tab{{name: "shell"}} | |||
| 17 | + | ac := &attachedClient{} | |||
| 18 | + | ||||
| 19 | + | core := sess.core() | |||
| 20 | + | require.Same(t, &sess.sessionCore, core) | |||
| 21 | + | require.Same(t, sess, mustLocalSession(t, attachmentSession(sess))) | |||
| 22 | + | require.False(t, sess.isProxy()) | |||
| 23 | + | require.Equal(t, sessionCapabilities{}, core.caps, "zero-value local sessions must retain all local capabilities") | |||
| 24 | + | ||||
| 25 | + | core.mu.Lock() | |||
| 26 | + | require.False(t, sess.mu.TryLock(), "sessionCore.mu must be the mutex promoted as session.mu") | |||
| 27 | + | core.client = ac | |||
| 28 | + | require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac)) | |||
| 29 | + | sess.tabs = append(sess.tabs, &tab{name: "logs"}) | |||
| 30 | + | require.Len(t, sess.tabs, 2) | |||
| 31 | + | core.mu.Unlock() | |||
| 32 | + | ||||
| 33 | + | require.True(t, sess.mu.TryLock()) | |||
| 34 | + | sess.mu.Unlock() | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestSessionSnapshotViewUsesPromotedMutexForLocalTabAndRoleState(t *testing.T) { | |||
| 38 | + | sess := &session{ | |||
| 39 | + | sessionCore: sessionCore{id: domain.SessionID("local"), name: "work"}, | |||
| 40 | + | tabs: []*tab{{name: "shell"}}, | |||
| 41 | + | } | |||
| 42 | + | ac := &attachedClient{} | |||
| 43 | + | ||||
| 44 | + | // Mutate local-only tab state and shared attachment role state under the | |||
| 45 | + | // promoted core mutex. snapshotView must observe the same mutex before it | |||
| 46 | + | // can publish either field. | |||
| 47 | + | sess.core().mu.Lock() | |||
| 48 | + | sess.tabs = append(sess.tabs, &tab{name: "logs"}) | |||
| 49 | + | sess.active = 1 | |||
| 50 | + | sess.client = ac | |||
| 51 | + | ||||
| 52 | + | preCall := make(chan struct{}) | |||
| 53 | + | allowCall := make(chan struct{}) | |||
| 54 | + | snapshotted := make(chan sessionView, 1) | |||
| 55 | + | go func() { | |||
| 56 | + | // The barrier places the goroutine immediately before snapshotView, so | |||
| 57 | + | // the lock is definitely held before the call can begin. | |||
| 58 | + | close(preCall) | |||
| 59 | + | <-allowCall | |||
| 60 | + | snapshotted <- sess.snapshotView(viewOptions{}) | |||
| 61 | + | }() | |||
| 62 | + | awaitTestCompletion(t, preCall, "snapshot goroutine did not reach the pre-call barrier") | |||
| 63 | + | close(allowCall) | |||
| 64 | + | ||||
| 65 | + | // Give the released call a bounded opportunity to acquire the mutex; it | |||
| 66 | + | // must remain blocked until the owner releases it below. | |||
| 67 | + | timer := time.NewTimer(50 * time.Millisecond) | |||
| 68 | + | defer timer.Stop() | |||
| 69 | + | select { | |||
| 70 | + | case <-snapshotted: | |||
| 71 | + | t.Fatal("snapshotView completed while sessionCore.mu guarded local session state") | |||
| 72 | + | case <-timer.C: | |||
| 73 | + | } | |||
| 74 | + | require.Equal(t, attachmentActive, sess.attachmentRoleLocked(ac)) | |||
| 75 | + | sess.core().mu.Unlock() | |||
| 76 | + | ||||
| 77 | + | view := awaitTestValue(t, snapshotted, "snapshotView did not complete after sessionCore.mu was released") | |||
| 78 | + | require.True(t, view.attached) | |||
| 79 | + | require.Equal(t, 2, view.tabCount) | |||
| 80 | + | require.Equal(t, 1, view.active) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestLocalCreateThenKillRemovesLiveRegistryEntry(t *testing.T) { | |||
| 84 | + | pty, releasePTY := newBlockingPTY(t) | |||
| 85 | + | defer releasePTY() | |||
| 86 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 87 | + | ||||
| 88 | + | sess, err := createSessionForTest(d, "0", true, "/tmp", defaultSize, terminalEnv{}, nil) | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | d.mu.Lock() | |||
| 91 | + | registered := d.sessions[sess.id] | |||
| 92 | + | d.mu.Unlock() | |||
| 93 | + | require.Same(t, sess, registered) | |||
| 94 | + | ||||
| 95 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |||
| 96 | + | d.mu.Lock() | |||
| 97 | + | _, live := d.sessions[sess.id] | |||
| 98 | + | d.mu.Unlock() | |||
| 99 | + | require.False(t, live, "killed local session must not remain in the live registry") | |||
| 100 | + | d.sessWg.Wait() | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func TestAttachmentSessionRegistryUsesExactIdentity(t *testing.T) { | |||
| 104 | + | d := &Daemon{sessions: make(map[domain.SessionID]attachmentSession)} | |||
| 105 | + | first := &session{} | |||
| 106 | + | first.id = domain.SessionID("same") | |||
| 107 | + | second := &session{} | |||
| 108 | + | second.id = first.id | |||
| 109 | + | ||||
| 110 | + | require.True(t, d.registerSessionLocked(first)) | |||
| 111 | + | require.False(t, d.registerSessionLocked(first), "duplicate registration must be rejected") | |||
| 112 | + | require.False(t, d.registerSessionLocked(second), "a different pointer with the same ID must be rejected") | |||
| 113 | + | require.False(t, d.unregisterSessionLocked(second), "unregister must not remove a replacement pointer") | |||
| 114 | + | require.Same(t, first, d.sessions[first.id]) | |||
| 115 | + | require.True(t, d.unregisterSessionLocked(first)) | |||
| 116 | + | require.NotContains(t, d.sessions, first.id) | |||
| 117 | + | require.False(t, d.unregisterSessionLocked(first)) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestSessionCoreLockOrderUsesImmutableIDs(t *testing.T) { | |||
| 121 | + | first := &session{} | |||
| 122 | + | first.id = domain.SessionID("a") | |||
| 123 | + | second := &session{} | |||
| 124 | + | second.id = domain.SessionID("z") | |||
| 125 | + | ||||
| 126 | + | unlock := lockAttachmentSessions(second, first) | |||
| 127 | + | require.False(t, first.core().mu.TryLock()) | |||
| 128 | + | require.False(t, second.core().mu.TryLock()) | |||
| 129 | + | unlock() | |||
| 130 | + | ||||
| 131 | + | require.True(t, first.core().mu.TryLock()) | |||
| 132 | + | first.core().mu.Unlock() | |||
| 133 | + | require.True(t, second.core().mu.TryLock()) | |||
| 134 | + | second.core().mu.Unlock() | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestProxiedInitialMetadataSkipsInvalidSnapshot(t *testing.T) { | |||
| 138 | + | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |||
| 139 | + | hello := ports.Hello{ | |||
| 140 | + | Version: ports.ProtocolVersion, | |||
| 141 | + | Intent: ports.IntentNew, | |||
| 142 | + | Proxied: true, | |||
| 143 | + | Name: "remote-work", | |||
| 144 | + | Size: defaultSize, | |||
| 145 | + | } | |||
| 146 | + | tr := newWelcomeBlockingTransport(t) | |||
| 147 | + | done := make(chan struct{}) | |||
| 148 | + | go func() { | |||
| 149 | + | d.handleHello(tr.tr, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |||
| 150 | + | close(done) | |||
| 151 | + | }() | |||
| 152 | + | ||||
| 153 | + | awaitTestCompletion(t, tr.welcomeEntered, "proxied handshake did not send Welcome") | |||
| 154 | + | sess := firstSession(d) | |||
| 155 | + | require.NotNil(t, sess) | |||
| 156 | + | sess.mu.Lock() | |||
| 157 | + | sess.active = len(sess.tabs) | |||
| 158 | + | sess.mu.Unlock() | |||
| 159 | + | tr.release() | |||
| 160 | + | awaitTestCompletion(t, done, "invalid metadata snapshot did not end the handshake") | |||
| 161 | + | ||||
| 162 | + | welcome := <-tr.sends | |||
| 163 | + | require.Equal(t, ports.MsgWelcome, welcome.Type) | |||
| 164 | + | select { | |||
| 165 | + | case frame := <-tr.sends: | |||
| 166 | + | t.Fatalf("invalid metadata snapshot was sent as frame type %d", frame.Type) | |||
| 167 | + | default: | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | |||
| 171 | + | d.sessWg.Wait() | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func mustLocalSession(t *testing.T, entry attachmentSession) *session { | |||
| 175 | + | t.Helper() | |||
| 176 | + | sess, ok := localSession(entry) | |||
| 177 | + | require.True(t, ok) | |||
| 178 | + | return sess | |||
| 179 | + | } | |||
M internal/usecase/daemon/session_move_locator_test.go +2 -3
| @@ -11,10 +11,9 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | func TestSessionMoveLocatorSynchronizesMutableNameRead(t *testing.T) { | |
| 14 | - | sess := &session{ | |
| 15 | - | id: domain.SessionID("session-id"), | |
| 14 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("session-id"), | |
| 16 | 15 | incarnation: domain.IncarnationID{1}, | |
| 17 | - | name: "initial", | |
| 16 | + | name: "initial"}, | |
| 18 | 17 | } | |
| 19 | 18 | start := make(chan struct{}) | |
| 20 | 19 | var wg sync.WaitGroup | |
| @@ -11,10 +11,9 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | func TestSessionMoveLocatorSynchronizesMutableNameRead(t *testing.T) { | 13 | func TestSessionMoveLocatorSynchronizesMutableNameRead(t *testing.T) { | ||
| 14 | - | sess := &session{ | 14 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("session-id"), |
| 15 | - | id: domain.SessionID("session-id"), | |||
| 16 | incarnation: domain.IncarnationID{1}, | 15 | incarnation: domain.IncarnationID{1}, | ||
| 17 | - | name: "initial", | 16 | + | name: "initial"}, |
| 18 | } | 17 | } | ||
| 19 | start := make(chan struct{}) | 18 | start := make(chan struct{}) | ||
| 20 | var wg sync.WaitGroup | 19 | var wg sync.WaitGroup | ||
M internal/usecase/daemon/session_purge_retry_test.go +1 -1
| @@ -38,7 +38,7 @@ func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *te | |||
| 38 | 38 | pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | |
| 39 | 39 | require.True(t, ok) | |
| 40 | 40 | pty.EXPECT().Close().Return(nil).Once() | |
| 41 | - | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |
| 41 | + | d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess} | |
| 42 | 42 | ||
| 43 | 43 | require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | |
| 44 | 44 | require.Equal(t, []string{"delete incarnation"}, repository.calls) | |
| @@ -38,7 +38,7 @@ func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *te | |||||
| 38 | pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | 38 | pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY) | ||
| 39 | require.True(t, ok) | 39 | require.True(t, ok) | ||
| 40 | pty.EXPECT().Close().Return(nil).Once() | 40 | pty.EXPECT().Close().Return(nil).Once() | ||
| 41 | - | d.sessions = map[domain.SessionID]*session{sess.id: sess} | 41 | + | d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess} |
| 42 | 42 | ||||
| 43 | require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | 43 | require.Error(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | ||
| 44 | require.Equal(t, []string{"delete incarnation"}, repository.calls) | 44 | require.Equal(t, []string{"delete incarnation"}, repository.calls) | ||
M internal/usecase/daemon/session_rename_purge_test.go +1 -1
| @@ -16,7 +16,7 @@ func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) { | |||
| 16 | 16 | store, state := newMockStore(t) | |
| 17 | 17 | WithStore(t, store)(d) | |
| 18 | 18 | sess := newSnapshotTestSession(t, "old", false, "/work") | |
| 19 | - | d.sessions = map[domain.SessionID]*session{sess.id: sess} | |
| 19 | + | d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess} | |
| 20 | 20 | sess.mu.Lock() | |
| 21 | 21 | record := sess.persistRecordLocked(1) | |
| 22 | 22 | sess.mu.Unlock() | |
| @@ -16,7 +16,7 @@ func TestRenamePreservesIncarnationSnapshotSources(t *testing.T) { | |||||
| 16 | store, state := newMockStore(t) | 16 | store, state := newMockStore(t) | ||
| 17 | WithStore(t, store)(d) | 17 | WithStore(t, store)(d) | ||
| 18 | sess := newSnapshotTestSession(t, "old", false, "/work") | 18 | sess := newSnapshotTestSession(t, "old", false, "/work") | ||
| 19 | - | d.sessions = map[domain.SessionID]*session{sess.id: sess} | 19 | + | d.sessions = map[domain.SessionID]attachmentSession{sess.id: sess} |
| 20 | sess.mu.Lock() | 20 | sess.mu.Lock() | ||
| 21 | record := sess.persistRecordLocked(1) | 21 | record := sess.persistRecordLocked(1) | ||
| 22 | sess.mu.Unlock() | 22 | sess.mu.Unlock() | ||
M internal/usecase/daemon/session_test.go +8 -8
| @@ -168,7 +168,7 @@ func TestHandshakeOldHelloLayoutReportsVersionMismatch(t *testing.T) { | |||
| 168 | 168 | func TestHandshakeNameTaken(t *testing.T) { | |
| 169 | 169 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 170 | 170 | // Pre-populate a bare session (no goroutines) to collide with. | |
| 171 | - | d.sessions[domain.SessionID("x")] = &session{id: "x", name: "work"} | |
| 171 | + | d.sessions[domain.SessionID("x")] = &session{sessionCore: sessionCore{id: "x", name: "work"}} | |
| 172 | 172 | ||
| 173 | 173 | tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24})) | |
| 174 | 174 | d.handleConn(tr) | |
| @@ -272,8 +272,8 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||
| 272 | 272 | func TestEphemeralNumberingReuse(t *testing.T) { | |
| 273 | 273 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 274 | 274 | // Emulate two ephemeral sessions "0" and "1". | |
| 275 | - | d.sessions["a"] = &session{id: "a", name: "0"} | |
| 276 | - | d.sessions["b"] = &session{id: "b", name: "1"} | |
| 275 | + | d.sessions["a"] = &session{sessionCore: sessionCore{id: "a", name: "0"}} | |
| 276 | + | d.sessions["b"] = &session{sessionCore: sessionCore{id: "b", name: "1"}} | |
| 277 | 277 | require.Equal(t, "2", d.allocEphemeralNameLocked()) | |
| 278 | 278 | ||
| 279 | 279 | // Kill "0": the freed number is reused before "2". | |
| @@ -890,14 +890,14 @@ func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) { | |||
| 890 | 890 | store, state := newMockStore(t) | |
| 891 | 891 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | |
| 892 | 892 | WithStore(t, store)(d) | |
| 893 | - | named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}} | |
| 893 | + | named := &session{sessionCore: sessionCore{name: "work", createdAt: 1, incarnation: domain.IncarnationID{1}}, tabs: []*tab{{}}} | |
| 894 | 894 | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1})) | |
| 895 | 895 | ||
| 896 | 896 | d.touchMRU(named) | |
| 897 | 897 | require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq) | |
| 898 | 898 | setsAfterNamed := state.sets | |
| 899 | 899 | ||
| 900 | - | ephemeral := &session{name: "0", ephemeral: true, tabs: []*tab{{}}} | |
| 900 | + | ephemeral := &session{sessionCore: sessionCore{name: "0", ephemeral: true}, tabs: []*tab{{}}} | |
| 901 | 901 | d.touchMRU(ephemeral) | |
| 902 | 902 | require.False(t, state.has("0")) | |
| 903 | 903 | require.Equal(t, setsAfterNamed, state.sets) | |
| @@ -1609,7 +1609,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||
| 1609 | 1609 | ac.sendMu.Unlock() | |
| 1610 | 1610 | ||
| 1611 | 1611 | require.NoError(t, d.createSessionAndSwitch(sess, ac, "next")) | |
| 1612 | - | got := ac.sess.Get() | |
| 1612 | + | got := ac.currentSession() | |
| 1613 | 1613 | require.NotNil(t, got) | |
| 1614 | 1614 | sourceCoordinator.mu.Lock() | |
| 1615 | 1615 | require.NotNil(t, sourceCoordinator.lease) | |
| @@ -1839,7 +1839,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||
| 1839 | 1839 | t.Run("active replacement is rejected", func(t *testing.T) { | |
| 1840 | 1840 | d, from, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 1841 | 1841 | defer releaseAll(releases) | |
| 1842 | - | target := d.sessions["recent"] | |
| 1842 | + | target := mustLocalSession(t, d.sessions["recent"]) | |
| 1843 | 1843 | target.createdAt = 22 | |
| 1844 | 1844 | expected := int64(21) | |
| 1845 | 1845 | ||
| @@ -1865,7 +1865,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||
| 1865 | 1865 | t.Run("stopped target that became active switches same lifecycle", func(t *testing.T) { | |
| 1866 | 1866 | d, from, ac, _, releases := newRecentNavigationTestSessions(t) | |
| 1867 | 1867 | defer releaseAll(releases) | |
| 1868 | - | target := d.sessions["recent"] | |
| 1868 | + | target := mustLocalSession(t, d.sessions["recent"]) | |
| 1869 | 1869 | target.createdAt = 41 | |
| 1870 | 1870 | expected := int64(41) | |
| 1871 | 1871 | ||
| @@ -168,7 +168,7 @@ func TestHandshakeOldHelloLayoutReportsVersionMismatch(t *testing.T) { | |||||
| 168 | func TestHandshakeNameTaken(t *testing.T) { | 168 | func TestHandshakeNameTaken(t *testing.T) { | ||
| 169 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 169 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 170 | // Pre-populate a bare session (no goroutines) to collide with. | 170 | // Pre-populate a bare session (no goroutines) to collide with. | ||
| 171 | - | d.sessions[domain.SessionID("x")] = &session{id: "x", name: "work"} | 171 | + | d.sessions[domain.SessionID("x")] = &session{sessionCore: sessionCore{id: "x", name: "work"}} |
| 172 | 172 | ||||
| 173 | tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24})) | 173 | tr, sends, _ := newConn(t, mustHello(ports.IntentNew, "work", domain.Size{Cols: 80, Rows: 24})) | ||
| 174 | d.handleConn(tr) | 174 | d.handleConn(tr) | ||
| @@ -272,8 +272,8 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) { | |||||
| 272 | func TestEphemeralNumberingReuse(t *testing.T) { | 272 | func TestEphemeralNumberingReuse(t *testing.T) { | ||
| 273 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | 273 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 274 | // Emulate two ephemeral sessions "0" and "1". | 274 | // Emulate two ephemeral sessions "0" and "1". | ||
| 275 | - | d.sessions["a"] = &session{id: "a", name: "0"} | 275 | + | d.sessions["a"] = &session{sessionCore: sessionCore{id: "a", name: "0"}} |
| 276 | - | d.sessions["b"] = &session{id: "b", name: "1"} | 276 | + | d.sessions["b"] = &session{sessionCore: sessionCore{id: "b", name: "1"}} |
| 277 | require.Equal(t, "2", d.allocEphemeralNameLocked()) | 277 | require.Equal(t, "2", d.allocEphemeralNameLocked()) | ||
| 278 | 278 | ||||
| 279 | // Kill "0": the freed number is reused before "2". | 279 | // Kill "0": the freed number is reused before "2". | ||
| @@ -890,14 +890,14 @@ func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) { | |||||
| 890 | store, state := newMockStore(t) | 890 | store, state := newMockStore(t) | ||
| 891 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | 891 | d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{}) | ||
| 892 | WithStore(t, store)(d) | 892 | WithStore(t, store)(d) | ||
| 893 | - | named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}} | 893 | + | named := &session{sessionCore: sessionCore{name: "work", createdAt: 1, incarnation: domain.IncarnationID{1}}, tabs: []*tab{{}}} |
| 894 | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1})) | 894 | require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1})) | ||
| 895 | 895 | ||||
| 896 | d.touchMRU(named) | 896 | d.touchMRU(named) | ||
| 897 | require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq) | 897 | require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq) | ||
| 898 | setsAfterNamed := state.sets | 898 | setsAfterNamed := state.sets | ||
| 899 | 899 | ||||
| 900 | - | ephemeral := &session{name: "0", ephemeral: true, tabs: []*tab{{}}} | 900 | + | ephemeral := &session{sessionCore: sessionCore{name: "0", ephemeral: true}, tabs: []*tab{{}}} |
| 901 | d.touchMRU(ephemeral) | 901 | d.touchMRU(ephemeral) | ||
| 902 | require.False(t, state.has("0")) | 902 | require.False(t, state.has("0")) | ||
| 903 | require.Equal(t, setsAfterNamed, state.sets) | 903 | require.Equal(t, setsAfterNamed, state.sets) | ||
| @@ -1609,7 +1609,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) { | |||||
| 1609 | ac.sendMu.Unlock() | 1609 | ac.sendMu.Unlock() | ||
| 1610 | 1610 | ||||
| 1611 | require.NoError(t, d.createSessionAndSwitch(sess, ac, "next")) | 1611 | require.NoError(t, d.createSessionAndSwitch(sess, ac, "next")) | ||
| 1612 | - | got := ac.sess.Get() | 1612 | + | got := ac.currentSession() |
| 1613 | require.NotNil(t, got) | 1613 | require.NotNil(t, got) | ||
| 1614 | sourceCoordinator.mu.Lock() | 1614 | sourceCoordinator.mu.Lock() | ||
| 1615 | require.NotNil(t, sourceCoordinator.lease) | 1615 | require.NotNil(t, sourceCoordinator.lease) | ||
| @@ -1839,7 +1839,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||||
| 1839 | t.Run("active replacement is rejected", func(t *testing.T) { | 1839 | t.Run("active replacement is rejected", func(t *testing.T) { | ||
| 1840 | d, from, ac, _, releases := newRecentNavigationTestSessions(t) | 1840 | d, from, ac, _, releases := newRecentNavigationTestSessions(t) | ||
| 1841 | defer releaseAll(releases) | 1841 | defer releaseAll(releases) | ||
| 1842 | - | target := d.sessions["recent"] | 1842 | + | target := mustLocalSession(t, d.sessions["recent"]) |
| 1843 | target.createdAt = 22 | 1843 | target.createdAt = 22 | ||
| 1844 | expected := int64(21) | 1844 | expected := int64(21) | ||
| 1845 | 1845 | ||||
| @@ -1865,7 +1865,7 @@ func TestLifecycleExpectedTargetChecksAreAtomicAcrossStateTransitions(t *testing | |||||
| 1865 | t.Run("stopped target that became active switches same lifecycle", func(t *testing.T) { | 1865 | t.Run("stopped target that became active switches same lifecycle", func(t *testing.T) { | ||
| 1866 | d, from, ac, _, releases := newRecentNavigationTestSessions(t) | 1866 | d, from, ac, _, releases := newRecentNavigationTestSessions(t) | ||
| 1867 | defer releaseAll(releases) | 1867 | defer releaseAll(releases) | ||
| 1868 | - | target := d.sessions["recent"] | 1868 | + | target := mustLocalSession(t, d.sessions["recent"]) |
| 1869 | target.createdAt = 41 | 1869 | target.createdAt = 41 | ||
| 1870 | expected := int64(41) | 1870 | expected := int64(41) | ||
| 1871 | 1871 | ||||
M internal/usecase/daemon/session_view_test.go +7 -9
| @@ -17,13 +17,13 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) { | |||
| 17 | 17 | { | |
| 18 | 18 | name: "named session with attention tab", | |
| 19 | 19 | build: func() *session { | |
| 20 | - | s := &session{ | |
| 21 | - | id: domain.SessionID("s1"), | |
| 20 | + | s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), | |
| 22 | 21 | incarnation: domain.IncarnationID{1}, | |
| 23 | 22 | name: "alpha", | |
| 24 | 23 | createdAt: 42, | |
| 25 | - | active: 1, | |
| 26 | - | client: &attachedClient{}, | |
| 24 | + | ||
| 25 | + | client: &attachedClient{}}, active: 1, | |
| 26 | + | ||
| 27 | 27 | tabs: []*tab{ | |
| 28 | 28 | {stableID: "t1", name: "build"}, | |
| 29 | 29 | {stableID: "t2", name: "logs", attention: true}, | |
| @@ -52,7 +52,7 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) { | |||
| 52 | 52 | { | |
| 53 | 53 | name: "ephemeral detached session without tabs", | |
| 54 | 54 | build: func() *session { | |
| 55 | - | return &session{id: domain.SessionID("s2"), name: "9", ephemeral: true} | |
| 55 | + | return &session{sessionCore: sessionCore{id: domain.SessionID("s2"), name: "9", ephemeral: true}} | |
| 56 | 56 | }, | |
| 57 | 57 | opts: viewOptions{}, | |
| 58 | 58 | want: sessionView{ | |
| @@ -74,10 +74,8 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) { | |||
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | func TestSnapshotViewFocusedTitlesOption(t *testing.T) { | |
| 77 | - | s := &session{ | |
| 78 | - | id: domain.SessionID("s1"), | |
| 79 | - | name: "alpha", | |
| 80 | - | tabs: []*tab{{stableID: "t1", name: "build"}}, | |
| 77 | + | s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), | |
| 78 | + | name: "alpha"}, tabs: []*tab{{stableID: "t1", name: "build"}}, | |
| 81 | 79 | } | |
| 82 | 80 | off := s.snapshotView(viewOptions{tabDetails: true}) | |
| 83 | 81 | if off.tabs[0].focusedTitle != "" { | |
| @@ -17,13 +17,13 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) { | |||||
| 17 | { | 17 | { | ||
| 18 | name: "named session with attention tab", | 18 | name: "named session with attention tab", | ||
| 19 | build: func() *session { | 19 | build: func() *session { | ||
| 20 | - | s := &session{ | 20 | + | s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), |
| 21 | - | id: domain.SessionID("s1"), | |||
| 22 | incarnation: domain.IncarnationID{1}, | 21 | incarnation: domain.IncarnationID{1}, | ||
| 23 | name: "alpha", | 22 | name: "alpha", | ||
| 24 | createdAt: 42, | 23 | createdAt: 42, | ||
| 25 | - | active: 1, | 24 | + | |
| 26 | - | client: &attachedClient{}, | 25 | + | client: &attachedClient{}}, active: 1, |
| 26 | + | ||||
| 27 | tabs: []*tab{ | 27 | tabs: []*tab{ | ||
| 28 | {stableID: "t1", name: "build"}, | 28 | {stableID: "t1", name: "build"}, | ||
| 29 | {stableID: "t2", name: "logs", attention: true}, | 29 | {stableID: "t2", name: "logs", attention: true}, | ||
| @@ -52,7 +52,7 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) { | |||||
| 52 | { | 52 | { | ||
| 53 | name: "ephemeral detached session without tabs", | 53 | name: "ephemeral detached session without tabs", | ||
| 54 | build: func() *session { | 54 | build: func() *session { | ||
| 55 | - | return &session{id: domain.SessionID("s2"), name: "9", ephemeral: true} | 55 | + | return &session{sessionCore: sessionCore{id: domain.SessionID("s2"), name: "9", ephemeral: true}} |
| 56 | }, | 56 | }, | ||
| 57 | opts: viewOptions{}, | 57 | opts: viewOptions{}, | ||
| 58 | want: sessionView{ | 58 | want: sessionView{ | ||
| @@ -74,10 +74,8 @@ func TestSnapshotViewCapturesSessionFields(t *testing.T) { | |||||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | func TestSnapshotViewFocusedTitlesOption(t *testing.T) { | 76 | func TestSnapshotViewFocusedTitlesOption(t *testing.T) { | ||
| 77 | - | s := &session{ | 77 | + | s := &session{sessionCore: sessionCore{id: domain.SessionID("s1"), |
| 78 | - | id: domain.SessionID("s1"), | 78 | + | name: "alpha"}, tabs: []*tab{{stableID: "t1", name: "build"}}, |
| 79 | - | name: "alpha", | |||
| 80 | - | tabs: []*tab{{stableID: "t1", name: "build"}}, | |||
| 81 | } | 79 | } | ||
| 82 | off := s.snapshotView(viewOptions{tabDetails: true}) | 80 | off := s.snapshotView(viewOptions{tabDetails: true}) | ||
| 83 | if off.tabs[0].focusedTitle != "" { | 81 | if off.tabs[0].focusedTitle != "" { | ||
M internal/usecase/daemon/snapshot_restore.go +7 -2
| @@ -290,7 +290,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable | |||
| 290 | 290 | } | |
| 291 | 291 | ||
| 292 | 292 | func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session { | |
| 293 | - | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), createdAt: int64(snap.CreatedAt), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | |
| 293 | + | sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | |
| 294 | 294 | // Restored tabs remain private until persistAndRegisterRestoredSession. | |
| 295 | 295 | // Initialize owners now so registration and reader startup cannot expose an | |
| 296 | 296 | // ownerless pane. | |
| @@ -350,8 +350,13 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se | |||
| 350 | 350 | sess.mu.Lock() | |
| 351 | 351 | sess.restoreDone = stopped.restoreDone | |
| 352 | 352 | sess.mu.Unlock() | |
| 353 | + | if !d.registerSessionLocked(sess) { | |
| 354 | + | return false, errors.New("daemon: session registry rejected restored session") | |
| 355 | + | } | |
| 356 | + | // The stopped entry is the durable authority and restore barrier until the | |
| 357 | + | // runtime identity has actually been published. A registry rejection must | |
| 358 | + | // leave that exact entry available for attach/recovery retries. | |
| 353 | 359 | delete(d.stopped, sess.name) | |
| 354 | - | d.sessions[sess.id] = sess | |
| 355 | 360 | sess.snapDirty.Store(false) | |
| 356 | 361 | return true, nil | |
| 357 | 362 | } | |
| @@ -290,7 +290,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable | |||||
| 290 | } | 290 | } | ||
| 291 | 291 | ||||
| 292 | func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session { | 292 | func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session { | ||
| 293 | - | sess := &session{name: snap.Name, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), createdAt: int64(snap.CreatedAt), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} | 293 | + | sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, active: int(snap.Active), terminal: terminalEnv{}, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)} |
| 294 | // Restored tabs remain private until persistAndRegisterRestoredSession. | 294 | // Restored tabs remain private until persistAndRegisterRestoredSession. | ||
| 295 | // Initialize owners now so registration and reader startup cannot expose an | 295 | // Initialize owners now so registration and reader startup cannot expose an | ||
| 296 | // ownerless pane. | 296 | // ownerless pane. | ||
| @@ -350,8 +350,13 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se | |||||
| 350 | sess.mu.Lock() | 350 | sess.mu.Lock() | ||
| 351 | sess.restoreDone = stopped.restoreDone | 351 | sess.restoreDone = stopped.restoreDone | ||
| 352 | sess.mu.Unlock() | 352 | sess.mu.Unlock() | ||
| 353 | + | if !d.registerSessionLocked(sess) { | |||
| 354 | + | return false, errors.New("daemon: session registry rejected restored session") | |||
| 355 | + | } | |||
| 356 | + | // The stopped entry is the durable authority and restore barrier until the | |||
| 357 | + | // runtime identity has actually been published. A registry rejection must | |||
| 358 | + | // leave that exact entry available for attach/recovery retries. | |||
| 353 | delete(d.stopped, sess.name) | 359 | delete(d.stopped, sess.name) | ||
| 354 | - | d.sessions[sess.id] = sess | |||
| 355 | sess.snapDirty.Store(false) | 360 | sess.snapDirty.Store(false) | ||
| 356 | return true, nil | 361 | return true, nil | ||
| 357 | } | 362 | } | ||
M internal/usecase/daemon/snapshot_test.go +1 -1
| @@ -89,7 +89,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||
| 89 | 89 | p := tb.panes["pane-1"] | |
| 90 | 90 | p.screen.Write([]byte("hello")) | |
| 91 | 91 | appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) | |
| 92 | - | sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42} | |
| 92 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, createdAt: 42}, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd} | |
| 93 | 93 | if !ephemeral { | |
| 94 | 94 | sess.incarnation = domain.IncarnationID{1} | |
| 95 | 95 | } | |
| @@ -89,7 +89,7 @@ func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd strin | |||||
| 89 | p := tb.panes["pane-1"] | 89 | p := tb.panes["pane-1"] | ||
| 90 | p.screen.Write([]byte("hello")) | 90 | p.screen.Write([]byte("hello")) | ||
| 91 | appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) | 91 | appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}}) | ||
| 92 | - | sess := &session{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd, createdAt: 42} | 92 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, createdAt: 42}, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, active: 0, cwd: cwd} |
| 93 | if !ephemeral { | 93 | if !ephemeral { | ||
| 94 | sess.incarnation = domain.IncarnationID{1} | 94 | sess.incarnation = domain.IncarnationID{1} | ||
| 95 | } | 95 | } | ||
M internal/usecase/daemon/snatched_cleanup.go +24 -21
| @@ -15,16 +15,17 @@ func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal b | |||
| 15 | 15 | frozen := freezeRoleEffectGates(token.ac) | |
| 16 | 16 | defer frozen.unfreeze() | |
| 17 | 17 | d.notices.routingMu.Lock() | |
| 18 | - | token.sess.mu.Lock() | |
| 19 | - | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 18 | + | core := token.sess.core() | |
| 19 | + | core.mu.Lock() | |
| 20 | + | current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched && | |
| 20 | 21 | token.ac.roleGeneration.Load() == token.generation && | |
| 21 | 22 | token.ac.transportSnapshotCurrent(token.transport) | |
| 22 | 23 | if current { | |
| 23 | - | delete(token.sess.snatched, token.ac) | |
| 24 | + | delete(core.snatched, token.ac) | |
| 24 | 25 | token.ac.roleGeneration.Add(1) | |
| 25 | 26 | token.ac.invalidateFrozenRoleCapability() | |
| 26 | 27 | } | |
| 27 | - | token.sess.mu.Unlock() | |
| 28 | + | core.mu.Unlock() | |
| 28 | 29 | d.notices.routingMu.Unlock() | |
| 29 | 30 | if !current { | |
| 30 | 31 | return false | |
| @@ -50,20 +51,21 @@ func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal b | |||
| 50 | 51 | // parkSnatchedAttachment removes only the exact lost snatched incarnation and | |
| 51 | 52 | // retains its resume identity for a role-preserving reconnect. | |
| 52 | 53 | func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool { | |
| 53 | - | if token.ac == nil || !token.ac.resumeCapable { | |
| 54 | + | sess, ok := localSession(token.sess) | |
| 55 | + | if !ok || token.ac == nil || !token.ac.resumeCapable { | |
| 54 | 56 | return false | |
| 55 | 57 | } | |
| 56 | 58 | // Advertise parking before unroute so IntentResume never observes both the | |
| 57 | 59 | // snatched seat and parking/parked registries empty for a still-valid token. | |
| 58 | - | parkingToken := d.markParkingInFlight(token.sess, token.ac) | |
| 60 | + | parkingToken := d.markParkingInFlight(sess, token.ac) | |
| 59 | 61 | if !d.unrouteSnatchedAttachment(token, false) { | |
| 60 | - | d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken) | |
| 62 | + | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) | |
| 61 | 63 | return false | |
| 62 | 64 | } | |
| 63 | 65 | if d.afterSnatchedUnrouteBeforePark != nil { | |
| 64 | 66 | d.afterSnatchedUnrouteBeforePark() | |
| 65 | 67 | } | |
| 66 | - | if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | |
| 68 | + | if d.parkAttachmentAs(sess, token.ac, attachmentSnatched) { | |
| 67 | 69 | captured := token.transport.transport | |
| 68 | 70 | _ = token.ac.revokeTransport(captured) | |
| 69 | 71 | _ = token.ac.closeCapturedTransport(captured) | |
| @@ -90,18 +92,19 @@ func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool { | |||
| 90 | 92 | // published snatched incarnation. Attachment-local cleanup runs without routing | |
| 91 | 93 | // or session locks while the terminal capability remains frozen. | |
| 92 | 94 | func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool { | |
| 93 | - | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | |
| 95 | + | sess, ok := localSession(token.sess) | |
| 96 | + | if !ok || token.ac == nil || token.transport.transport == nil { | |
| 94 | 97 | return false | |
| 95 | 98 | } | |
| 96 | 99 | frozen := freezeRoleEffectGates(token.ac) | |
| 97 | 100 | defer frozen.unfreeze() | |
| 98 | 101 | ||
| 99 | 102 | d.notices.routingMu.Lock() | |
| 100 | - | token.sess.mu.Lock() | |
| 101 | - | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 102 | - | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 103 | + | sess.mu.Lock() | |
| 104 | + | current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched && | |
| 105 | + | token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 103 | 106 | token.ac.transportSnapshotCurrent(token.transport) | |
| 104 | - | token.sess.mu.Unlock() | |
| 107 | + | sess.mu.Unlock() | |
| 105 | 108 | d.notices.routingMu.Unlock() | |
| 106 | 109 | if !current { | |
| 107 | 110 | return false | |
| @@ -111,7 +114,7 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) | |||
| 111 | 114 | // the unroute→park gap for a still-valid snatched credential. | |
| 112 | 115 | var parkingToken uint64 | |
| 113 | 116 | if token.ac.resumeCapable { | |
| 114 | - | parkingToken = d.markParkingInFlight(token.sess, token.ac) | |
| 117 | + | parkingToken = d.markParkingInFlight(sess, token.ac) | |
| 115 | 118 | } | |
| 116 | 119 | ||
| 117 | 120 | // The frozen gate prevents a promotion from publishing between overlay | |
| @@ -119,19 +122,19 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) | |||
| 119 | 122 | d.clearForSnatch(token) | |
| 120 | 123 | ||
| 121 | 124 | d.notices.routingMu.Lock() | |
| 122 | - | token.sess.mu.Lock() | |
| 123 | - | current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | |
| 124 | - | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 125 | + | sess.mu.Lock() | |
| 126 | + | current = attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched && | |
| 127 | + | token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | |
| 125 | 128 | token.ac.transportSnapshotCurrent(token.transport) | |
| 126 | 129 | if current { | |
| 127 | - | delete(token.sess.snatched, token.ac) | |
| 130 | + | delete(sess.snatched, token.ac) | |
| 128 | 131 | token.ac.roleGeneration.Add(1) | |
| 129 | 132 | token.ac.invalidateFrozenRoleCapability() | |
| 130 | 133 | } | |
| 131 | - | token.sess.mu.Unlock() | |
| 134 | + | sess.mu.Unlock() | |
| 132 | 135 | d.notices.routingMu.Unlock() | |
| 133 | 136 | if !current { | |
| 134 | - | d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken) | |
| 137 | + | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) | |
| 135 | 138 | return false | |
| 136 | 139 | } | |
| 137 | 140 | ||
| @@ -139,7 +142,7 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) | |||
| 139 | 142 | if d.afterSnatchedUnrouteBeforePark != nil { | |
| 140 | 143 | d.afterSnatchedUnrouteBeforePark() | |
| 141 | 144 | } | |
| 142 | - | if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | |
| 145 | + | if token.ac.resumeCapable && d.parkAttachmentAs(sess, token.ac, attachmentSnatched) { | |
| 143 | 146 | captured := token.transport.transport | |
| 144 | 147 | _ = token.ac.revokeTransport(captured) | |
| 145 | 148 | _ = token.ac.closeCapturedTransport(captured) | |
| @@ -15,16 +15,17 @@ func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal b | |||||
| 15 | frozen := freezeRoleEffectGates(token.ac) | 15 | frozen := freezeRoleEffectGates(token.ac) | ||
| 16 | defer frozen.unfreeze() | 16 | defer frozen.unfreeze() | ||
| 17 | d.notices.routingMu.Lock() | 17 | d.notices.routingMu.Lock() | ||
| 18 | - | token.sess.mu.Lock() | 18 | + | core := token.sess.core() |
| 19 | - | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | 19 | + | core.mu.Lock() |
| 20 | + | current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched && | |||
| 20 | token.ac.roleGeneration.Load() == token.generation && | 21 | token.ac.roleGeneration.Load() == token.generation && | ||
| 21 | token.ac.transportSnapshotCurrent(token.transport) | 22 | token.ac.transportSnapshotCurrent(token.transport) | ||
| 22 | if current { | 23 | if current { | ||
| 23 | - | delete(token.sess.snatched, token.ac) | 24 | + | delete(core.snatched, token.ac) |
| 24 | token.ac.roleGeneration.Add(1) | 25 | token.ac.roleGeneration.Add(1) | ||
| 25 | token.ac.invalidateFrozenRoleCapability() | 26 | token.ac.invalidateFrozenRoleCapability() | ||
| 26 | } | 27 | } | ||
| 27 | - | token.sess.mu.Unlock() | 28 | + | core.mu.Unlock() |
| 28 | d.notices.routingMu.Unlock() | 29 | d.notices.routingMu.Unlock() | ||
| 29 | if !current { | 30 | if !current { | ||
| 30 | return false | 31 | return false | ||
| @@ -50,20 +51,21 @@ func (d *Daemon) unrouteSnatchedAttachment(token attachmentRoleToken, terminal b | |||||
| 50 | // parkSnatchedAttachment removes only the exact lost snatched incarnation and | 51 | // parkSnatchedAttachment removes only the exact lost snatched incarnation and | ||
| 51 | // retains its resume identity for a role-preserving reconnect. | 52 | // retains its resume identity for a role-preserving reconnect. | ||
| 52 | func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool { | 53 | func (d *Daemon) parkSnatchedAttachment(token attachmentRoleToken) bool { | ||
| 53 | - | if token.ac == nil || !token.ac.resumeCapable { | 54 | + | sess, ok := localSession(token.sess) |
| 55 | + | if !ok || token.ac == nil || !token.ac.resumeCapable { | |||
| 54 | return false | 56 | return false | ||
| 55 | } | 57 | } | ||
| 56 | // Advertise parking before unroute so IntentResume never observes both the | 58 | // Advertise parking before unroute so IntentResume never observes both the | ||
| 57 | // snatched seat and parking/parked registries empty for a still-valid token. | 59 | // snatched seat and parking/parked registries empty for a still-valid token. | ||
| 58 | - | parkingToken := d.markParkingInFlight(token.sess, token.ac) | 60 | + | parkingToken := d.markParkingInFlight(sess, token.ac) |
| 59 | if !d.unrouteSnatchedAttachment(token, false) { | 61 | if !d.unrouteSnatchedAttachment(token, false) { | ||
| 60 | - | d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken) | 62 | + | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) |
| 61 | return false | 63 | return false | ||
| 62 | } | 64 | } | ||
| 63 | if d.afterSnatchedUnrouteBeforePark != nil { | 65 | if d.afterSnatchedUnrouteBeforePark != nil { | ||
| 64 | d.afterSnatchedUnrouteBeforePark() | 66 | d.afterSnatchedUnrouteBeforePark() | ||
| 65 | } | 67 | } | ||
| 66 | - | if d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | 68 | + | if d.parkAttachmentAs(sess, token.ac, attachmentSnatched) { |
| 67 | captured := token.transport.transport | 69 | captured := token.transport.transport | ||
| 68 | _ = token.ac.revokeTransport(captured) | 70 | _ = token.ac.revokeTransport(captured) | ||
| 69 | _ = token.ac.closeCapturedTransport(captured) | 71 | _ = token.ac.closeCapturedTransport(captured) | ||
| @@ -90,18 +92,19 @@ func (d *Daemon) parkOrDropSnatchedAttachment(token attachmentRoleToken) bool { | |||||
| 90 | // published snatched incarnation. Attachment-local cleanup runs without routing | 92 | // published snatched incarnation. Attachment-local cleanup runs without routing | ||
| 91 | // or session locks while the terminal capability remains frozen. | 93 | // or session locks while the terminal capability remains frozen. | ||
| 92 | func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool { | 94 | func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) bool { | ||
| 93 | - | if token.sess == nil || token.ac == nil || token.transport.transport == nil { | 95 | + | sess, ok := localSession(token.sess) |
| 96 | + | if !ok || token.ac == nil || token.transport.transport == nil { | |||
| 94 | return false | 97 | return false | ||
| 95 | } | 98 | } | ||
| 96 | frozen := freezeRoleEffectGates(token.ac) | 99 | frozen := freezeRoleEffectGates(token.ac) | ||
| 97 | defer frozen.unfreeze() | 100 | defer frozen.unfreeze() | ||
| 98 | 101 | ||||
| 99 | d.notices.routingMu.Lock() | 102 | d.notices.routingMu.Lock() | ||
| 100 | - | token.sess.mu.Lock() | 103 | + | sess.mu.Lock() |
| 101 | - | current := token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | 104 | + | current := attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched && |
| 102 | - | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | 105 | + | token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && |
| 103 | token.ac.transportSnapshotCurrent(token.transport) | 106 | token.ac.transportSnapshotCurrent(token.transport) | ||
| 104 | - | token.sess.mu.Unlock() | 107 | + | sess.mu.Unlock() |
| 105 | d.notices.routingMu.Unlock() | 108 | d.notices.routingMu.Unlock() | ||
| 106 | if !current { | 109 | if !current { | ||
| 107 | return false | 110 | return false | ||
| @@ -111,7 +114,7 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) | |||||
| 111 | // the unroute→park gap for a still-valid snatched credential. | 114 | // the unroute→park gap for a still-valid snatched credential. | ||
| 112 | var parkingToken uint64 | 115 | var parkingToken uint64 | ||
| 113 | if token.ac.resumeCapable { | 116 | if token.ac.resumeCapable { | ||
| 114 | - | parkingToken = d.markParkingInFlight(token.sess, token.ac) | 117 | + | parkingToken = d.markParkingInFlight(sess, token.ac) |
| 115 | } | 118 | } | ||
| 116 | 119 | ||||
| 117 | // The frozen gate prevents a promotion from publishing between overlay | 120 | // The frozen gate prevents a promotion from publishing between overlay | ||
| @@ -119,19 +122,19 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) | |||||
| 119 | d.clearForSnatch(token) | 122 | d.clearForSnatch(token) | ||
| 120 | 123 | ||||
| 121 | d.notices.routingMu.Lock() | 124 | d.notices.routingMu.Lock() | ||
| 122 | - | token.sess.mu.Lock() | 125 | + | sess.mu.Lock() |
| 123 | - | current = token.sess.attachmentRoleLocked(token.ac) == attachmentSnatched && | 126 | + | current = attachmentSessionRoleLocked(token.sess, token.ac) == attachmentSnatched && |
| 124 | - | token.ac.currentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && | 127 | + | token.ac.currentAttachmentSession() == token.sess && token.ac.roleGeneration.Load() == token.generation && |
| 125 | token.ac.transportSnapshotCurrent(token.transport) | 128 | token.ac.transportSnapshotCurrent(token.transport) | ||
| 126 | if current { | 129 | if current { | ||
| 127 | - | delete(token.sess.snatched, token.ac) | 130 | + | delete(sess.snatched, token.ac) |
| 128 | token.ac.roleGeneration.Add(1) | 131 | token.ac.roleGeneration.Add(1) | ||
| 129 | token.ac.invalidateFrozenRoleCapability() | 132 | token.ac.invalidateFrozenRoleCapability() | ||
| 130 | } | 133 | } | ||
| 131 | - | token.sess.mu.Unlock() | 134 | + | sess.mu.Unlock() |
| 132 | d.notices.routingMu.Unlock() | 135 | d.notices.routingMu.Unlock() | ||
| 133 | if !current { | 136 | if !current { | ||
| 134 | - | d.clearParkingInFlightIfAbandoned(token.sess, token.ac, parkingToken) | 137 | + | d.clearParkingInFlightIfAbandoned(sess, token.ac, parkingToken) |
| 135 | return false | 138 | return false | ||
| 136 | } | 139 | } | ||
| 137 | 140 | ||||
| @@ -139,7 +142,7 @@ func (d *Daemon) cleanupInterruptedSnatchedAttachment(token attachmentRoleToken) | |||||
| 139 | if d.afterSnatchedUnrouteBeforePark != nil { | 142 | if d.afterSnatchedUnrouteBeforePark != nil { | ||
| 140 | d.afterSnatchedUnrouteBeforePark() | 143 | d.afterSnatchedUnrouteBeforePark() | ||
| 141 | } | 144 | } | ||
| 142 | - | if token.ac.resumeCapable && d.parkAttachmentAs(token.sess, token.ac, attachmentSnatched) { | 145 | + | if token.ac.resumeCapable && d.parkAttachmentAs(sess, token.ac, attachmentSnatched) { |
| 143 | captured := token.transport.transport | 146 | captured := token.transport.transport | ||
| 144 | _ = token.ac.revokeTransport(captured) | 147 | _ = token.ac.revokeTransport(captured) | ||
| 145 | _ = token.ac.closeCapturedTransport(captured) | 148 | _ = token.ac.closeCapturedTransport(captured) | ||
M internal/usecase/daemon/snatched_client_test.go +14 -14
| @@ -21,7 +21,7 @@ func TestSessionAttachmentRole(t *testing.T) { | |||
| 21 | 21 | active := &attachedClient{} | |
| 22 | 22 | waiting := &attachedClient{} | |
| 23 | 23 | unknown := &attachedClient{} | |
| 24 | - | sess := &session{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}} | |
| 24 | + | sess := &session{sessionCore: sessionCore{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}} | |
| 25 | 25 | ||
| 26 | 26 | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | |
| 27 | 27 | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | |
| @@ -30,7 +30,7 @@ func TestSessionAttachmentRole(t *testing.T) { | |||
| 30 | 30 | ||
| 31 | 31 | func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) { | |
| 32 | 32 | d := newTestDaemon(t, nil, stubClock{}) | |
| 33 | - | sess := &session{id: domain.SessionID("work")} | |
| 33 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} | |
| 34 | 34 | oldTransport := &closeTrackingTransport{} | |
| 35 | 35 | old := &attachedClient{tr: oldTransport} | |
| 36 | 36 | old.setSession(sess) | |
| @@ -67,7 +67,7 @@ func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) { | |||
| 67 | 67 | ||
| 68 | 68 | func TestDeferredAttachmentCleanupKeepsReplacementOpenAndSendsResetPanel(t *testing.T) { | |
| 69 | 69 | d := newTestDaemon(t, nil, stubClock{}) | |
| 70 | - | sess := &session{id: domain.SessionID("work")} | |
| 70 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} | |
| 71 | 71 | oldTransport := &closeTrackingTransport{} | |
| 72 | 72 | old := &attachedClient{ | |
| 73 | 73 | tr: oldTransport, | |
| @@ -283,7 +283,7 @@ func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) { | |||
| 283 | 283 | otherTransport := newDatagramTestTransport() | |
| 284 | 284 | otherClient := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: first.size} | |
| 285 | 285 | otherClient.initOverlays() | |
| 286 | - | other := &session{id: "other", name: "other", ctx: otherCtx, cancel: otherCancel, client: otherClient} | |
| 286 | + | other := &session{sessionCore: sessionCore{id: "other", name: "other", client: otherClient}, ctx: otherCtx, cancel: otherCancel} | |
| 287 | 287 | otherClient.setSession(other) | |
| 288 | 288 | d.mu.Lock() | |
| 289 | 289 | d.sessions[other.id] = other | |
| @@ -754,7 +754,7 @@ func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) { | |||
| 754 | 754 | oldTransport := &failingSnatchedTransport{} | |
| 755 | 755 | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 756 | 756 | old.initOverlays() | |
| 757 | - | sess := &session{id: "work", client: old} | |
| 757 | + | sess := &session{sessionCore: sessionCore{id: "work", client: old}} | |
| 758 | 758 | old.setSession(sess) | |
| 759 | 759 | d.sessions[sess.id] = sess | |
| 760 | 760 | ||
| @@ -823,7 +823,7 @@ func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | |||
| 823 | 823 | oldTransport := &failingSnatchedTransport{} | |
| 824 | 824 | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 825 | 825 | old.initOverlays() | |
| 826 | - | sess := &session{id: "work", client: old} | |
| 826 | + | sess := &session{sessionCore: sessionCore{id: "work", client: old}} | |
| 827 | 827 | old.setSession(sess) | |
| 828 | 828 | d.sessions[sess.id] = sess | |
| 829 | 829 | ||
| @@ -864,7 +864,7 @@ func TestClearForSnatchReleasesEachOverlayFamilyBeforeTakingNext(t *testing.T) { | |||
| 864 | 864 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 865 | 865 | ac.initOverlays() | |
| 866 | 866 | ac.roleGeneration.Store(1) | |
| 867 | - | sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}} | |
| 867 | + | sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}} | |
| 868 | 868 | ac.setSession(sess) | |
| 869 | 869 | token := attachmentRoleToken{ | |
| 870 | 870 | sess: sess, ac: ac, role: attachmentSnatched, | |
| @@ -905,7 +905,7 @@ func TestClearForSnatchRejectsStaleGenerationWithoutClearingNewOverlay(t *testin | |||
| 905 | 905 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 906 | 906 | ac.initOverlays() | |
| 907 | 907 | ac.roleGeneration.Store(1) | |
| 908 | - | sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}} | |
| 908 | + | sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}} | |
| 909 | 909 | ac.setSession(sess) | |
| 910 | 910 | token := attachmentRoleToken{ | |
| 911 | 911 | sess: sess, ac: ac, role: attachmentSnatched, | |
| @@ -960,7 +960,7 @@ func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) { | |||
| 960 | 960 | for _, tt := range tests { | |
| 961 | 961 | t.Run(tt.name, func(t *testing.T) { | |
| 962 | 962 | d := newTestDaemon(t, nil, stubClock{}) | |
| 963 | - | sess := &session{id: domain.SessionID("work")} | |
| 963 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} | |
| 964 | 964 | old := &attachedClient{tr: &closeTrackingTransport{}} | |
| 965 | 965 | old.setSession(sess) | |
| 966 | 966 | sess.client = old | |
| @@ -986,8 +986,8 @@ func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) { | |||
| 986 | 986 | ||
| 987 | 987 | func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) { | |
| 988 | 988 | d := newTestDaemon(t, nil, stubClock{}) | |
| 989 | - | from := &session{id: "z-source"} | |
| 990 | - | target := &session{id: "a-target"} | |
| 989 | + | from := &session{sessionCore: sessionCore{id: "z-source"}} | |
| 990 | + | target := &session{sessionCore: sessionCore{id: "a-target"}} | |
| 991 | 991 | moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 992 | 992 | displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size} | |
| 993 | 993 | moving.initOverlays() | |
| @@ -1088,7 +1088,7 @@ func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *test | |||
| 1088 | 1088 | ac.roleGeneration.Store(2) | |
| 1089 | 1089 | ac.output.next = 4 | |
| 1090 | 1090 | ac.output.acked = 1 | |
| 1091 | - | sess := &session{id: "work", client: ac} | |
| 1091 | + | sess := &session{sessionCore: sessionCore{id: "work", client: ac}} | |
| 1092 | 1092 | ac.setSession(sess) | |
| 1093 | 1093 | d.sessions[sess.id] = sess | |
| 1094 | 1094 | rc := d.attachCoordinator(sess, nil, ac, true) | |
| @@ -1109,8 +1109,8 @@ func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *test | |||
| 1109 | 1109 | ||
| 1110 | 1110 | func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) { | |
| 1111 | 1111 | d := newTestDaemon(t, nil, stubClock{}) | |
| 1112 | - | from := &session{id: domain.SessionID("z-source")} | |
| 1113 | - | target := &session{id: domain.SessionID("a-target")} | |
| 1112 | + | from := &session{sessionCore: sessionCore{id: domain.SessionID("z-source")}} | |
| 1113 | + | target := &session{sessionCore: sessionCore{id: domain.SessionID("a-target")}} | |
| 1114 | 1114 | moving := &attachedClient{tr: &closeTrackingTransport{}} | |
| 1115 | 1115 | displaced := &attachedClient{tr: &closeTrackingTransport{}} | |
| 1116 | 1116 | moving.setSession(from) | |
| @@ -21,7 +21,7 @@ func TestSessionAttachmentRole(t *testing.T) { | |||||
| 21 | active := &attachedClient{} | 21 | active := &attachedClient{} | ||
| 22 | waiting := &attachedClient{} | 22 | waiting := &attachedClient{} | ||
| 23 | unknown := &attachedClient{} | 23 | unknown := &attachedClient{} | ||
| 24 | - | sess := &session{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}} | 24 | + | sess := &session{sessionCore: sessionCore{client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}} |
| 25 | 25 | ||||
| 26 | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | 26 | require.Equal(t, attachmentActive, sess.attachmentRole(active)) | ||
| 27 | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | 27 | require.Equal(t, attachmentSnatched, sess.attachmentRole(waiting)) | ||
| @@ -30,7 +30,7 @@ func TestSessionAttachmentRole(t *testing.T) { | |||||
| 30 | 30 | ||||
| 31 | func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) { | 31 | func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) { | ||
| 32 | d := newTestDaemon(t, nil, stubClock{}) | 32 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 33 | - | sess := &session{id: domain.SessionID("work")} | 33 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} |
| 34 | oldTransport := &closeTrackingTransport{} | 34 | oldTransport := &closeTrackingTransport{} | ||
| 35 | old := &attachedClient{tr: oldTransport} | 35 | old := &attachedClient{tr: oldTransport} | ||
| 36 | old.setSession(sess) | 36 | old.setSession(sess) | ||
| @@ -67,7 +67,7 @@ func TestTransitionAttachmentReplacesActiveMembership(t *testing.T) { | |||||
| 67 | 67 | ||||
| 68 | func TestDeferredAttachmentCleanupKeepsReplacementOpenAndSendsResetPanel(t *testing.T) { | 68 | func TestDeferredAttachmentCleanupKeepsReplacementOpenAndSendsResetPanel(t *testing.T) { | ||
| 69 | d := newTestDaemon(t, nil, stubClock{}) | 69 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 70 | - | sess := &session{id: domain.SessionID("work")} | 70 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} |
| 71 | oldTransport := &closeTrackingTransport{} | 71 | oldTransport := &closeTrackingTransport{} | ||
| 72 | old := &attachedClient{ | 72 | old := &attachedClient{ | ||
| 73 | tr: oldTransport, | 73 | tr: oldTransport, | ||
| @@ -283,7 +283,7 @@ func TestKillSessionClosesActiveAndAllSnatchedClients(t *testing.T) { | |||||
| 283 | otherTransport := newDatagramTestTransport() | 283 | otherTransport := newDatagramTestTransport() | ||
| 284 | otherClient := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: first.size} | 284 | otherClient := &attachedClient{tr: otherTransport, output: newOutputStateStream(), size: first.size} | ||
| 285 | otherClient.initOverlays() | 285 | otherClient.initOverlays() | ||
| 286 | - | other := &session{id: "other", name: "other", ctx: otherCtx, cancel: otherCancel, client: otherClient} | 286 | + | other := &session{sessionCore: sessionCore{id: "other", name: "other", client: otherClient}, ctx: otherCtx, cancel: otherCancel} |
| 287 | otherClient.setSession(other) | 287 | otherClient.setSession(other) | ||
| 288 | d.mu.Lock() | 288 | d.mu.Lock() | ||
| 289 | d.sessions[other.id] = other | 289 | d.sessions[other.id] = other | ||
| @@ -754,7 +754,7 @@ func TestSnatchedPanelSendFailureRemovesOnlyWaitingClient(t *testing.T) { | |||||
| 754 | oldTransport := &failingSnatchedTransport{} | 754 | oldTransport := &failingSnatchedTransport{} | ||
| 755 | 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}} | ||
| 756 | old.initOverlays() | 756 | old.initOverlays() | ||
| 757 | - | sess := &session{id: "work", client: old} | 757 | + | sess := &session{sessionCore: sessionCore{id: "work", client: old}} |
| 758 | old.setSession(sess) | 758 | old.setSession(sess) | ||
| 759 | d.sessions[sess.id] = sess | 759 | d.sessions[sess.id] = sess | ||
| 760 | 760 | ||||
| @@ -823,7 +823,7 @@ func TestSnatchedThemePanelFailureRemovesOnlySnatchedAttachment(t *testing.T) { | |||||
| 823 | oldTransport := &failingSnatchedTransport{} | 823 | oldTransport := &failingSnatchedTransport{} | ||
| 824 | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 824 | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 825 | old.initOverlays() | 825 | old.initOverlays() | ||
| 826 | - | sess := &session{id: "work", client: old} | 826 | + | sess := &session{sessionCore: sessionCore{id: "work", client: old}} |
| 827 | old.setSession(sess) | 827 | old.setSession(sess) | ||
| 828 | d.sessions[sess.id] = sess | 828 | d.sessions[sess.id] = sess | ||
| 829 | 829 | ||||
| @@ -864,7 +864,7 @@ func TestClearForSnatchReleasesEachOverlayFamilyBeforeTakingNext(t *testing.T) { | |||||
| 864 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 864 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 865 | ac.initOverlays() | 865 | ac.initOverlays() | ||
| 866 | ac.roleGeneration.Store(1) | 866 | ac.roleGeneration.Store(1) | ||
| 867 | - | sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}} | 867 | + | sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}} |
| 868 | ac.setSession(sess) | 868 | ac.setSession(sess) | ||
| 869 | token := attachmentRoleToken{ | 869 | token := attachmentRoleToken{ | ||
| 870 | sess: sess, ac: ac, role: attachmentSnatched, | 870 | sess: sess, ac: ac, role: attachmentSnatched, | ||
| @@ -905,7 +905,7 @@ func TestClearForSnatchRejectsStaleGenerationWithoutClearingNewOverlay(t *testin | |||||
| 905 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | 905 | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | ||
| 906 | ac.initOverlays() | 906 | ac.initOverlays() | ||
| 907 | ac.roleGeneration.Store(1) | 907 | ac.roleGeneration.Store(1) | ||
| 908 | - | sess := &session{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}} | 908 | + | sess := &session{sessionCore: sessionCore{id: "work", snatched: map[*attachedClient]struct{}{ac: {}}}} |
| 909 | ac.setSession(sess) | 909 | ac.setSession(sess) | ||
| 910 | token := attachmentRoleToken{ | 910 | token := attachmentRoleToken{ | ||
| 911 | sess: sess, ac: ac, role: attachmentSnatched, | 911 | sess: sess, ac: ac, role: attachmentSnatched, | ||
| @@ -960,7 +960,7 @@ func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) { | |||||
| 960 | for _, tt := range tests { | 960 | for _, tt := range tests { | ||
| 961 | t.Run(tt.name, func(t *testing.T) { | 961 | t.Run(tt.name, func(t *testing.T) { | ||
| 962 | d := newTestDaemon(t, nil, stubClock{}) | 962 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 963 | - | sess := &session{id: domain.SessionID("work")} | 963 | + | sess := &session{sessionCore: sessionCore{id: domain.SessionID("work")}} |
| 964 | old := &attachedClient{tr: &closeTrackingTransport{}} | 964 | old := &attachedClient{tr: &closeTrackingTransport{}} | ||
| 965 | old.setSession(sess) | 965 | old.setSession(sess) | ||
| 966 | sess.client = old | 966 | sess.client = old | ||
| @@ -986,8 +986,8 @@ func TestTransitionAttachmentRejectsBeforeOwnershipCommit(t *testing.T) { | |||||
| 986 | 986 | ||||
| 987 | func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) { | 987 | func TestCrossSessionTransitionPublishesCoherentOwnershipBeforeBlockedFirstPaint(t *testing.T) { | ||
| 988 | d := newTestDaemon(t, nil, stubClock{}) | 988 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 989 | - | from := &session{id: "z-source"} | 989 | + | from := &session{sessionCore: sessionCore{id: "z-source"}} |
| 990 | - | target := &session{id: "a-target"} | 990 | + | target := &session{sessionCore: sessionCore{id: "a-target"}} |
| 991 | moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | 991 | moving := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | ||
| 992 | displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size} | 992 | displaced := &attachedClient{tr: &closeTrackingTransport{}, output: newOutputStateStream(), size: moving.size} | ||
| 993 | moving.initOverlays() | 993 | moving.initOverlays() | ||
| @@ -1088,7 +1088,7 @@ func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *test | |||||
| 1088 | ac.roleGeneration.Store(2) | 1088 | ac.roleGeneration.Store(2) | ||
| 1089 | ac.output.next = 4 | 1089 | ac.output.next = 4 | ||
| 1090 | ac.output.acked = 1 | 1090 | ac.output.acked = 1 | ||
| 1091 | - | sess := &session{id: "work", client: ac} | 1091 | + | sess := &session{sessionCore: sessionCore{id: "work", client: ac}} |
| 1092 | ac.setSession(sess) | 1092 | ac.setSession(sess) | ||
| 1093 | d.sessions[sess.id] = sess | 1093 | d.sessions[sess.id] = sess | ||
| 1094 | rc := d.attachCoordinator(sess, nil, ac, true) | 1094 | rc := d.attachCoordinator(sess, nil, ac, true) | ||
| @@ -1109,8 +1109,8 @@ func TestFirstPaintForTransitionRejectsStaleRoleTokenBeforeRebaseOrReset(t *test | |||||
| 1109 | 1109 | ||||
| 1110 | func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) { | 1110 | func TestTransitionAttachmentMovesActiveBetweenSessions(t *testing.T) { | ||
| 1111 | d := newTestDaemon(t, nil, stubClock{}) | 1111 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 1112 | - | from := &session{id: domain.SessionID("z-source")} | 1112 | + | from := &session{sessionCore: sessionCore{id: domain.SessionID("z-source")}} |
| 1113 | - | target := &session{id: domain.SessionID("a-target")} | 1113 | + | target := &session{sessionCore: sessionCore{id: domain.SessionID("a-target")}} |
| 1114 | moving := &attachedClient{tr: &closeTrackingTransport{}} | 1114 | moving := &attachedClient{tr: &closeTrackingTransport{}} | ||
| 1115 | displaced := &attachedClient{tr: &closeTrackingTransport{}} | 1115 | displaced := &attachedClient{tr: &closeTrackingTransport{}} | ||
| 1116 | moving.setSession(from) | 1116 | moving.setSession(from) | ||
M internal/usecase/daemon/snatched_input_test.go +1 -3
| @@ -139,9 +139,7 @@ func newSnatchedInputFixture(t *testing.T, clock ports.Clock) (*Daemon, *session | |||
| 139 | 139 | } | |
| 140 | 140 | waiting.initOverlays() | |
| 141 | 141 | active.initOverlays() | |
| 142 | - | sess := &session{ | |
| 143 | - | id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}}, | |
| 144 | - | } | |
| 142 | + | sess := &session{sessionCore: sessionCore{id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}} | |
| 145 | 143 | waiting.setSession(sess) | |
| 146 | 144 | active.setSession(sess) | |
| 147 | 145 | d.sessions[sess.id] = sess | |
| @@ -139,9 +139,7 @@ func newSnatchedInputFixture(t *testing.T, clock ports.Clock) (*Daemon, *session | |||||
| 139 | } | 139 | } | ||
| 140 | waiting.initOverlays() | 140 | waiting.initOverlays() | ||
| 141 | active.initOverlays() | 141 | active.initOverlays() | ||
| 142 | - | sess := &session{ | 142 | + | sess := &session{sessionCore: sessionCore{id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}}}} |
| 143 | - | id: "strict-input", client: active, snatched: map[*attachedClient]struct{}{waiting: {}}, | |||
| 144 | - | } | |||
| 145 | waiting.setSession(sess) | 143 | waiting.setSession(sess) | ||
| 146 | active.setSession(sess) | 144 | active.setSession(sess) | ||
| 147 | d.sessions[sess.id] = sess | 145 | d.sessions[sess.id] = sess | ||
M internal/usecase/daemon/snatched_reclaim_test.go +2 -3
| @@ -42,9 +42,8 @@ func newAtomicReclaimFixture(t *testing.T, clock ports.Clock, waitingTransport, | |||
| 42 | 42 | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | |
| 43 | 43 | waiting.initOverlays() | |
| 44 | 44 | active.initOverlays() | |
| 45 | - | sess := &session{ | |
| 46 | - | id: "atomic-reclaim", client: active, | |
| 47 | - | snatched: map[*attachedClient]struct{}{waiting: {}}, | |
| 45 | + | sess := &session{sessionCore: sessionCore{id: "atomic-reclaim", client: active, | |
| 46 | + | snatched: map[*attachedClient]struct{}{waiting: {}}}, | |
| 48 | 47 | } | |
| 49 | 48 | waiting.setSession(sess) | |
| 50 | 49 | active.setSession(sess) | |
| @@ -42,9 +42,8 @@ func newAtomicReclaimFixture(t *testing.T, clock ports.Clock, waitingTransport, | |||||
| 42 | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | 42 | active := &attachedClient{tr: activeTransport, output: newOutputStateStream(), size: waiting.size} | ||
| 43 | waiting.initOverlays() | 43 | waiting.initOverlays() | ||
| 44 | active.initOverlays() | 44 | active.initOverlays() | ||
| 45 | - | sess := &session{ | 45 | + | sess := &session{sessionCore: sessionCore{id: "atomic-reclaim", client: active, |
| 46 | - | id: "atomic-reclaim", client: active, | 46 | + | snatched: map[*attachedClient]struct{}{waiting: {}}}, |
| 47 | - | snatched: map[*attachedClient]struct{}{waiting: {}}, | |||
| 48 | } | 47 | } | ||
| 49 | waiting.setSession(sess) | 48 | waiting.setSession(sess) | ||
| 50 | active.setSession(sess) | 49 | active.setSession(sess) | ||
M internal/usecase/daemon/status_test.go +6 -6
| @@ -966,7 +966,7 @@ func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing. | |||
| 966 | 966 | p, releasePTY := newBlockingPTY(t) | |
| 967 | 967 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 968 | 968 | defer releasePTY() | |
| 969 | - | live := &session{id: "live", name: "live", tabs: []*tab{{}}} | |
| 969 | + | live := &session{sessionCore: sessionCore{id: "live", name: "live"}, tabs: []*tab{{}}} | |
| 970 | 970 | live.mruAt.Store(1) | |
| 971 | 971 | d.sessions[live.id] = live | |
| 972 | 972 | ||
| @@ -1007,7 +1007,7 @@ func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) | |||
| 1007 | 1007 | sess.name = "current" | |
| 1008 | 1008 | sess.mruAt.Store(100) | |
| 1009 | 1009 | for i := range 10 { | |
| 1010 | - | other := &session{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i), tabs: []*tab{{attention: i == 8}}} | |
| 1010 | + | other := &session{sessionCore: sessionCore{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i)}, tabs: []*tab{{attention: i == 8}}} | |
| 1011 | 1011 | other.mruAt.Store(uint64(i + 1)) | |
| 1012 | 1012 | d.sessions[other.id] = other | |
| 1013 | 1013 | } | |
| @@ -1028,9 +1028,9 @@ func TestBarStateForMRUExcludesEphemeralSessions(t *testing.T) { | |||
| 1028 | 1028 | d, current, _, _ := newManualSessionWithPTYs(t, p) | |
| 1029 | 1029 | defer releasePTY() | |
| 1030 | 1030 | ||
| 1031 | - | persistent := &session{id: "named", name: "named", tabs: []*tab{{}}} | |
| 1031 | + | persistent := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}} | |
| 1032 | 1032 | persistent.mruAt.Store(1) | |
| 1033 | - | ephemeral := &session{id: "ephemeral", name: "1", ephemeral: true, tabs: []*tab{{}}} | |
| 1033 | + | ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}} | |
| 1034 | 1034 | ephemeral.mruAt.Store(2) | |
| 1035 | 1035 | d.sessions[persistent.id] = persistent | |
| 1036 | 1036 | d.sessions[ephemeral.id] = ephemeral | |
| @@ -1046,7 +1046,7 @@ func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) { | |||
| 1046 | 1046 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 1047 | 1047 | defer releasePTY() | |
| 1048 | 1048 | for _, name := range []string{"bravo", "alpha", "charlie"} { | |
| 1049 | - | d.sessions[domain.SessionID(name)] = &session{id: domain.SessionID(name), name: name, tabs: []*tab{{}}} | |
| 1049 | + | d.sessions[domain.SessionID(name)] = &session{sessionCore: sessionCore{id: domain.SessionID(name), name: name}, tabs: []*tab{{}}} | |
| 1050 | 1050 | } | |
| 1051 | 1051 | ||
| 1052 | 1052 | state := d.barStateFor(sess, "") | |
| @@ -1065,7 +1065,7 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) { | |||
| 1065 | 1065 | d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30} | |
| 1066 | 1066 | d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20} | |
| 1067 | 1067 | d.mruSeq.Store(30) | |
| 1068 | - | cur := &session{id: "cur", name: "current", tabs: []*tab{{}}} | |
| 1068 | + | cur := &session{sessionCore: sessionCore{id: "cur", name: "current"}, tabs: []*tab{{}}} | |
| 1069 | 1069 | d.sessions[cur.id] = cur | |
| 1070 | 1070 | ||
| 1071 | 1071 | _, err := createSessionForTest(d, "zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv) | |
| @@ -966,7 +966,7 @@ func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing. | |||||
| 966 | p, releasePTY := newBlockingPTY(t) | 966 | p, releasePTY := newBlockingPTY(t) | ||
| 967 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | 967 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | ||
| 968 | defer releasePTY() | 968 | defer releasePTY() | ||
| 969 | - | live := &session{id: "live", name: "live", tabs: []*tab{{}}} | 969 | + | live := &session{sessionCore: sessionCore{id: "live", name: "live"}, tabs: []*tab{{}}} |
| 970 | live.mruAt.Store(1) | 970 | live.mruAt.Store(1) | ||
| 971 | d.sessions[live.id] = live | 971 | d.sessions[live.id] = live | ||
| 972 | 972 | ||||
| @@ -1007,7 +1007,7 @@ func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) | |||||
| 1007 | sess.name = "current" | 1007 | sess.name = "current" | ||
| 1008 | sess.mruAt.Store(100) | 1008 | sess.mruAt.Store(100) | ||
| 1009 | for i := range 10 { | 1009 | for i := range 10 { | ||
| 1010 | - | other := &session{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i), tabs: []*tab{{attention: i == 8}}} | 1010 | + | other := &session{sessionCore: sessionCore{id: domain.SessionID("s" + strconv.Itoa(i)), name: "s" + strconv.Itoa(i)}, tabs: []*tab{{attention: i == 8}}} |
| 1011 | other.mruAt.Store(uint64(i + 1)) | 1011 | other.mruAt.Store(uint64(i + 1)) | ||
| 1012 | d.sessions[other.id] = other | 1012 | d.sessions[other.id] = other | ||
| 1013 | } | 1013 | } | ||
| @@ -1028,9 +1028,9 @@ func TestBarStateForMRUExcludesEphemeralSessions(t *testing.T) { | |||||
| 1028 | d, current, _, _ := newManualSessionWithPTYs(t, p) | 1028 | d, current, _, _ := newManualSessionWithPTYs(t, p) | ||
| 1029 | defer releasePTY() | 1029 | defer releasePTY() | ||
| 1030 | 1030 | ||||
| 1031 | - | persistent := &session{id: "named", name: "named", tabs: []*tab{{}}} | 1031 | + | persistent := &session{sessionCore: sessionCore{id: "named", name: "named"}, tabs: []*tab{{}}} |
| 1032 | persistent.mruAt.Store(1) | 1032 | persistent.mruAt.Store(1) | ||
| 1033 | - | ephemeral := &session{id: "ephemeral", name: "1", ephemeral: true, tabs: []*tab{{}}} | 1033 | + | ephemeral := &session{sessionCore: sessionCore{id: "ephemeral", name: "1", ephemeral: true}, tabs: []*tab{{}}} |
| 1034 | ephemeral.mruAt.Store(2) | 1034 | ephemeral.mruAt.Store(2) | ||
| 1035 | d.sessions[persistent.id] = persistent | 1035 | d.sessions[persistent.id] = persistent | ||
| 1036 | d.sessions[ephemeral.id] = ephemeral | 1036 | d.sessions[ephemeral.id] = ephemeral | ||
| @@ -1046,7 +1046,7 @@ func TestBarStateForMRUZeroTimesUseDeterministicNameOrder(t *testing.T) { | |||||
| 1046 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | 1046 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | ||
| 1047 | defer releasePTY() | 1047 | defer releasePTY() | ||
| 1048 | for _, name := range []string{"bravo", "alpha", "charlie"} { | 1048 | for _, name := range []string{"bravo", "alpha", "charlie"} { | ||
| 1049 | - | d.sessions[domain.SessionID(name)] = &session{id: domain.SessionID(name), name: name, tabs: []*tab{{}}} | 1049 | + | d.sessions[domain.SessionID(name)] = &session{sessionCore: sessionCore{id: domain.SessionID(name), name: name}, tabs: []*tab{{}}} |
| 1050 | } | 1050 | } | ||
| 1051 | 1051 | ||||
| 1052 | state := d.barStateFor(sess, "") | 1052 | state := d.barStateFor(sess, "") | ||
| @@ -1065,7 +1065,7 @@ func TestBarStateForMRURestoredStoppedSessionsUsePersistedOrder(t *testing.T) { | |||||
| 1065 | d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30} | 1065 | d.stopped["alpha"] = stoppedSession{name: "alpha", cwd: "/tmp/alpha", createdAt: 1, lastUsedSeq: 30} | ||
| 1066 | d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20} | 1066 | d.stopped["zeta"] = stoppedSession{name: "zeta", cwd: "/tmp/zeta", createdAt: 1, lastUsedSeq: 20} | ||
| 1067 | d.mruSeq.Store(30) | 1067 | d.mruSeq.Store(30) | ||
| 1068 | - | cur := &session{id: "cur", name: "current", tabs: []*tab{{}}} | 1068 | + | cur := &session{sessionCore: sessionCore{id: "cur", name: "current"}, tabs: []*tab{{}}} |
| 1069 | d.sessions[cur.id] = cur | 1069 | d.sessions[cur.id] = cur | ||
| 1070 | 1070 | ||||
| 1071 | _, err := createSessionForTest(d, "zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv) | 1071 | _, err := createSessionForTest(d, "zeta", false, "/tmp/zeta", sz, terminalEnv{}, d.baseEnv) | ||
M internal/usecase/daemon/transactional_resize_test.go +1 -1
| @@ -521,7 +521,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |||
| 521 | 521 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | |
| 522 | 522 | require.True(t, d.runResizeTransaction(source, ac, lease, epoch)) | |
| 523 | 523 | ||
| 524 | - | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |
| 524 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}} | |
| 525 | 525 | publishPaneOwner(popup, destination, tb, 7) | |
| 526 | 526 | destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9} | |
| 527 | 527 | popup.mu.Lock() | |
| @@ -521,7 +521,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |||||
| 521 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | 521 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | ||
| 522 | require.True(t, d.runResizeTransaction(source, ac, lease, epoch)) | 522 | require.True(t, d.runResizeTransaction(source, ac, lease, epoch)) | ||
| 523 | 523 | ||||
| 524 | - | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | 524 | + | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}} |
| 525 | publishPaneOwner(popup, destination, tb, 7) | 525 | publishPaneOwner(popup, destination, tb, 7) | ||
| 526 | destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9} | 526 | destinationRect := domain.Rect{X: 9, Y: 4, Width: 31, Height: 9} | ||
| 527 | popup.mu.Lock() | 527 | popup.mu.Lock() | ||