refactor(daemon): deepen shared PTY geometry (#225)
* refactor(daemon): deepen shared PTY geometry * fix(daemon): keep geometry reconciliation authoritative
- commit
- cbfd03f5503afcfc31d780df681fac2b061af4bb
- author
- bnema · · 2026-08-27 18:43 UTC
- committer
- GitHub · · 2026-08-27 18:43 UTC
- parent
- 5a95bf3
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
35 files · +496 -607
M internal/usecase/daemon/attachment_registry.go +3 -115
| @@ -101,66 +101,12 @@ func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool { | |||
| 101 | 101 | c.nextAttachmentID++ | |
| 102 | 102 | c.attachments[ac] = struct{}{} | |
| 103 | 103 | c.attachmentOrder[ac] = c.nextAttachmentID | |
| 104 | - | if c.claimGeometryOwnerLocked(ac, ac.geometrySnapshot()) == 0 { | |
| 105 | - | c.refreshGeometryOwnerLocked() | |
| 104 | + | if c.geometry.claimLocked(c, ac, ac.geometrySnapshot()) == nil { | |
| 105 | + | c.geometry.refreshLocked(c) | |
| 106 | 106 | } | |
| 107 | 107 | return true | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | - | // latestValidGeometryOwnerLocked selects the most recent valid attachment, | |
| 111 | - | // excluding one claimant when a rejected request is being released. Caller | |
| 112 | - | // holds the owning session's mu. | |
| 113 | - | func (c *sessionCore) latestValidGeometryOwnerLocked(exclude *attachedClient) *attachedClient { | |
| 114 | - | var owner *attachedClient | |
| 115 | - | var ownerClaim, ownerOrder uint64 | |
| 116 | - | for candidate := range c.attachments { | |
| 117 | - | if candidate == nil || candidate == exclude || !candidate.geometrySnapshot().Valid() || !candidate.geometryClaimSnapshot().Valid() { | |
| 118 | - | continue | |
| 119 | - | } | |
| 120 | - | claim := candidate.geometryClaim.Load() | |
| 121 | - | order := c.attachmentOrder[candidate] | |
| 122 | - | if owner == nil || claim > ownerClaim || (claim == ownerClaim && order > ownerOrder) { | |
| 123 | - | owner = candidate | |
| 124 | - | ownerClaim = claim | |
| 125 | - | ownerOrder = order | |
| 126 | - | } | |
| 127 | - | } | |
| 128 | - | return owner | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | // refreshGeometryOwnerLocked repairs a removed or invalid owner without | |
| 132 | - | // inventing a newer claim. Caller holds the owning session's mu. | |
| 133 | - | func (c *sessionCore) refreshGeometryOwnerLocked() { | |
| 134 | - | if c == nil { | |
| 135 | - | return | |
| 136 | - | } | |
| 137 | - | owner := c.geometryOwner.Load() | |
| 138 | - | if owner != nil { | |
| 139 | - | if _, ok := c.attachments[owner]; ok && owner.geometrySnapshot().Valid() && owner.geometryClaimSnapshot().Valid() { | |
| 140 | - | return | |
| 141 | - | } | |
| 142 | - | } | |
| 143 | - | c.geometryMu.Lock() | |
| 144 | - | c.geometryOwner.Store(c.latestValidGeometryOwnerLocked(nil)) | |
| 145 | - | c.geometryMu.Unlock() | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | // claimGeometryOwnerLocked records the latest attachment claim. Caller holds | |
| 149 | - | // the owning session's mu; the atomic publication lets resize transactions | |
| 150 | - | // validate the claim while tab locks are held. | |
| 151 | - | func (c *sessionCore) claimGeometryOwnerLocked(ac *attachedClient, geometry domain.Geometry) uint64 { | |
| 152 | - | if c == nil || ac == nil || !geometry.Valid() { | |
| 153 | - | return 0 | |
| 154 | - | } | |
| 155 | - | c.geometryMu.Lock() | |
| 156 | - | defer c.geometryMu.Unlock() | |
| 157 | - | c.geometryClaimSeq++ | |
| 158 | - | claim := c.geometryClaimSeq | |
| 159 | - | ac.publishGeometryClaim(geometry, claim) | |
| 160 | - | c.geometryOwner.Store(ac) | |
| 161 | - | return claim | |
| 162 | - | } | |
| 163 | - | ||
| 164 | 110 | func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool { | |
| 165 | 111 | if entry == nil || entry.core() == nil { | |
| 166 | 112 | return false | |
| @@ -182,64 +128,6 @@ func (s *session) registerAttachment(ac *attachedClient) bool { | |||
| 182 | 128 | return s.registerAttachmentLocked(ac) | |
| 183 | 129 | } | |
| 184 | 130 | ||
| 185 | - | // claimGeometryOwner records an explicit resize claim after the attachment | |
| 186 | - | // has already published its new local size. It is also used by callers that | |
| 187 | - | // bypass the wire resize path. | |
| 188 | - | func (s *session) claimGeometryOwner(ac *attachedClient) (uint64, bool) { | |
| 189 | - | if ac == nil { | |
| 190 | - | return 0, false | |
| 191 | - | } | |
| 192 | - | return s.claimGeometryOwnerForGeometry(ac, ac.geometrySnapshot()) | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | // claimGeometryOwnerForSize records a requested shared geometry independently | |
| 196 | - | // of the attachment-local publication. Transactional resize commits use this | |
| 197 | - | // before the PTY phase so a stale request cannot publish after a newer claim. | |
| 198 | - | func (s *session) claimGeometryOwnerForSize(ac *attachedClient, size domain.Size) (uint64, bool) { | |
| 199 | - | if ac == nil { | |
| 200 | - | return 0, false | |
| 201 | - | } | |
| 202 | - | // Layout and floating transactions carry only their shared cell size. Keep | |
| 203 | - | // the claimant's terminal pixels when those operations re-claim authority; | |
| 204 | - | // replacing the geometry with a cell-only value would make a later fallback | |
| 205 | - | // lose the terminal's pixel dimensions. | |
| 206 | - | geometry := ac.geometrySnapshot() | |
| 207 | - | geometry.Size = size | |
| 208 | - | return s.claimGeometryOwnerForGeometry(ac, geometry) | |
| 209 | - | } | |
| 210 | - | ||
| 211 | - | // claimGeometryOwnerForGeometry records a requested shared cell and pixel | |
| 212 | - | // geometry independently of attachment-local publication. | |
| 213 | - | func (s *session) claimGeometryOwnerForGeometry(ac *attachedClient, geometry domain.Geometry) (uint64, bool) { | |
| 214 | - | if s == nil || ac == nil || !geometry.Valid() { | |
| 215 | - | return 0, false | |
| 216 | - | } | |
| 217 | - | s.mu.Lock() | |
| 218 | - | defer s.mu.Unlock() | |
| 219 | - | if _, ok := s.attachments[ac]; !ok { | |
| 220 | - | return 0, false | |
| 221 | - | } | |
| 222 | - | return s.claimGeometryOwnerLocked(ac, geometry.NormalizePixels()), true | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | // releaseGeometryClaim drops a claim that never reached the coordinator's | |
| 226 | - | // resize request. It never creates a new claim; an older valid peer may become | |
| 227 | - | // the source again, or the session retains its headless geometry. | |
| 228 | - | func (s *session) releaseGeometryClaim(ac *attachedClient, claim uint64) { | |
| 229 | - | if s == nil || ac == nil || claim == 0 { | |
| 230 | - | return | |
| 231 | - | } | |
| 232 | - | s.mu.Lock() | |
| 233 | - | defer s.mu.Unlock() | |
| 234 | - | if s.geometryOwner.Load() != ac || ac.geometryClaim.Load() != claim { | |
| 235 | - | return | |
| 236 | - | } | |
| 237 | - | ac.clearGeometryClaim() | |
| 238 | - | s.geometryMu.Lock() | |
| 239 | - | s.geometryOwner.Store(s.latestValidGeometryOwnerLocked(ac)) | |
| 240 | - | s.geometryMu.Unlock() | |
| 241 | - | } | |
| 242 | - | ||
| 243 | 131 | // unregisterAttachmentLocked removes only ac and leaves every other | |
| 244 | 132 | // attachment's view intact. Caller holds s.mu. | |
| 245 | 133 | func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool { | |
| @@ -258,7 +146,7 @@ func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool { | |||
| 258 | 146 | } | |
| 259 | 147 | delete(c.attachments, ac) | |
| 260 | 148 | delete(c.attachmentOrder, ac) | |
| 261 | - | c.refreshGeometryOwnerLocked() | |
| 149 | + | c.geometry.removeAttachmentLocked(c, ac) | |
| 262 | 150 | return true | |
| 263 | 151 | } | |
| 264 | 152 | ||
| @@ -101,66 +101,12 @@ func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool { | |||||
| 101 | c.nextAttachmentID++ | 101 | c.nextAttachmentID++ | ||
| 102 | c.attachments[ac] = struct{}{} | 102 | c.attachments[ac] = struct{}{} | ||
| 103 | c.attachmentOrder[ac] = c.nextAttachmentID | 103 | c.attachmentOrder[ac] = c.nextAttachmentID | ||
| 104 | - | if c.claimGeometryOwnerLocked(ac, ac.geometrySnapshot()) == 0 { | 104 | + | if c.geometry.claimLocked(c, ac, ac.geometrySnapshot()) == nil { |
| 105 | - | c.refreshGeometryOwnerLocked() | 105 | + | c.geometry.refreshLocked(c) |
| 106 | } | 106 | } | ||
| 107 | return true | 107 | return true | ||
| 108 | } | 108 | } | ||
| 109 | 109 | ||||
| 110 | - | // latestValidGeometryOwnerLocked selects the most recent valid attachment, | |||
| 111 | - | // excluding one claimant when a rejected request is being released. Caller | |||
| 112 | - | // holds the owning session's mu. | |||
| 113 | - | func (c *sessionCore) latestValidGeometryOwnerLocked(exclude *attachedClient) *attachedClient { | |||
| 114 | - | var owner *attachedClient | |||
| 115 | - | var ownerClaim, ownerOrder uint64 | |||
| 116 | - | for candidate := range c.attachments { | |||
| 117 | - | if candidate == nil || candidate == exclude || !candidate.geometrySnapshot().Valid() || !candidate.geometryClaimSnapshot().Valid() { | |||
| 118 | - | continue | |||
| 119 | - | } | |||
| 120 | - | claim := candidate.geometryClaim.Load() | |||
| 121 | - | order := c.attachmentOrder[candidate] | |||
| 122 | - | if owner == nil || claim > ownerClaim || (claim == ownerClaim && order > ownerOrder) { | |||
| 123 | - | owner = candidate | |||
| 124 | - | ownerClaim = claim | |||
| 125 | - | ownerOrder = order | |||
| 126 | - | } | |||
| 127 | - | } | |||
| 128 | - | return owner | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | // refreshGeometryOwnerLocked repairs a removed or invalid owner without | |||
| 132 | - | // inventing a newer claim. Caller holds the owning session's mu. | |||
| 133 | - | func (c *sessionCore) refreshGeometryOwnerLocked() { | |||
| 134 | - | if c == nil { | |||
| 135 | - | return | |||
| 136 | - | } | |||
| 137 | - | owner := c.geometryOwner.Load() | |||
| 138 | - | if owner != nil { | |||
| 139 | - | if _, ok := c.attachments[owner]; ok && owner.geometrySnapshot().Valid() && owner.geometryClaimSnapshot().Valid() { | |||
| 140 | - | return | |||
| 141 | - | } | |||
| 142 | - | } | |||
| 143 | - | c.geometryMu.Lock() | |||
| 144 | - | c.geometryOwner.Store(c.latestValidGeometryOwnerLocked(nil)) | |||
| 145 | - | c.geometryMu.Unlock() | |||
| 146 | - | } | |||
| 147 | - | ||||
| 148 | - | // claimGeometryOwnerLocked records the latest attachment claim. Caller holds | |||
| 149 | - | // the owning session's mu; the atomic publication lets resize transactions | |||
| 150 | - | // validate the claim while tab locks are held. | |||
| 151 | - | func (c *sessionCore) claimGeometryOwnerLocked(ac *attachedClient, geometry domain.Geometry) uint64 { | |||
| 152 | - | if c == nil || ac == nil || !geometry.Valid() { | |||
| 153 | - | return 0 | |||
| 154 | - | } | |||
| 155 | - | c.geometryMu.Lock() | |||
| 156 | - | defer c.geometryMu.Unlock() | |||
| 157 | - | c.geometryClaimSeq++ | |||
| 158 | - | claim := c.geometryClaimSeq | |||
| 159 | - | ac.publishGeometryClaim(geometry, claim) | |||
| 160 | - | c.geometryOwner.Store(ac) | |||
| 161 | - | return claim | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool { | 110 | func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool { | ||
| 165 | if entry == nil || entry.core() == nil { | 111 | if entry == nil || entry.core() == nil { | ||
| 166 | return false | 112 | return false | ||
| @@ -182,64 +128,6 @@ func (s *session) registerAttachment(ac *attachedClient) bool { | |||||
| 182 | return s.registerAttachmentLocked(ac) | 128 | return s.registerAttachmentLocked(ac) | ||
| 183 | } | 129 | } | ||
| 184 | 130 | ||||
| 185 | - | // claimGeometryOwner records an explicit resize claim after the attachment | |||
| 186 | - | // has already published its new local size. It is also used by callers that | |||
| 187 | - | // bypass the wire resize path. | |||
| 188 | - | func (s *session) claimGeometryOwner(ac *attachedClient) (uint64, bool) { | |||
| 189 | - | if ac == nil { | |||
| 190 | - | return 0, false | |||
| 191 | - | } | |||
| 192 | - | return s.claimGeometryOwnerForGeometry(ac, ac.geometrySnapshot()) | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | // claimGeometryOwnerForSize records a requested shared geometry independently | |||
| 196 | - | // of the attachment-local publication. Transactional resize commits use this | |||
| 197 | - | // before the PTY phase so a stale request cannot publish after a newer claim. | |||
| 198 | - | func (s *session) claimGeometryOwnerForSize(ac *attachedClient, size domain.Size) (uint64, bool) { | |||
| 199 | - | if ac == nil { | |||
| 200 | - | return 0, false | |||
| 201 | - | } | |||
| 202 | - | // Layout and floating transactions carry only their shared cell size. Keep | |||
| 203 | - | // the claimant's terminal pixels when those operations re-claim authority; | |||
| 204 | - | // replacing the geometry with a cell-only value would make a later fallback | |||
| 205 | - | // lose the terminal's pixel dimensions. | |||
| 206 | - | geometry := ac.geometrySnapshot() | |||
| 207 | - | geometry.Size = size | |||
| 208 | - | return s.claimGeometryOwnerForGeometry(ac, geometry) | |||
| 209 | - | } | |||
| 210 | - | ||||
| 211 | - | // claimGeometryOwnerForGeometry records a requested shared cell and pixel | |||
| 212 | - | // geometry independently of attachment-local publication. | |||
| 213 | - | func (s *session) claimGeometryOwnerForGeometry(ac *attachedClient, geometry domain.Geometry) (uint64, bool) { | |||
| 214 | - | if s == nil || ac == nil || !geometry.Valid() { | |||
| 215 | - | return 0, false | |||
| 216 | - | } | |||
| 217 | - | s.mu.Lock() | |||
| 218 | - | defer s.mu.Unlock() | |||
| 219 | - | if _, ok := s.attachments[ac]; !ok { | |||
| 220 | - | return 0, false | |||
| 221 | - | } | |||
| 222 | - | return s.claimGeometryOwnerLocked(ac, geometry.NormalizePixels()), true | |||
| 223 | - | } | |||
| 224 | - | ||||
| 225 | - | // releaseGeometryClaim drops a claim that never reached the coordinator's | |||
| 226 | - | // resize request. It never creates a new claim; an older valid peer may become | |||
| 227 | - | // the source again, or the session retains its headless geometry. | |||
| 228 | - | func (s *session) releaseGeometryClaim(ac *attachedClient, claim uint64) { | |||
| 229 | - | if s == nil || ac == nil || claim == 0 { | |||
| 230 | - | return | |||
| 231 | - | } | |||
| 232 | - | s.mu.Lock() | |||
| 233 | - | defer s.mu.Unlock() | |||
| 234 | - | if s.geometryOwner.Load() != ac || ac.geometryClaim.Load() != claim { | |||
| 235 | - | return | |||
| 236 | - | } | |||
| 237 | - | ac.clearGeometryClaim() | |||
| 238 | - | s.geometryMu.Lock() | |||
| 239 | - | s.geometryOwner.Store(s.latestValidGeometryOwnerLocked(ac)) | |||
| 240 | - | s.geometryMu.Unlock() | |||
| 241 | - | } | |||
| 242 | - | ||||
| 243 | // unregisterAttachmentLocked removes only ac and leaves every other | 131 | // unregisterAttachmentLocked removes only ac and leaves every other | ||
| 244 | // attachment's view intact. Caller holds s.mu. | 132 | // attachment's view intact. Caller holds s.mu. | ||
| 245 | func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool { | 133 | func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool { | ||
| @@ -258,7 +146,7 @@ func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool { | |||||
| 258 | } | 146 | } | ||
| 259 | delete(c.attachments, ac) | 147 | delete(c.attachments, ac) | ||
| 260 | delete(c.attachmentOrder, ac) | 148 | delete(c.attachmentOrder, ac) | ||
| 261 | - | c.refreshGeometryOwnerLocked() | 149 | + | c.geometry.removeAttachmentLocked(c, ac) |
| 262 | return true | 150 | return true | ||
| 263 | } | 151 | } | ||
| 264 | 152 | ||||
M internal/usecase/daemon/attachment_render_test.go +5 -5
| @@ -334,9 +334,9 @@ func TestStaleAttachmentGeometryClaimCannotCommit(t *testing.T) { | |||
| 334 | 334 | return | |
| 335 | 335 | } | |
| 336 | 336 | superseded = true | |
| 337 | - | sess.claimGeometryOwner(second) | |
| 337 | + | sess.geometry.claimAttachment(sess, second) | |
| 338 | 338 | } | |
| 339 | - | require.False(t, d.recalculateSessionGeometry(sess, first), "a newer attachment claim must reject the stale layout commit") | |
| 339 | + | require.False(t, sess.geometry.reconcile(d, sess, first), "a newer attachment claim must reject the stale layout commit") | |
| 340 | 340 | ||
| 341 | 341 | tb := sess.tabs[0] | |
| 342 | 342 | tb.mu.Lock() | |
| @@ -344,7 +344,7 @@ func TestStaleAttachmentGeometryClaimCannotCommit(t *testing.T) { | |||
| 344 | 344 | tb.mu.Unlock() | |
| 345 | 345 | ||
| 346 | 346 | d.beforeSessionResizePublication = nil | |
| 347 | - | require.True(t, d.recalculateSessionGeometry(sess, nil)) | |
| 347 | + | require.True(t, sess.geometry.reconcile(d, sess, nil)) | |
| 348 | 348 | tb.mu.Lock() | |
| 349 | 349 | require.Equal(t, domain.Size{Cols: 90, Rows: 28}, tb.size) | |
| 350 | 350 | tb.mu.Unlock() | |
| @@ -358,9 +358,9 @@ func TestAttachmentMoveReconcilesSourceGeometryAfterOwnerRemoval(t *testing.T) { | |||
| 358 | 358 | require.True(t, source.registerAttachment(peer)) | |
| 359 | 359 | ||
| 360 | 360 | moved.setSize(domain.Size{Cols: 120, Rows: 40}) | |
| 361 | - | _, claimed := source.claimGeometryOwner(moved) | |
| 361 | + | _, claimed := source.geometry.claimAttachment(source, moved) | |
| 362 | 362 | require.True(t, claimed) | |
| 363 | - | require.True(t, d.recalculateSessionGeometry(source, nil)) | |
| 363 | + | require.True(t, source.geometry.reconcile(d, source, nil)) | |
| 364 | 364 | ||
| 365 | 365 | target := &session{ | |
| 366 | 366 | sessionCore: sessionCore{id: "target-geometry", name: "target-geometry"}, | |
| @@ -334,9 +334,9 @@ func TestStaleAttachmentGeometryClaimCannotCommit(t *testing.T) { | |||||
| 334 | return | 334 | return | ||
| 335 | } | 335 | } | ||
| 336 | superseded = true | 336 | superseded = true | ||
| 337 | - | sess.claimGeometryOwner(second) | 337 | + | sess.geometry.claimAttachment(sess, second) |
| 338 | } | 338 | } | ||
| 339 | - | require.False(t, d.recalculateSessionGeometry(sess, first), "a newer attachment claim must reject the stale layout commit") | 339 | + | require.False(t, sess.geometry.reconcile(d, sess, first), "a newer attachment claim must reject the stale layout commit") |
| 340 | 340 | ||||
| 341 | tb := sess.tabs[0] | 341 | tb := sess.tabs[0] | ||
| 342 | tb.mu.Lock() | 342 | tb.mu.Lock() | ||
| @@ -344,7 +344,7 @@ func TestStaleAttachmentGeometryClaimCannotCommit(t *testing.T) { | |||||
| 344 | tb.mu.Unlock() | 344 | tb.mu.Unlock() | ||
| 345 | 345 | ||||
| 346 | d.beforeSessionResizePublication = nil | 346 | d.beforeSessionResizePublication = nil | ||
| 347 | - | require.True(t, d.recalculateSessionGeometry(sess, nil)) | 347 | + | require.True(t, sess.geometry.reconcile(d, sess, nil)) |
| 348 | tb.mu.Lock() | 348 | tb.mu.Lock() | ||
| 349 | require.Equal(t, domain.Size{Cols: 90, Rows: 28}, tb.size) | 349 | require.Equal(t, domain.Size{Cols: 90, Rows: 28}, tb.size) | ||
| 350 | tb.mu.Unlock() | 350 | tb.mu.Unlock() | ||
| @@ -358,9 +358,9 @@ func TestAttachmentMoveReconcilesSourceGeometryAfterOwnerRemoval(t *testing.T) { | |||||
| 358 | require.True(t, source.registerAttachment(peer)) | 358 | require.True(t, source.registerAttachment(peer)) | ||
| 359 | 359 | ||||
| 360 | moved.setSize(domain.Size{Cols: 120, Rows: 40}) | 360 | moved.setSize(domain.Size{Cols: 120, Rows: 40}) | ||
| 361 | - | _, claimed := source.claimGeometryOwner(moved) | 361 | + | _, claimed := source.geometry.claimAttachment(source, moved) |
| 362 | require.True(t, claimed) | 362 | require.True(t, claimed) | ||
| 363 | - | require.True(t, d.recalculateSessionGeometry(source, nil)) | 363 | + | require.True(t, source.geometry.reconcile(d, source, nil)) |
| 364 | 364 | ||||
| 365 | target := &session{ | 365 | target := &session{ | ||
| 366 | sessionCore: sessionCore{id: "target-geometry", name: "target-geometry"}, | 366 | sessionCore: sessionCore{id: "target-geometry", name: "target-geometry"}, | ||
M internal/usecase/daemon/attachment_transition.go +1 -1
| @@ -168,7 +168,7 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||
| 168 | 168 | } | |
| 169 | 169 | } | |
| 170 | 170 | if result.sourceGeometrySession != nil { | |
| 171 | - | d.recalculateSessionGeometryAndInvalidateAsync(result.sourceGeometrySession, "attachment_transition.go") | |
| 171 | + | result.sourceGeometrySession.geometry.reconcileAndInvalidateAsync(d, result.sourceGeometrySession, "attachment_transition.go") | |
| 172 | 172 | } | |
| 173 | 173 | return result, nil | |
| 174 | 174 | } | |
| @@ -168,7 +168,7 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||||
| 168 | } | 168 | } | ||
| 169 | } | 169 | } | ||
| 170 | if result.sourceGeometrySession != nil { | 170 | if result.sourceGeometrySession != nil { | ||
| 171 | - | d.recalculateSessionGeometryAndInvalidateAsync(result.sourceGeometrySession, "attachment_transition.go") | 171 | + | result.sourceGeometrySession.geometry.reconcileAndInvalidateAsync(d, result.sourceGeometrySession, "attachment_transition.go") |
| 172 | } | 172 | } | ||
| 173 | return result, nil | 173 | return result, nil | ||
| 174 | } | 174 | } | ||
M internal/usecase/daemon/attachment_transition_publish.go +1 -1
| @@ -192,7 +192,7 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |||
| 192 | 192 | if !registered && publication.source == req.target && !req.preserveAttachment { | |
| 193 | 193 | // A same-session rebind keeps membership but still represents a fresh | |
| 194 | 194 | // attachment claim with the replacement transport's current size. | |
| 195 | - | req.target.claimGeometryOwnerLocked(req.next, req.next.geometrySnapshot()) | |
| 195 | + | req.target.geometry.claimLocked(req.target.core(), req.next, req.next.geometrySnapshot()) | |
| 196 | 196 | } | |
| 197 | 197 | req.next.setSession(req.target) | |
| 198 | 198 | } | |
| @@ -192,7 +192,7 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) { | |||||
| 192 | if !registered && publication.source == req.target && !req.preserveAttachment { | 192 | if !registered && publication.source == req.target && !req.preserveAttachment { | ||
| 193 | // A same-session rebind keeps membership but still represents a fresh | 193 | // A same-session rebind keeps membership but still represents a fresh | ||
| 194 | // attachment claim with the replacement transport's current size. | 194 | // attachment claim with the replacement transport's current size. | ||
| 195 | - | req.target.claimGeometryOwnerLocked(req.next, req.next.geometrySnapshot()) | 195 | + | req.target.geometry.claimLocked(req.target.core(), req.next, req.next.geometrySnapshot()) |
| 196 | } | 196 | } | ||
| 197 | req.next.setSession(req.target) | 197 | req.next.setSession(req.target) | ||
| 198 | } | 198 | } | ||
M internal/usecase/daemon/client.go +9 -56
| @@ -68,16 +68,10 @@ type attachedClient struct { | |||
| 68 | 68 | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | |
| 69 | 69 | // sizeMu lets shared-geometry snapshots read attachment windows without | |
| 70 | 70 | // waiting behind a blocked transport send. | |
| 71 | - | sizeMu sync.RWMutex | |
| 72 | - | size domain.Size // retained for legacy fixtures; live paths publish geometry | |
| 73 | - | geometry domain.Geometry // controlling-terminal geometry for this attachment | |
| 74 | - | geometryClaimGeometry domain.Geometry // protected by sizeMu; shared geometry claim snapshot | |
| 75 | - | geometryClaimInitialized bool // protected by sizeMu; distinguishes legacy fixtures | |
| 76 | - | // geometryClaim is an attachment-local monotonic claim sequence. The | |
| 77 | - | // owning session publishes it whenever this attachment is attached, | |
| 78 | - | // resumed, or explicitly resized. | |
| 79 | - | geometryClaim atomic.Uint64 | |
| 80 | - | keys *keys.Router | |
| 71 | + | sizeMu sync.RWMutex | |
| 72 | + | size domain.Size // retained for legacy fixtures; live paths publish geometry | |
| 73 | + | geometry domain.Geometry // controlling-terminal geometry for this Attachment | |
| 74 | + | keys *keys.Router | |
| 81 | 75 | // view is attachment-local navigation state. It is never inferred from a | |
| 82 | 76 | // session-wide active tab, so multiple attachments can observe different | |
| 83 | 77 | // tabs and panes without changing shared session ownership. | |
| @@ -200,47 +194,6 @@ func (ac *attachedClient) setGeometry(geometry domain.Geometry) { | |||
| 200 | 194 | ac.sizeMu.Unlock() | |
| 201 | 195 | } | |
| 202 | 196 | ||
| 203 | - | func (ac *attachedClient) geometryClaimSnapshot() domain.Geometry { | |
| 204 | - | if ac == nil { | |
| 205 | - | return domain.Geometry{} | |
| 206 | - | } | |
| 207 | - | ac.sizeMu.RLock() | |
| 208 | - | claimGeometry, localGeometry, localSize := ac.geometryClaimGeometry, ac.geometry, ac.size | |
| 209 | - | initialized := ac.geometryClaimInitialized | |
| 210 | - | ac.sizeMu.RUnlock() | |
| 211 | - | if !initialized { | |
| 212 | - | if localGeometry.Valid() { | |
| 213 | - | return localGeometry | |
| 214 | - | } | |
| 215 | - | return domain.Geometry{Size: localSize} | |
| 216 | - | } | |
| 217 | - | return claimGeometry | |
| 218 | - | } | |
| 219 | - | ||
| 220 | - | // publishGeometryClaim makes the claim geometry and sequence visible as one | |
| 221 | - | // attachment-local publication under sizeMu. | |
| 222 | - | func (ac *attachedClient) publishGeometryClaim(geometry domain.Geometry, claim uint64) { | |
| 223 | - | if ac == nil { | |
| 224 | - | return | |
| 225 | - | } | |
| 226 | - | ac.sizeMu.Lock() | |
| 227 | - | ac.geometryClaimGeometry = geometry.NormalizePixels() | |
| 228 | - | ac.geometryClaimInitialized = true | |
| 229 | - | ac.geometryClaim.Store(claim) | |
| 230 | - | ac.sizeMu.Unlock() | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | func (ac *attachedClient) clearGeometryClaim() { | |
| 234 | - | if ac == nil { | |
| 235 | - | return | |
| 236 | - | } | |
| 237 | - | ac.sizeMu.Lock() | |
| 238 | - | ac.geometryClaimGeometry = domain.Geometry{} | |
| 239 | - | ac.geometryClaimInitialized = true | |
| 240 | - | ac.geometryClaim.Store(0) | |
| 241 | - | ac.sizeMu.Unlock() | |
| 242 | - | } | |
| 243 | - | ||
| 244 | 197 | // currentSession returns the attachment's owning local session. | |
| 245 | 198 | func (ac *attachedClient) currentSession() *session { | |
| 246 | 199 | if ac == nil { | |
| @@ -912,9 +865,9 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, lease *a | |||
| 912 | 865 | // PTY.Resize is an external, non-cancelable operation. Do not hold the | |
| 913 | 866 | // handshake on it; the first frame can use retained geometry and the | |
| 914 | 867 | // shared invalidation will repaint every attachment after the claim wins. | |
| 915 | - | d.recalculateSessionGeometryAndInvalidateAsync(sess, "client.go") | |
| 868 | + | sess.geometry.reconcileAndInvalidateAsync(d, sess, "client.go") | |
| 916 | 869 | } else { | |
| 917 | - | d.recalculateSessionGeometryAndInvalidate(sess, nil, "client.go") | |
| 870 | + | sess.geometry.reconcileAndInvalidate(d, sess, nil, "client.go") | |
| 918 | 871 | } | |
| 919 | 872 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 920 | 873 | // Floating activation may perform its own pane-local warmup after the shared | |
| @@ -976,7 +929,7 @@ func (d *Daemon) applyThemeForAttachment(effect *attachmentEffect, msg ports.The | |||
| 976 | 929 | } | |
| 977 | 930 | ||
| 978 | 931 | func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |
| 979 | - | d.requestTransactionalResize(sess, ac, sz, false) | |
| 932 | + | sess.geometry.requestResize(d, sess, ac, sz, false) | |
| 980 | 933 | } | |
| 981 | 934 | ||
| 982 | 935 | // resizeAttachmentForLease updates the sender's window/view, then makes that | |
| @@ -1040,7 +993,7 @@ func (d *Daemon) resizeAttachmentGeometryForLease(effect *attachmentEffect, geom | |||
| 1040 | 993 | // When this resize changes canonical geometry, the shared invalidation fans | |
| 1041 | 994 | // out a fresh frame to every attachment; otherwise only this attachment | |
| 1042 | 995 | // needs a viewport redraw. | |
| 1043 | - | if d.recalculateSessionGeometryAndInvalidate(sess, ac, "client.go") { | |
| 996 | + | if sess.geometry.reconcileAndInvalidate(d, sess, ac, "client.go") { | |
| 1044 | 997 | return true | |
| 1045 | 998 | } | |
| 1046 | 999 | if sameGeometry { | |
| @@ -1157,7 +1110,7 @@ func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domai | |||
| 1157 | 1110 | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | |
| 1158 | 1111 | // returned value reports whether the synchronous request was accepted. | |
| 1159 | 1112 | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool { | |
| 1160 | - | return d.requestTransactionalResize(sess, ac, sz, true) | |
| 1113 | + | return sess.geometry.requestResize(d, sess, ac, sz, true) | |
| 1161 | 1114 | } | |
| 1162 | 1115 | ||
| 1163 | 1116 | // killSession removes a session and tears down its resources. It is | |
| @@ -68,16 +68,10 @@ type attachedClient struct { | |||||
| 68 | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | 68 | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | ||
| 69 | // sizeMu lets shared-geometry snapshots read attachment windows without | 69 | // sizeMu lets shared-geometry snapshots read attachment windows without | ||
| 70 | // waiting behind a blocked transport send. | 70 | // waiting behind a blocked transport send. | ||
| 71 | - | sizeMu sync.RWMutex | 71 | + | sizeMu sync.RWMutex |
| 72 | - | size domain.Size // retained for legacy fixtures; live paths publish geometry | 72 | + | size domain.Size // retained for legacy fixtures; live paths publish geometry |
| 73 | - | geometry domain.Geometry // controlling-terminal geometry for this attachment | 73 | + | geometry domain.Geometry // controlling-terminal geometry for this Attachment |
| 74 | - | geometryClaimGeometry domain.Geometry // protected by sizeMu; shared geometry claim snapshot | 74 | + | keys *keys.Router |
| 75 | - | geometryClaimInitialized bool // protected by sizeMu; distinguishes legacy fixtures | |||
| 76 | - | // geometryClaim is an attachment-local monotonic claim sequence. The | |||
| 77 | - | // owning session publishes it whenever this attachment is attached, | |||
| 78 | - | // resumed, or explicitly resized. | |||
| 79 | - | geometryClaim atomic.Uint64 | |||
| 80 | - | keys *keys.Router | |||
| 81 | // view is attachment-local navigation state. It is never inferred from a | 75 | // view is attachment-local navigation state. It is never inferred from a | ||
| 82 | // session-wide active tab, so multiple attachments can observe different | 76 | // session-wide active tab, so multiple attachments can observe different | ||
| 83 | // tabs and panes without changing shared session ownership. | 77 | // tabs and panes without changing shared session ownership. | ||
| @@ -200,47 +194,6 @@ func (ac *attachedClient) setGeometry(geometry domain.Geometry) { | |||||
| 200 | ac.sizeMu.Unlock() | 194 | ac.sizeMu.Unlock() | ||
| 201 | } | 195 | } | ||
| 202 | 196 | ||||
| 203 | - | func (ac *attachedClient) geometryClaimSnapshot() domain.Geometry { | |||
| 204 | - | if ac == nil { | |||
| 205 | - | return domain.Geometry{} | |||
| 206 | - | } | |||
| 207 | - | ac.sizeMu.RLock() | |||
| 208 | - | claimGeometry, localGeometry, localSize := ac.geometryClaimGeometry, ac.geometry, ac.size | |||
| 209 | - | initialized := ac.geometryClaimInitialized | |||
| 210 | - | ac.sizeMu.RUnlock() | |||
| 211 | - | if !initialized { | |||
| 212 | - | if localGeometry.Valid() { | |||
| 213 | - | return localGeometry | |||
| 214 | - | } | |||
| 215 | - | return domain.Geometry{Size: localSize} | |||
| 216 | - | } | |||
| 217 | - | return claimGeometry | |||
| 218 | - | } | |||
| 219 | - | ||||
| 220 | - | // publishGeometryClaim makes the claim geometry and sequence visible as one | |||
| 221 | - | // attachment-local publication under sizeMu. | |||
| 222 | - | func (ac *attachedClient) publishGeometryClaim(geometry domain.Geometry, claim uint64) { | |||
| 223 | - | if ac == nil { | |||
| 224 | - | return | |||
| 225 | - | } | |||
| 226 | - | ac.sizeMu.Lock() | |||
| 227 | - | ac.geometryClaimGeometry = geometry.NormalizePixels() | |||
| 228 | - | ac.geometryClaimInitialized = true | |||
| 229 | - | ac.geometryClaim.Store(claim) | |||
| 230 | - | ac.sizeMu.Unlock() | |||
| 231 | - | } | |||
| 232 | - | ||||
| 233 | - | func (ac *attachedClient) clearGeometryClaim() { | |||
| 234 | - | if ac == nil { | |||
| 235 | - | return | |||
| 236 | - | } | |||
| 237 | - | ac.sizeMu.Lock() | |||
| 238 | - | ac.geometryClaimGeometry = domain.Geometry{} | |||
| 239 | - | ac.geometryClaimInitialized = true | |||
| 240 | - | ac.geometryClaim.Store(0) | |||
| 241 | - | ac.sizeMu.Unlock() | |||
| 242 | - | } | |||
| 243 | - | ||||
| 244 | // currentSession returns the attachment's owning local session. | 197 | // currentSession returns the attachment's owning local session. | ||
| 245 | func (ac *attachedClient) currentSession() *session { | 198 | func (ac *attachedClient) currentSession() *session { | ||
| 246 | if ac == nil { | 199 | if ac == nil { | ||
| @@ -912,9 +865,9 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, lease *a | |||||
| 912 | // PTY.Resize is an external, non-cancelable operation. Do not hold the | 865 | // PTY.Resize is an external, non-cancelable operation. Do not hold the | ||
| 913 | // handshake on it; the first frame can use retained geometry and the | 866 | // handshake on it; the first frame can use retained geometry and the | ||
| 914 | // shared invalidation will repaint every attachment after the claim wins. | 867 | // shared invalidation will repaint every attachment after the claim wins. | ||
| 915 | - | d.recalculateSessionGeometryAndInvalidateAsync(sess, "client.go") | 868 | + | sess.geometry.reconcileAndInvalidateAsync(d, sess, "client.go") |
| 916 | } else { | 869 | } else { | ||
| 917 | - | d.recalculateSessionGeometryAndInvalidate(sess, nil, "client.go") | 870 | + | sess.geometry.reconcileAndInvalidate(d, sess, nil, "client.go") |
| 918 | } | 871 | } | ||
| 919 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | 872 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | ||
| 920 | // Floating activation may perform its own pane-local warmup after the shared | 873 | // Floating activation may perform its own pane-local warmup after the shared | ||
| @@ -976,7 +929,7 @@ func (d *Daemon) applyThemeForAttachment(effect *attachmentEffect, msg ports.The | |||||
| 976 | } | 929 | } | ||
| 977 | 930 | ||||
| 978 | func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | 931 | func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | ||
| 979 | - | d.requestTransactionalResize(sess, ac, sz, false) | 932 | + | sess.geometry.requestResize(d, sess, ac, sz, false) |
| 980 | } | 933 | } | ||
| 981 | 934 | ||||
| 982 | // resizeAttachmentForLease updates the sender's window/view, then makes that | 935 | // resizeAttachmentForLease updates the sender's window/view, then makes that | ||
| @@ -1040,7 +993,7 @@ func (d *Daemon) resizeAttachmentGeometryForLease(effect *attachmentEffect, geom | |||||
| 1040 | // When this resize changes canonical geometry, the shared invalidation fans | 993 | // When this resize changes canonical geometry, the shared invalidation fans | ||
| 1041 | // out a fresh frame to every attachment; otherwise only this attachment | 994 | // out a fresh frame to every attachment; otherwise only this attachment | ||
| 1042 | // needs a viewport redraw. | 995 | // needs a viewport redraw. | ||
| 1043 | - | if d.recalculateSessionGeometryAndInvalidate(sess, ac, "client.go") { | 996 | + | if sess.geometry.reconcileAndInvalidate(d, sess, ac, "client.go") { |
| 1044 | return true | 997 | return true | ||
| 1045 | } | 998 | } | ||
| 1046 | if sameGeometry { | 999 | if sameGeometry { | ||
| @@ -1157,7 +1110,7 @@ func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domai | |||||
| 1157 | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | 1110 | // resizeForFirstPaint retains attach's synchronous geometry guarantee. The | ||
| 1158 | // returned value reports whether the synchronous request was accepted. | 1111 | // returned value reports whether the synchronous request was accepted. | ||
| 1159 | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool { | 1112 | func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool { | ||
| 1160 | - | return d.requestTransactionalResize(sess, ac, sz, true) | 1113 | + | return sess.geometry.requestResize(d, sess, ac, sz, true) |
| 1161 | } | 1114 | } | ||
| 1162 | 1115 | ||||
| 1163 | // killSession removes a session and tears down its resources. It is | 1116 | // killSession removes a session and tears down its resources. It is | ||
M internal/usecase/daemon/client_transport_errors.go +2 -2
| @@ -74,7 +74,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port | |||
| 74 | 74 | if rc := sess.renderCoordinator(); rc != nil { | |
| 75 | 75 | rc.noteDetach(ac) | |
| 76 | 76 | } | |
| 77 | - | d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go") | |
| 77 | + | sess.geometry.reconcileAndInvalidate(d, sess, nil, "client_transport_errors.go") | |
| 78 | 78 | sess.mu.Lock() | |
| 79 | 79 | ephemeral := sess.ephemeral | |
| 80 | 80 | name := sess.name | |
| @@ -182,7 +182,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed | |||
| 182 | 182 | if rc := sess.renderCoordinator(); rc != nil { | |
| 183 | 183 | rc.noteDetach(ac) | |
| 184 | 184 | } | |
| 185 | - | d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go") | |
| 185 | + | sess.geometry.reconcileAndInvalidate(d, sess, nil, "client_transport_errors.go") | |
| 186 | 186 | if d.parkAttachment(sess, ac) { | |
| 187 | 187 | _ = ac.closeCapturedTransport(failed) | |
| 188 | 188 | d.log.Warn("parked client after send error", "session", name) | |
| @@ -74,7 +74,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port | |||||
| 74 | if rc := sess.renderCoordinator(); rc != nil { | 74 | if rc := sess.renderCoordinator(); rc != nil { | ||
| 75 | rc.noteDetach(ac) | 75 | rc.noteDetach(ac) | ||
| 76 | } | 76 | } | ||
| 77 | - | d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go") | 77 | + | sess.geometry.reconcileAndInvalidate(d, sess, nil, "client_transport_errors.go") |
| 78 | sess.mu.Lock() | 78 | sess.mu.Lock() | ||
| 79 | ephemeral := sess.ephemeral | 79 | ephemeral := sess.ephemeral | ||
| 80 | name := sess.name | 80 | name := sess.name | ||
| @@ -182,7 +182,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed | |||||
| 182 | if rc := sess.renderCoordinator(); rc != nil { | 182 | if rc := sess.renderCoordinator(); rc != nil { | ||
| 183 | rc.noteDetach(ac) | 183 | rc.noteDetach(ac) | ||
| 184 | } | 184 | } | ||
| 185 | - | d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go") | 185 | + | sess.geometry.reconcileAndInvalidate(d, sess, nil, "client_transport_errors.go") |
| 186 | if d.parkAttachment(sess, ac) { | 186 | if d.parkAttachment(sess, ac) { | ||
| 187 | _ = ac.closeCapturedTransport(failed) | 187 | _ = ac.closeCapturedTransport(failed) | ||
| 188 | d.log.Warn("parked client after send error", "session", name) | 188 | d.log.Warn("parked client after send error", "session", name) | ||
M internal/usecase/daemon/control.go +1 -1
| @@ -483,7 +483,7 @@ func (e controlExec) CreateSessionNamed(name string) error { | |||
| 483 | 483 | cwd, env := e.sess.cwd, copyEnvironment(e.sess.env) | |
| 484 | 484 | e.sess.mu.Unlock() | |
| 485 | 485 | geometry := domain.Geometry{Size: e.sess.fullViewportSize()} | |
| 486 | - | if source, ok := e.sess.geometrySourceSnapshot(); ok { | |
| 486 | + | if source, ok := e.sess.geometry.sourceSnapshot(e.sess); ok { | |
| 487 | 487 | geometry = source.geometry | |
| 488 | 488 | } | |
| 489 | 489 | e.d.mu.Lock() | |
| @@ -483,7 +483,7 @@ func (e controlExec) CreateSessionNamed(name string) error { | |||||
| 483 | cwd, env := e.sess.cwd, copyEnvironment(e.sess.env) | 483 | cwd, env := e.sess.cwd, copyEnvironment(e.sess.env) | ||
| 484 | e.sess.mu.Unlock() | 484 | e.sess.mu.Unlock() | ||
| 485 | geometry := domain.Geometry{Size: e.sess.fullViewportSize()} | 485 | geometry := domain.Geometry{Size: e.sess.fullViewportSize()} | ||
| 486 | - | if source, ok := e.sess.geometrySourceSnapshot(); ok { | 486 | + | if source, ok := e.sess.geometry.sourceSnapshot(e.sess); ok { |
| 487 | geometry = source.geometry | 487 | geometry = source.geometry | ||
| 488 | } | 488 | } | ||
| 489 | e.d.mu.Lock() | 489 | e.d.mu.Lock() | ||
M internal/usecase/daemon/copy_mouse_input.go +1 -1
| @@ -282,7 +282,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||
| 282 | 282 | } | |
| 283 | 283 | tb.mu.Unlock() | |
| 284 | 284 | if layoutChanged { | |
| 285 | - | d.applyTabLayout(sess, tb) | |
| 285 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 286 | 286 | } | |
| 287 | 287 | if otherPane { | |
| 288 | 288 | d.exitCopyMode(ac) | |
| @@ -282,7 +282,7 @@ func (d *Daemon) handleActiveCopyMouse(sess *session, ac *attachedClient, tb *ta | |||||
| 282 | } | 282 | } | ||
| 283 | tb.mu.Unlock() | 283 | tb.mu.Unlock() | ||
| 284 | if layoutChanged { | 284 | if layoutChanged { | ||
| 285 | - | d.applyTabLayout(sess, tb) | 285 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 286 | } | 286 | } | ||
| 287 | if otherPane { | 287 | if otherPane { | ||
| 288 | d.exitCopyMode(ac) | 288 | d.exitCopyMode(ac) | ||
M internal/usecase/daemon/daemon.go +0 -4
| @@ -152,10 +152,6 @@ type Daemon struct { | |||
| 152 | 152 | // and immediately before a post-commit effect is published. Tests use it to | |
| 153 | 153 | // move a pane through the ordered resize fences at the former TOCTOU window. | |
| 154 | 154 | beforeResizeOwnerPostEffect func(resizeOwnerPostEffect) | |
| 155 | - | // afterResizeCommitSendLocked is a deterministic lock-order seam. It runs | |
| 156 | - | // after publishResizeCommit acquires attachment sendMu and before it acquires | |
| 157 | - | // owner fences. Tests must not perform external work from this callback. | |
| 158 | - | afterResizeCommitSendLocked func() | |
| 159 | 155 | // afterConnectionSessionSnapshot is a deterministic test seam after the | |
| 160 | 156 | // connection loop reads its current session and before it captures that role. | |
| 161 | 157 | afterConnectionSessionSnapshot func(*session) | |
| @@ -152,10 +152,6 @@ type Daemon struct { | |||||
| 152 | // and immediately before a post-commit effect is published. Tests use it to | 152 | // and immediately before a post-commit effect is published. Tests use it to | ||
| 153 | // move a pane through the ordered resize fences at the former TOCTOU window. | 153 | // move a pane through the ordered resize fences at the former TOCTOU window. | ||
| 154 | beforeResizeOwnerPostEffect func(resizeOwnerPostEffect) | 154 | beforeResizeOwnerPostEffect func(resizeOwnerPostEffect) | ||
| 155 | - | // afterResizeCommitSendLocked is a deterministic lock-order seam. It runs | |||
| 156 | - | // after publishResizeCommit acquires attachment sendMu and before it acquires | |||
| 157 | - | // owner fences. Tests must not perform external work from this callback. | |||
| 158 | - | afterResizeCommitSendLocked func() | |||
| 159 | // afterConnectionSessionSnapshot is a deterministic test seam after the | 155 | // afterConnectionSessionSnapshot is a deterministic test seam after the | ||
| 160 | // connection loop reads its current session and before it captures that role. | 156 | // connection loop reads its current session and before it captures that role. | ||
| 161 | afterConnectionSessionSnapshot func(*session) | 157 | afterConnectionSessionSnapshot func(*session) | ||
M internal/usecase/daemon/floating.go +7 -7
| @@ -202,7 +202,7 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes | |||
| 202 | 202 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 203 | 203 | tb.mu.Unlock() | |
| 204 | 204 | if hasFloating { | |
| 205 | - | return d.requestTransactionalResize(sess, nil, size, true) | |
| 205 | + | return sess.geometry.requestResize(d, sess, nil, size, true) | |
| 206 | 206 | } | |
| 207 | 207 | return false | |
| 208 | 208 | } | |
| @@ -216,9 +216,9 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes | |||
| 216 | 216 | tb.mu.Unlock() | |
| 217 | 217 | if hasFloating { | |
| 218 | 218 | if lease != nil { | |
| 219 | - | return d.requestTransactionalResizeForLease(sess, ac, lease, size, true) | |
| 219 | + | return sess.geometry.requestResizeForLease(d, sess, ac, lease, size, true) | |
| 220 | 220 | } | |
| 221 | - | return d.requestTransactionalResize(sess, ac, size, true) | |
| 221 | + | return sess.geometry.requestResize(d, sess, ac, size, true) | |
| 222 | 222 | } | |
| 223 | 223 | return false | |
| 224 | 224 | } | |
| @@ -260,7 +260,7 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | |||
| 260 | 260 | tb.mu.Lock() | |
| 261 | 261 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 262 | 262 | tb.mu.Unlock() | |
| 263 | - | d.requestTransactionalResize(sess, ac, size, true) | |
| 263 | + | sess.geometry.requestResize(d, sess, ac, size, true) | |
| 264 | 264 | } | |
| 265 | 265 | return nil | |
| 266 | 266 | } | |
| @@ -386,7 +386,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 386 | 386 | sessionName: name, | |
| 387 | 387 | cwd: cwd, | |
| 388 | 388 | size: size, | |
| 389 | - | ptyGeometry: sess.paneGeometry(size), | |
| 389 | + | ptyGeometry: sess.geometry.paneGeometry(size), | |
| 390 | 390 | geometry: geometry, | |
| 391 | 391 | paneStableID: paneStableID, | |
| 392 | 392 | env: childEnvFrom(env, name, tabStableID, paneStableID), | |
| @@ -496,14 +496,14 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin | |||
| 496 | 496 | if len(attachments) == 0 { | |
| 497 | 497 | // A headless resize may commit while Open is warming. Reconcile the | |
| 498 | 498 | // visible floating geometry even without an attachment callback. | |
| 499 | - | d.applyVisibleFloatingLayout(sess, tb, nil) | |
| 499 | + | sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) | |
| 500 | 500 | return | |
| 501 | 501 | } | |
| 502 | 502 | tb.mu.Lock() | |
| 503 | 503 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 504 | 504 | tb.mu.Unlock() | |
| 505 | 505 | for _, ac := range attachments { | |
| 506 | - | d.requestTransactionalResize(sess, ac, size, true) | |
| 506 | + | sess.geometry.requestResize(d, sess, ac, size, true) | |
| 507 | 507 | } | |
| 508 | 508 | } | |
| 509 | 509 | } | |
| @@ -202,7 +202,7 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes | |||||
| 202 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | 202 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | ||
| 203 | tb.mu.Unlock() | 203 | tb.mu.Unlock() | ||
| 204 | if hasFloating { | 204 | if hasFloating { | ||
| 205 | - | return d.requestTransactionalResize(sess, nil, size, true) | 205 | + | return sess.geometry.requestResize(d, sess, nil, size, true) |
| 206 | } | 206 | } | ||
| 207 | return false | 207 | return false | ||
| 208 | } | 208 | } | ||
| @@ -216,9 +216,9 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes | |||||
| 216 | tb.mu.Unlock() | 216 | tb.mu.Unlock() | ||
| 217 | if hasFloating { | 217 | if hasFloating { | ||
| 218 | if lease != nil { | 218 | if lease != nil { | ||
| 219 | - | return d.requestTransactionalResizeForLease(sess, ac, lease, size, true) | 219 | + | return sess.geometry.requestResizeForLease(d, sess, ac, lease, size, true) |
| 220 | } | 220 | } | ||
| 221 | - | return d.requestTransactionalResize(sess, ac, size, true) | 221 | + | return sess.geometry.requestResize(d, sess, ac, size, true) |
| 222 | } | 222 | } | ||
| 223 | return false | 223 | return false | ||
| 224 | } | 224 | } | ||
| @@ -260,7 +260,7 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | |||||
| 260 | tb.mu.Lock() | 260 | tb.mu.Lock() | ||
| 261 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | 261 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | ||
| 262 | tb.mu.Unlock() | 262 | tb.mu.Unlock() | ||
| 263 | - | d.requestTransactionalResize(sess, ac, size, true) | 263 | + | sess.geometry.requestResize(d, sess, ac, size, true) |
| 264 | } | 264 | } | ||
| 265 | return nil | 265 | return nil | ||
| 266 | } | 266 | } | ||
| @@ -386,7 +386,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||||
| 386 | sessionName: name, | 386 | sessionName: name, | ||
| 387 | cwd: cwd, | 387 | cwd: cwd, | ||
| 388 | size: size, | 388 | size: size, | ||
| 389 | - | ptyGeometry: sess.paneGeometry(size), | 389 | + | ptyGeometry: sess.geometry.paneGeometry(size), |
| 390 | geometry: geometry, | 390 | geometry: geometry, | ||
| 391 | paneStableID: paneStableID, | 391 | paneStableID: paneStableID, | ||
| 392 | env: childEnvFrom(env, name, tabStableID, paneStableID), | 392 | env: childEnvFrom(env, name, tabStableID, paneStableID), | ||
| @@ -496,14 +496,14 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin | |||||
| 496 | if len(attachments) == 0 { | 496 | if len(attachments) == 0 { | ||
| 497 | // A headless resize may commit while Open is warming. Reconcile the | 497 | // A headless resize may commit while Open is warming. Reconcile the | ||
| 498 | // visible floating geometry even without an attachment callback. | 498 | // visible floating geometry even without an attachment callback. | ||
| 499 | - | d.applyVisibleFloatingLayout(sess, tb, nil) | 499 | + | sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) |
| 500 | return | 500 | return | ||
| 501 | } | 501 | } | ||
| 502 | tb.mu.Lock() | 502 | tb.mu.Lock() | ||
| 503 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | 503 | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | ||
| 504 | tb.mu.Unlock() | 504 | tb.mu.Unlock() | ||
| 505 | for _, ac := range attachments { | 505 | for _, ac := range attachments { | ||
| 506 | - | d.requestTransactionalResize(sess, ac, size, true) | 506 | + | sess.geometry.requestResize(d, sess, ac, size, true) |
| 507 | } | 507 | } | ||
| 508 | } | 508 | } | ||
| 509 | } | 509 | } | ||
M internal/usecase/daemon/floating_render_test.go +7 -4
| @@ -22,7 +22,8 @@ func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometr | |||
| 22 | 22 | return false | |
| 23 | 23 | } | |
| 24 | 24 | plan := preparedTabLayout{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}} | |
| 25 | - | d.applyPreparedTabMembers(&plan) | |
| 25 | + | geometryModule := &sharedPTYGeometry{} | |
| 26 | + | geometryModule.applyPreparedTabMembers(d, &plan) | |
| 26 | 27 | member := plan.members[0] | |
| 27 | 28 | if !member.ok { | |
| 28 | 29 | return false | |
| @@ -200,7 +201,8 @@ func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t | |||
| 200 | 201 | d := newTestDaemon(t, nil, stubClock{}) | |
| 201 | 202 | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 202 | 203 | ||
| 203 | - | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |
| 204 | + | sess := &session{tabs: []*tab{tb}} | |
| 205 | + | failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) | |
| 204 | 206 | require.True(t, ok) | |
| 205 | 207 | require.Empty(t, failed) | |
| 206 | 208 | require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize") | |
| @@ -925,10 +927,11 @@ func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) { | |||
| 925 | 927 | installTestFloating(tb, p, true) | |
| 926 | 928 | d := newTestDaemon(t, nil, stubClock{}) | |
| 927 | 929 | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 930 | + | sess := &session{tabs: []*tab{tb}} | |
| 928 | 931 | ||
| 929 | 932 | done := make(chan bool, 1) | |
| 930 | 933 | go func() { | |
| 931 | - | _, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |
| 934 | + | _, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) | |
| 932 | 935 | done <- ok | |
| 933 | 936 | }() | |
| 934 | 937 | <-pty.entered | |
| @@ -947,7 +950,7 @@ func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) { | |||
| 947 | 950 | require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate") | |
| 948 | 951 | p.mu.Unlock() | |
| 949 | 952 | ||
| 950 | - | _, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |
| 953 | + | _, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) | |
| 951 | 954 | require.True(t, ok) | |
| 952 | 955 | require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes()) | |
| 953 | 956 | require.Equal(t, newGeometry.Inner, p.rect) | |
| @@ -22,7 +22,8 @@ func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometr | |||||
| 22 | return false | 22 | return false | ||
| 23 | } | 23 | } | ||
| 24 | plan := preparedTabLayout{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}} | 24 | plan := preparedTabLayout{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}} | ||
| 25 | - | d.applyPreparedTabMembers(&plan) | 25 | + | geometryModule := &sharedPTYGeometry{} |
| 26 | + | geometryModule.applyPreparedTabMembers(d, &plan) | |||
| 26 | member := plan.members[0] | 27 | member := plan.members[0] | ||
| 27 | if !member.ok { | 28 | if !member.ok { | ||
| 28 | return false | 29 | return false | ||
| @@ -200,7 +201,8 @@ func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t | |||||
| 200 | d := newTestDaemon(t, nil, stubClock{}) | 201 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 201 | d.ApplyConfig(domain.Config{Floating: cfg}) | 202 | d.ApplyConfig(domain.Config{Floating: cfg}) | ||
| 202 | 203 | ||||
| 203 | - | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | 204 | + | sess := &session{tabs: []*tab{tb}} |
| 205 | + | failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) | |||
| 204 | require.True(t, ok) | 206 | require.True(t, ok) | ||
| 205 | require.Empty(t, failed) | 207 | require.Empty(t, failed) | ||
| 206 | require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize") | 208 | require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize") | ||
| @@ -925,10 +927,11 @@ func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) { | |||||
| 925 | installTestFloating(tb, p, true) | 927 | installTestFloating(tb, p, true) | ||
| 926 | d := newTestDaemon(t, nil, stubClock{}) | 928 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 927 | d.ApplyConfig(domain.Config{Floating: cfg}) | 929 | d.ApplyConfig(domain.Config{Floating: cfg}) | ||
| 930 | + | sess := &session{tabs: []*tab{tb}} | |||
| 928 | 931 | ||||
| 929 | done := make(chan bool, 1) | 932 | done := make(chan bool, 1) | ||
| 930 | go func() { | 933 | go func() { | ||
| 931 | - | _, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | 934 | + | _, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) |
| 932 | done <- ok | 935 | done <- ok | ||
| 933 | }() | 936 | }() | ||
| 934 | <-pty.entered | 937 | <-pty.entered | ||
| @@ -947,7 +950,7 @@ func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) { | |||||
| 947 | require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate") | 950 | require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate") | ||
| 948 | p.mu.Unlock() | 951 | p.mu.Unlock() | ||
| 949 | 952 | ||||
| 950 | - | _, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | 953 | + | _, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) |
| 951 | require.True(t, ok) | 954 | require.True(t, ok) | ||
| 952 | require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes()) | 955 | require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes()) | ||
| 953 | require.Equal(t, newGeometry.Inner, p.rect) | 956 | require.Equal(t, newGeometry.Inner, p.rect) | ||
M internal/usecase/daemon/floating_test.go +1 -1
| @@ -244,7 +244,7 @@ func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) { | |||
| 244 | 244 | d.startFloating(sess, tb, true) | |
| 245 | 245 | openCall := <-opened | |
| 246 | 246 | require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size) | |
| 247 | - | require.True(t, d.requestTransactionalResize(sess, nil, latest, true)) | |
| 247 | + | require.True(t, sess.geometry.requestResize(d, sess, nil, latest, true)) | |
| 248 | 248 | allowOpen() | |
| 249 | 249 | select { | |
| 250 | 250 | case <-resized: | |
| @@ -244,7 +244,7 @@ func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) { | |||||
| 244 | d.startFloating(sess, tb, true) | 244 | d.startFloating(sess, tb, true) | ||
| 245 | openCall := <-opened | 245 | openCall := <-opened | ||
| 246 | require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size) | 246 | require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size) | ||
| 247 | - | require.True(t, d.requestTransactionalResize(sess, nil, latest, true)) | 247 | + | require.True(t, sess.geometry.requestResize(d, sess, nil, latest, true)) |
| 248 | allowOpen() | 248 | allowOpen() | ||
| 249 | select { | 249 | select { | ||
| 250 | case <-resized: | 250 | case <-resized: | ||
M internal/usecase/daemon/geometry_propagation_test.go +11 -10
| @@ -28,12 +28,12 @@ func TestSessionPaneGeometryUsesLatestAttachmentClaimAndTruncates(t *testing.T) | |||
| 28 | 28 | require.True(t, sess.registerAttachment(second)) | |
| 29 | 29 | require.Equal(t, domain.Geometry{ | |
| 30 | 30 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 391, PixelHeight: 466, | |
| 31 | - | }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23})) | |
| 31 | + | }, sess.geometry.paneGeometry(domain.Size{Cols: 39, Rows: 23})) | |
| 32 | 32 | ||
| 33 | 33 | require.True(t, sess.unregisterAttachment(second)) | |
| 34 | 34 | require.Equal(t, domain.Geometry{ | |
| 35 | 35 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460, | |
| 36 | - | }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23})) | |
| 36 | + | }, sess.geometry.paneGeometry(domain.Size{Cols: 39, Rows: 23})) | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | 39 | func TestPreparedLayoutPropagatesGeometryToPTYAndScreenQueries(t *testing.T) { | |
| @@ -45,7 +45,8 @@ func TestPreparedLayoutPropagatesGeometryToPTYAndScreenQueries(t *testing.T) { | |||
| 45 | 45 | plan := preparedTabLayout{members: []resizeMember{{pane: pane, rect: domain.Rect{Width: 39, Height: 23}, geometry: geometry}}} | |
| 46 | 46 | daemon := &Daemon{log: slog.New(slog.NewTextHandler(io.Discard, nil))} | |
| 47 | 47 | ||
| 48 | - | daemon.applyPreparedTabMembers(&plan) | |
| 48 | + | geometryModule := &sharedPTYGeometry{} | |
| 49 | + | geometryModule.applyPreparedTabMembers(daemon, &plan) | |
| 49 | 50 | require.True(t, commitPreparedTabLayoutLocked(&plan)) | |
| 50 | 51 | require.Equal(t, geometry, pty.geometry) | |
| 51 | 52 | require.Equal(t, vt.Geometry{ | |
| @@ -85,27 +86,27 @@ func TestSizeOnlyGeometryClaimsPreservePixelsAndFallback(t *testing.T) { | |||
| 85 | 86 | require.True(t, sess.registerAttachment(first)) | |
| 86 | 87 | require.True(t, sess.registerAttachment(second)) | |
| 87 | 88 | ||
| 88 | - | _, claimed := sess.claimGeometryOwnerForSize(second, domain.Size{Cols: 80, Rows: 24}) | |
| 89 | + | _, claimed := sess.geometry.claimSize(sess, second, domain.Size{Cols: 80, Rows: 24}) | |
| 89 | 90 | require.True(t, claimed) | |
| 90 | 91 | require.Equal(t, domain.Geometry{ | |
| 91 | 92 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 438, PixelHeight: 517, | |
| 92 | - | }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23})) | |
| 93 | + | }, sess.geometry.paneGeometry(domain.Size{Cols: 39, Rows: 23})) | |
| 93 | 94 | ||
| 94 | 95 | require.True(t, sess.unregisterAttachment(second)) | |
| 95 | 96 | require.Equal(t, domain.Geometry{ | |
| 96 | 97 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460, | |
| 97 | - | }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23})) | |
| 98 | + | }, sess.geometry.paneGeometry(domain.Size{Cols: 39, Rows: 23})) | |
| 98 | 99 | ||
| 99 | 100 | partial := &attachedClient{} | |
| 100 | 101 | partial.setGeometry(domain.Geometry{ | |
| 101 | 102 | Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 901, | |
| 102 | 103 | }) | |
| 103 | 104 | require.True(t, sess.registerAttachment(partial)) | |
| 104 | - | require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 39, Rows: 23}}, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}), "partial pixels must remain cell-only") | |
| 105 | + | require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 39, Rows: 23}}, sess.geometry.paneGeometry(domain.Size{Cols: 39, Rows: 23}), "partial pixels must remain cell-only") | |
| 105 | 106 | require.True(t, sess.unregisterAttachment(partial)) | |
| 106 | 107 | require.Equal(t, domain.Geometry{ | |
| 107 | 108 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460, | |
| 108 | - | }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}), "fallback must restore the remaining complete pair") | |
| 109 | + | }, sess.geometry.paneGeometry(domain.Size{Cols: 39, Rows: 23}), "fallback must restore the remaining complete pair") | |
| 109 | 110 | } | |
| 110 | 111 | ||
| 111 | 112 | func TestSizeOnlyLayoutAndFloatingRetainClaimingPixelGeometry(t *testing.T) { | |
| @@ -119,7 +120,7 @@ func TestSizeOnlyLayoutAndFloatingRetainClaimingPixelGeometry(t *testing.T) { | |||
| 119 | 120 | tb := newTab(layoutPTY, domain.Size{Cols: 80, Rows: 22}) | |
| 120 | 121 | sess.tabs = []*tab{tb} | |
| 121 | 122 | d := newTestDaemon(t, nil, stubClock{}) | |
| 122 | - | require.True(t, d.applyTabLayout(sess, tb)) | |
| 123 | + | require.True(t, sess.geometry.applyTabLayout(d, sess, tb)) | |
| 123 | 124 | require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 22}, PixelWidth: 800, PixelHeight: 440}, layoutPTY.geometry) | |
| 124 | 125 | ||
| 125 | 126 | floatingPTY := &recordingGeometryPTY{} | |
| @@ -129,7 +130,7 @@ func TestSizeOnlyLayoutAndFloatingRetainClaimingPixelGeometry(t *testing.T) { | |||
| 129 | 130 | floating := newPane("floating", floatingPTY, rectSize(floatingGeometry.Inner)) | |
| 130 | 131 | floating.popupGeometry = floatingGeometry | |
| 131 | 132 | tb.floating = floatingSlot{state: floatingVisible, pane: floating, generation: 1} | |
| 132 | - | failed, ok := d.applyVisibleFloatingLayout(sess, tb, nil) | |
| 133 | + | failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) | |
| 133 | 134 | require.True(t, ok) | |
| 134 | 135 | require.Empty(t, failed) | |
| 135 | 136 | ptyRect := floatingGeometry.ptyRect() | |
| @@ -28,12 +28,12 @@ func TestSessionPaneGeometryUsesLatestAttachmentClaimAndTruncates(t *testing.T) | |||||
| 28 | require.True(t, sess.registerAttachment(second)) | 28 | require.True(t, sess.registerAttachment(second)) | ||
| 29 | require.Equal(t, domain.Geometry{ | 29 | require.Equal(t, domain.Geometry{ | ||
| 30 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 391, PixelHeight: 466, | 30 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 391, PixelHeight: 466, | ||
| 31 | - | }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23})) | 31 | + | }, sess.geometry.paneGeometry(domain.Size{Cols: 39, Rows: 23})) |
| 32 | 32 | ||||
| 33 | require.True(t, sess.unregisterAttachment(second)) | 33 | require.True(t, sess.unregisterAttachment(second)) | ||
| 34 | require.Equal(t, domain.Geometry{ | 34 | require.Equal(t, domain.Geometry{ | ||
| 35 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460, | 35 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460, | ||
| 36 | - | }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23})) | 36 | + | }, sess.geometry.paneGeometry(domain.Size{Cols: 39, Rows: 23})) |
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | func TestPreparedLayoutPropagatesGeometryToPTYAndScreenQueries(t *testing.T) { | 39 | func TestPreparedLayoutPropagatesGeometryToPTYAndScreenQueries(t *testing.T) { | ||
| @@ -45,7 +45,8 @@ func TestPreparedLayoutPropagatesGeometryToPTYAndScreenQueries(t *testing.T) { | |||||
| 45 | plan := preparedTabLayout{members: []resizeMember{{pane: pane, rect: domain.Rect{Width: 39, Height: 23}, geometry: geometry}}} | 45 | plan := preparedTabLayout{members: []resizeMember{{pane: pane, rect: domain.Rect{Width: 39, Height: 23}, geometry: geometry}}} | ||
| 46 | daemon := &Daemon{log: slog.New(slog.NewTextHandler(io.Discard, nil))} | 46 | daemon := &Daemon{log: slog.New(slog.NewTextHandler(io.Discard, nil))} | ||
| 47 | 47 | ||||
| 48 | - | daemon.applyPreparedTabMembers(&plan) | 48 | + | geometryModule := &sharedPTYGeometry{} |
| 49 | + | geometryModule.applyPreparedTabMembers(daemon, &plan) | |||
| 49 | require.True(t, commitPreparedTabLayoutLocked(&plan)) | 50 | require.True(t, commitPreparedTabLayoutLocked(&plan)) | ||
| 50 | require.Equal(t, geometry, pty.geometry) | 51 | require.Equal(t, geometry, pty.geometry) | ||
| 51 | require.Equal(t, vt.Geometry{ | 52 | require.Equal(t, vt.Geometry{ | ||
| @@ -85,27 +86,27 @@ func TestSizeOnlyGeometryClaimsPreservePixelsAndFallback(t *testing.T) { | |||||
| 85 | require.True(t, sess.registerAttachment(first)) | 86 | require.True(t, sess.registerAttachment(first)) | ||
| 86 | require.True(t, sess.registerAttachment(second)) | 87 | require.True(t, sess.registerAttachment(second)) | ||
| 87 | 88 | ||||
| 88 | - | _, claimed := sess.claimGeometryOwnerForSize(second, domain.Size{Cols: 80, Rows: 24}) | 89 | + | _, claimed := sess.geometry.claimSize(sess, second, domain.Size{Cols: 80, Rows: 24}) |
| 89 | require.True(t, claimed) | 90 | require.True(t, claimed) | ||
| 90 | require.Equal(t, domain.Geometry{ | 91 | require.Equal(t, domain.Geometry{ | ||
| 91 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 438, PixelHeight: 517, | 92 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 438, PixelHeight: 517, | ||
| 92 | - | }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23})) | 93 | + | }, sess.geometry.paneGeometry(domain.Size{Cols: 39, Rows: 23})) |
| 93 | 94 | ||||
| 94 | require.True(t, sess.unregisterAttachment(second)) | 95 | require.True(t, sess.unregisterAttachment(second)) | ||
| 95 | require.Equal(t, domain.Geometry{ | 96 | require.Equal(t, domain.Geometry{ | ||
| 96 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460, | 97 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460, | ||
| 97 | - | }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23})) | 98 | + | }, sess.geometry.paneGeometry(domain.Size{Cols: 39, Rows: 23})) |
| 98 | 99 | ||||
| 99 | partial := &attachedClient{} | 100 | partial := &attachedClient{} | ||
| 100 | partial.setGeometry(domain.Geometry{ | 101 | partial.setGeometry(domain.Geometry{ | ||
| 101 | Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 901, | 102 | Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 901, | ||
| 102 | }) | 103 | }) | ||
| 103 | require.True(t, sess.registerAttachment(partial)) | 104 | require.True(t, sess.registerAttachment(partial)) | ||
| 104 | - | require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 39, Rows: 23}}, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}), "partial pixels must remain cell-only") | 105 | + | require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 39, Rows: 23}}, sess.geometry.paneGeometry(domain.Size{Cols: 39, Rows: 23}), "partial pixels must remain cell-only") |
| 105 | require.True(t, sess.unregisterAttachment(partial)) | 106 | require.True(t, sess.unregisterAttachment(partial)) | ||
| 106 | require.Equal(t, domain.Geometry{ | 107 | require.Equal(t, domain.Geometry{ | ||
| 107 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460, | 108 | Size: domain.Size{Cols: 39, Rows: 23}, PixelWidth: 390, PixelHeight: 460, | ||
| 108 | - | }, sess.paneGeometry(domain.Size{Cols: 39, Rows: 23}), "fallback must restore the remaining complete pair") | 109 | + | }, sess.geometry.paneGeometry(domain.Size{Cols: 39, Rows: 23}), "fallback must restore the remaining complete pair") |
| 109 | } | 110 | } | ||
| 110 | 111 | ||||
| 111 | func TestSizeOnlyLayoutAndFloatingRetainClaimingPixelGeometry(t *testing.T) { | 112 | func TestSizeOnlyLayoutAndFloatingRetainClaimingPixelGeometry(t *testing.T) { | ||
| @@ -119,7 +120,7 @@ func TestSizeOnlyLayoutAndFloatingRetainClaimingPixelGeometry(t *testing.T) { | |||||
| 119 | tb := newTab(layoutPTY, domain.Size{Cols: 80, Rows: 22}) | 120 | tb := newTab(layoutPTY, domain.Size{Cols: 80, Rows: 22}) | ||
| 120 | sess.tabs = []*tab{tb} | 121 | sess.tabs = []*tab{tb} | ||
| 121 | d := newTestDaemon(t, nil, stubClock{}) | 122 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 122 | - | require.True(t, d.applyTabLayout(sess, tb)) | 123 | + | require.True(t, sess.geometry.applyTabLayout(d, sess, tb)) |
| 123 | require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 22}, PixelWidth: 800, PixelHeight: 440}, layoutPTY.geometry) | 124 | require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 22}, PixelWidth: 800, PixelHeight: 440}, layoutPTY.geometry) | ||
| 124 | 125 | ||||
| 125 | floatingPTY := &recordingGeometryPTY{} | 126 | floatingPTY := &recordingGeometryPTY{} | ||
| @@ -129,7 +130,7 @@ func TestSizeOnlyLayoutAndFloatingRetainClaimingPixelGeometry(t *testing.T) { | |||||
| 129 | floating := newPane("floating", floatingPTY, rectSize(floatingGeometry.Inner)) | 130 | floating := newPane("floating", floatingPTY, rectSize(floatingGeometry.Inner)) | ||
| 130 | floating.popupGeometry = floatingGeometry | 131 | floating.popupGeometry = floatingGeometry | ||
| 131 | tb.floating = floatingSlot{state: floatingVisible, pane: floating, generation: 1} | 132 | tb.floating = floatingSlot{state: floatingVisible, pane: floating, generation: 1} | ||
| 132 | - | failed, ok := d.applyVisibleFloatingLayout(sess, tb, nil) | 133 | + | failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) |
| 133 | require.True(t, ok) | 134 | require.True(t, ok) | ||
| 134 | require.Empty(t, failed) | 135 | require.Empty(t, failed) | ||
| 135 | ptyRect := floatingGeometry.ptyRect() | 136 | ptyRect := floatingGeometry.ptyRect() | ||
M internal/usecase/daemon/input.go +1 -1
| @@ -243,7 +243,7 @@ func (d *Daemon) focusMousePane(sess *session, ac *attachedClient, tb *tab, id l | |||
| 243 | 243 | sess.setAttachmentPaneLocked(ac, tb, focused) | |
| 244 | 244 | sess.mu.Unlock() | |
| 245 | 245 | if changed { | |
| 246 | - | d.applyTabLayout(sess, tb) | |
| 246 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 247 | 247 | } | |
| 248 | 248 | return changed, focused | |
| 249 | 249 | } | |
| @@ -243,7 +243,7 @@ func (d *Daemon) focusMousePane(sess *session, ac *attachedClient, tb *tab, id l | |||||
| 243 | sess.setAttachmentPaneLocked(ac, tb, focused) | 243 | sess.setAttachmentPaneLocked(ac, tb, focused) | ||
| 244 | sess.mu.Unlock() | 244 | sess.mu.Unlock() | ||
| 245 | if changed { | 245 | if changed { | ||
| 246 | - | d.applyTabLayout(sess, tb) | 246 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 247 | } | 247 | } | ||
| 248 | return changed, focused | 248 | return changed, focused | ||
| 249 | } | 249 | } | ||
M internal/usecase/daemon/layout_mutation.go +1 -1
| @@ -68,7 +68,7 @@ func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePan | |||
| 68 | 68 | target.session.mu.Unlock() | |
| 69 | 69 | d.mu.Unlock() | |
| 70 | 70 | ||
| 71 | - | if !d.applyTabLayout(target.session, target.tab) { | |
| 71 | + | if !target.session.geometry.applyTabLayout(d, target.session, target.tab) { | |
| 72 | 72 | target.tab.mu.Lock() | |
| 73 | 73 | applied := target.tab.tree == candidate | |
| 74 | 74 | target.tab.mu.Unlock() | |
| @@ -68,7 +68,7 @@ func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePan | |||||
| 68 | target.session.mu.Unlock() | 68 | target.session.mu.Unlock() | ||
| 69 | d.mu.Unlock() | 69 | d.mu.Unlock() | ||
| 70 | 70 | ||||
| 71 | - | if !d.applyTabLayout(target.session, target.tab) { | 71 | + | if !target.session.geometry.applyTabLayout(d, target.session, target.tab) { |
| 72 | target.tab.mu.Lock() | 72 | target.tab.mu.Lock() | ||
| 73 | applied := target.tab.tree == candidate | 73 | applied := target.tab.tree == candidate | ||
| 74 | target.tab.mu.Unlock() | 74 | target.tab.mu.Unlock() | ||
M internal/usecase/daemon/move_cleanup_test.go +0 -77
| @@ -26,83 +26,6 @@ func TestDetachMoveAttachmentsUsesStableSnapshotOrder(t *testing.T) { | |||
| 26 | 26 | require.Same(t, second, retired[1].ac) | |
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | - | func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) { | |
| 30 | - | movedPTY, releaseMoved := newBlockingPTY(t) | |
| 31 | - | remainingPTY, releaseRemaining := newBlockingPTY(t) | |
| 32 | - | destinationPTY, releaseDestination := newBlockingPTY(t) | |
| 33 | - | defer releaseMoved() | |
| 34 | - | defer releaseRemaining() | |
| 35 | - | defer releaseDestination() | |
| 36 | - | ||
| 37 | - | d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY) | |
| 38 | - | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |
| 39 | - | sourceTab := source.tabs[0] | |
| 40 | - | sourceTab.stableID = "source-tab" | |
| 41 | - | moved := sourceTab.focusedPane() | |
| 42 | - | client.sendMu.Lock() | |
| 43 | - | client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}} | |
| 44 | - | client.sendMu.Unlock() | |
| 45 | - | ||
| 46 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}} | |
| 47 | - | destinationTab := destination.tabs[0] | |
| 48 | - | publishTiledPaneOwners(destination, destinationTab) | |
| 49 | - | d.mu.Lock() | |
| 50 | - | d.sessions[destination.id] = destination | |
| 51 | - | d.mu.Unlock() | |
| 52 | - | ||
| 53 | - | moved.mu.Lock() | |
| 54 | - | owner := moved.effectLeaseLocked() | |
| 55 | - | moved.mu.Unlock() | |
| 56 | - | members := []resizeMember{{session: source, tab: sourceTab, pane: moved, owner: owner}} | |
| 57 | - | rc := source.renderCoordinator() | |
| 58 | - | require.NotNil(t, rc) | |
| 59 | - | lease := rc.attachmentLease(client) | |
| 60 | - | epoch := rc.recordResizeRequestForLease(client.size, client, lease) | |
| 61 | - | ticket, admitted := beginAttachmentLeaseEffect(source, client, lease) | |
| 62 | - | require.True(t, admitted) | |
| 63 | - | defer ticket.End() | |
| 64 | - | ||
| 65 | - | sendLocked := make(chan struct{}) | |
| 66 | - | allowResizeFence := make(chan struct{}) | |
| 67 | - | d.afterResizeCommitSendLocked = func() { | |
| 68 | - | close(sendLocked) | |
| 69 | - | <-allowResizeFence | |
| 70 | - | } | |
| 71 | - | defer func() { d.afterResizeCommitSendLocked = nil }() | |
| 72 | - | resizeDone := make(chan bool, 1) | |
| 73 | - | go func() { | |
| 74 | - | resizeDone <- d.publishResizeCommit(members, source, client, lease, epoch, ticket, client.size) | |
| 75 | - | }() | |
| 76 | - | awaitTestCompletion(t, sendLocked, "resize commit did not acquire sendMu") | |
| 77 | - | ||
| 78 | - | insideMoveCommit := make(chan struct{}) | |
| 79 | - | allowMoveCommit := make(chan struct{}) | |
| 80 | - | d.beforeMovePaneCommit = func() { | |
| 81 | - | close(insideMoveCommit) | |
| 82 | - | <-allowMoveCommit | |
| 83 | - | } | |
| 84 | - | defer func() { d.beforeMovePaneCommit = nil }() | |
| 85 | - | moveDone := make(chan error, 1) | |
| 86 | - | go func() { | |
| 87 | - | moveDone <- d.movePane(movePaneRequest{ | |
| 88 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |
| 89 | - | SourceTabID: domain.TabStableID(sourceTab.stableID), | |
| 90 | - | SourcePaneID: domain.PaneStableID(moved.stableID), | |
| 91 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |
| 92 | - | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |
| 93 | - | }) | |
| 94 | - | }() | |
| 95 | - | awaitTestCompletion(t, insideMoveCommit, "move did not reach its commit section") | |
| 96 | - | ||
| 97 | - | // Resize now owns sendMu and waits for the moved pane fence. The move may | |
| 98 | - | // finish only if it releases every resize fence before capture cleanup tries | |
| 99 | - | // to acquire sendMu. | |
| 100 | - | close(allowResizeFence) | |
| 101 | - | close(allowMoveCommit) | |
| 102 | - | require.NoError(t, awaitTestValue(t, moveDone, "move deadlocked against resize commit")) | |
| 103 | - | require.False(t, awaitTestValue(t, resizeDone, "resize commit did not drop stale owner")) | |
| 104 | - | } | |
| 105 | - | ||
| 106 | 29 | func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) { | |
| 107 | 30 | movedPTY, releaseMoved := newBlockingPTY(t) | |
| 108 | 31 | remainingPTY, releaseRemaining := newBlockingPTY(t) | |
| @@ -26,83 +26,6 @@ func TestDetachMoveAttachmentsUsesStableSnapshotOrder(t *testing.T) { | |||||
| 26 | require.Same(t, second, retired[1].ac) | 26 | require.Same(t, second, retired[1].ac) | ||
| 27 | } | 27 | } | ||
| 28 | 28 | ||||
| 29 | - | func TestMovePaneReleasesResizeFencesBeforeAttachmentCleanup(t *testing.T) { | |||
| 30 | - | movedPTY, releaseMoved := newBlockingPTY(t) | |||
| 31 | - | remainingPTY, releaseRemaining := newBlockingPTY(t) | |||
| 32 | - | destinationPTY, releaseDestination := newBlockingPTY(t) | |||
| 33 | - | defer releaseMoved() | |||
| 34 | - | defer releaseRemaining() | |||
| 35 | - | defer releaseDestination() | |||
| 36 | - | ||||
| 37 | - | d, source, client, _ := newManualSessionWithPTYs(t, movedPTY, remainingPTY) | |||
| 38 | - | require.NotNil(t, d.attachCoordinator(source, nil, client, true)) | |||
| 39 | - | sourceTab := source.tabs[0] | |||
| 40 | - | sourceTab.stableID = "source-tab" | |||
| 41 | - | moved := sourceTab.focusedPane() | |||
| 42 | - | client.sendMu.Lock() | |||
| 43 | - | client.captureFrames = map[*pane]capturedPaneRenderState{moved: {}} | |||
| 44 | - | client.sendMu.Unlock() | |||
| 45 | - | ||||
| 46 | - | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination", ephemeral: true}, tabs: []*tab{newTabWithStableID("destination-tab", "destination-pane", destinationPTY, domain.Size{Cols: 80, Rows: 23})}} | |||
| 47 | - | destinationTab := destination.tabs[0] | |||
| 48 | - | publishTiledPaneOwners(destination, destinationTab) | |||
| 49 | - | d.mu.Lock() | |||
| 50 | - | d.sessions[destination.id] = destination | |||
| 51 | - | d.mu.Unlock() | |||
| 52 | - | ||||
| 53 | - | moved.mu.Lock() | |||
| 54 | - | owner := moved.effectLeaseLocked() | |||
| 55 | - | moved.mu.Unlock() | |||
| 56 | - | members := []resizeMember{{session: source, tab: sourceTab, pane: moved, owner: owner}} | |||
| 57 | - | rc := source.renderCoordinator() | |||
| 58 | - | require.NotNil(t, rc) | |||
| 59 | - | lease := rc.attachmentLease(client) | |||
| 60 | - | epoch := rc.recordResizeRequestForLease(client.size, client, lease) | |||
| 61 | - | ticket, admitted := beginAttachmentLeaseEffect(source, client, lease) | |||
| 62 | - | require.True(t, admitted) | |||
| 63 | - | defer ticket.End() | |||
| 64 | - | ||||
| 65 | - | sendLocked := make(chan struct{}) | |||
| 66 | - | allowResizeFence := make(chan struct{}) | |||
| 67 | - | d.afterResizeCommitSendLocked = func() { | |||
| 68 | - | close(sendLocked) | |||
| 69 | - | <-allowResizeFence | |||
| 70 | - | } | |||
| 71 | - | defer func() { d.afterResizeCommitSendLocked = nil }() | |||
| 72 | - | resizeDone := make(chan bool, 1) | |||
| 73 | - | go func() { | |||
| 74 | - | resizeDone <- d.publishResizeCommit(members, source, client, lease, epoch, ticket, client.size) | |||
| 75 | - | }() | |||
| 76 | - | awaitTestCompletion(t, sendLocked, "resize commit did not acquire sendMu") | |||
| 77 | - | ||||
| 78 | - | insideMoveCommit := make(chan struct{}) | |||
| 79 | - | allowMoveCommit := make(chan struct{}) | |||
| 80 | - | d.beforeMovePaneCommit = func() { | |||
| 81 | - | close(insideMoveCommit) | |||
| 82 | - | <-allowMoveCommit | |||
| 83 | - | } | |||
| 84 | - | defer func() { d.beforeMovePaneCommit = nil }() | |||
| 85 | - | moveDone := make(chan error, 1) | |||
| 86 | - | go func() { | |||
| 87 | - | moveDone <- d.movePane(movePaneRequest{ | |||
| 88 | - | Source: moveSessionLocator{ID: source.id, Incarnation: source.incarnation}, | |||
| 89 | - | SourceTabID: domain.TabStableID(sourceTab.stableID), | |||
| 90 | - | SourcePaneID: domain.PaneStableID(moved.stableID), | |||
| 91 | - | Destination: moveSessionLocator{ID: destination.id, Incarnation: destination.incarnation}, | |||
| 92 | - | DestinationTabID: domain.TabStableID(destinationTab.stableID), | |||
| 93 | - | }) | |||
| 94 | - | }() | |||
| 95 | - | awaitTestCompletion(t, insideMoveCommit, "move did not reach its commit section") | |||
| 96 | - | ||||
| 97 | - | // Resize now owns sendMu and waits for the moved pane fence. The move may | |||
| 98 | - | // finish only if it releases every resize fence before capture cleanup tries | |||
| 99 | - | // to acquire sendMu. | |||
| 100 | - | close(allowResizeFence) | |||
| 101 | - | close(allowMoveCommit) | |||
| 102 | - | require.NoError(t, awaitTestValue(t, moveDone, "move deadlocked against resize commit")) | |||
| 103 | - | require.False(t, awaitTestValue(t, resizeDone, "resize commit did not drop stale owner")) | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) { | 29 | func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) { | ||
| 107 | movedPTY, releaseMoved := newBlockingPTY(t) | 30 | movedPTY, releaseMoved := newBlockingPTY(t) | ||
| 108 | remainingPTY, releaseRemaining := newBlockingPTY(t) | 31 | remainingPTY, releaseRemaining := newBlockingPTY(t) | ||
M internal/usecase/daemon/move_pane_postcommit.go +2 -2
| @@ -110,10 +110,10 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||
| 110 | 110 | sourceLayoutTab = p.source.firstTab() | |
| 111 | 111 | } | |
| 112 | 112 | if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab { | |
| 113 | - | d.applyTabLayout(p.source, sourceLayoutTab) | |
| 113 | + | p.source.geometry.applyTabLayout(d, p.source, sourceLayoutTab) | |
| 114 | 114 | } | |
| 115 | 115 | } | |
| 116 | - | d.applyTabLayout(p.destination, p.destinationTab) | |
| 116 | + | p.destination.geometry.applyTabLayout(d, p.destination, p.destinationTab) | |
| 117 | 117 | if p.sourceMetadataValid && !p.sourceEmpty { | |
| 118 | 118 | d.markCatalogueDirty(p.sourceMetadata) | |
| 119 | 119 | } | |
| @@ -110,10 +110,10 @@ func (p movePanePostcommitPlan) execute(d *Daemon) { | |||||
| 110 | sourceLayoutTab = p.source.firstTab() | 110 | sourceLayoutTab = p.source.firstTab() | ||
| 111 | } | 111 | } | ||
| 112 | if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab { | 112 | if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab { | ||
| 113 | - | d.applyTabLayout(p.source, sourceLayoutTab) | 113 | + | p.source.geometry.applyTabLayout(d, p.source, sourceLayoutTab) |
| 114 | } | 114 | } | ||
| 115 | } | 115 | } | ||
| 116 | - | d.applyTabLayout(p.destination, p.destinationTab) | 116 | + | p.destination.geometry.applyTabLayout(d, p.destination, p.destinationTab) |
| 117 | if p.sourceMetadataValid && !p.sourceEmpty { | 117 | if p.sourceMetadataValid && !p.sourceEmpty { | ||
| 118 | d.markCatalogueDirty(p.sourceMetadata) | 118 | d.markCatalogueDirty(p.sourceMetadata) | ||
| 119 | } | 119 | } | ||
M internal/usecase/daemon/move_tab_test.go +1 -1
| @@ -212,7 +212,7 @@ func TestMoveTabWaitsForSourceResizeAndAppliesDestinationGeometryLast(t *testing | |||
| 212 | 212 | }) | |
| 213 | 213 | } | |
| 214 | 214 | layoutDone := make(chan bool, 1) | |
| 215 | - | go func() { layoutDone <- d.applyTabLayout(source, moved) }() | |
| 215 | + | go func() { layoutDone <- source.geometry.applyTabLayout(d, source, moved) }() | |
| 216 | 216 | <-resizeEntered | |
| 217 | 217 | reserved := make(chan struct{}) | |
| 218 | 218 | d.afterMoveLifecycleReserved = func() { close(reserved) } | |
| @@ -212,7 +212,7 @@ func TestMoveTabWaitsForSourceResizeAndAppliesDestinationGeometryLast(t *testing | |||||
| 212 | }) | 212 | }) | ||
| 213 | } | 213 | } | ||
| 214 | layoutDone := make(chan bool, 1) | 214 | layoutDone := make(chan bool, 1) | ||
| 215 | - | go func() { layoutDone <- d.applyTabLayout(source, moved) }() | 215 | + | go func() { layoutDone <- source.geometry.applyTabLayout(d, source, moved) }() |
| 216 | <-resizeEntered | 216 | <-resizeEntered | ||
| 217 | reserved := make(chan struct{}) | 217 | reserved := make(chan struct{}) | ||
| 218 | d.afterMoveLifecycleReserved = func() { close(reserved) } | 218 | d.afterMoveLifecycleReserved = func() { close(reserved) } | ||
M internal/usecase/daemon/navigation_overflow.go +1 -1
| @@ -243,6 +243,6 @@ func (d *Daemon) commitTabOverflowForAttachment(sess *session, ac *attachedClien | |||
| 243 | 243 | candidate.source.mu.Unlock() | |
| 244 | 244 | sess.mu.Unlock() | |
| 245 | 245 | sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID)) | |
| 246 | - | d.applyTabLayout(sess, candidate.target) | |
| 246 | + | sess.geometry.applyTabLayout(d, sess, candidate.target) | |
| 247 | 247 | return true | |
| 248 | 248 | } | |
| @@ -243,6 +243,6 @@ func (d *Daemon) commitTabOverflowForAttachment(sess *session, ac *attachedClien | |||||
| 243 | candidate.source.mu.Unlock() | 243 | candidate.source.mu.Unlock() | ||
| 244 | sess.mu.Unlock() | 244 | sess.mu.Unlock() | ||
| 245 | sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID)) | 245 | sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID)) | ||
| 246 | - | d.applyTabLayout(sess, candidate.target) | 246 | + | sess.geometry.applyTabLayout(d, sess, candidate.target) |
| 247 | return true | 247 | return true | ||
| 248 | } | 248 | } | ||
M internal/usecase/daemon/nil_pty_test.go +1 -1
| @@ -28,7 +28,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||
| 28 | 28 | first.bumpLayoutGenerationLocked() | |
| 29 | 29 | first.mu.Unlock() | |
| 30 | 30 | require.NotPanics(t, func() { | |
| 31 | - | d.applyTabLayout(sess, first) | |
| 31 | + | sess.geometry.applyTabLayout(d, sess, first) | |
| 32 | 32 | }) | |
| 33 | 33 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | |
| 34 | 34 | require.Equal(t, 5, first.focusedPane().screen.Frame.Height) | |
| @@ -28,7 +28,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||||
| 28 | first.bumpLayoutGenerationLocked() | 28 | first.bumpLayoutGenerationLocked() | ||
| 29 | first.mu.Unlock() | 29 | first.mu.Unlock() | ||
| 30 | require.NotPanics(t, func() { | 30 | require.NotPanics(t, func() { | ||
| 31 | - | d.applyTabLayout(sess, first) | 31 | + | sess.geometry.applyTabLayout(d, sess, first) |
| 32 | }) | 32 | }) | ||
| 33 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | 33 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | ||
| 34 | require.Equal(t, 5, first.focusedPane().screen.Frame.Height) | 34 | require.Equal(t, 5, first.focusedPane().screen.Frame.Height) | ||
M internal/usecase/daemon/pane_actions.go +6 -6
| @@ -81,7 +81,7 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 81 | 81 | tabStableID := tb.stableID | |
| 82 | 82 | generation := tb.layoutGeneration | |
| 83 | 83 | tb.mu.Unlock() | |
| 84 | - | initialGeometry := sess.paneGeometry(rectSize(newRect)) | |
| 84 | + | initialGeometry := sess.geometry.paneGeometry(rectSize(newRect)) | |
| 85 | 85 | ||
| 86 | 86 | paneStableID, err := newStableID("p") | |
| 87 | 87 | if err != nil { | |
| @@ -123,7 +123,7 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 123 | 123 | tb.bumpLayoutGenerationLocked() | |
| 124 | 124 | tb.mu.Unlock() | |
| 125 | 125 | ||
| 126 | - | d.applyTabLayout(sess, tb) | |
| 126 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 127 | 127 | d.startPaneGoroutines(sess, tb, p) | |
| 128 | 128 | return paneFocusChange{tab: tb, focused: p, layoutChanged: true}, nil | |
| 129 | 129 | } | |
| @@ -188,7 +188,7 @@ func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error { | |||
| 188 | 188 | } | |
| 189 | 189 | tb.mu.Unlock() | |
| 190 | 190 | if err == nil { | |
| 191 | - | d.applyTabLayout(sess, tb) | |
| 191 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 192 | 192 | } | |
| 193 | 193 | return err | |
| 194 | 194 | } | |
| @@ -312,7 +312,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||
| 312 | 312 | _ = d.closeTabLocked(sess, tb, true) | |
| 313 | 313 | return true | |
| 314 | 314 | } | |
| 315 | - | d.applyTabLayout(sess, tb) | |
| 315 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 316 | 316 | for _, ac := range attachments { | |
| 317 | 317 | if ac.overlays != nil { | |
| 318 | 318 | ac.overlays.clearCopyModeForPane(p) | |
| @@ -380,7 +380,7 @@ func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.Pan | |||
| 380 | 380 | sess.mu.Lock() | |
| 381 | 381 | sess.invalidateViewsLocked() | |
| 382 | 382 | sess.mu.Unlock() | |
| 383 | - | d.applyTabLayout(sess, tb) | |
| 383 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 384 | 384 | ||
| 385 | 385 | for _, viewer := range attachments { | |
| 386 | 386 | if viewer.overlays != nil { | |
| @@ -545,7 +545,7 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir | |||
| 545 | 545 | } | |
| 546 | 546 | tb.mu.Unlock() | |
| 547 | 547 | if change.layoutChanged { | |
| 548 | - | d.applyTabLayout(sess, tb) | |
| 548 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 549 | 549 | } | |
| 550 | 550 | if errors.Is(err, layout.ErrNoPane) { | |
| 551 | 551 | return change, errNoNeighbor | |
| @@ -81,7 +81,7 @@ func (d *Daemon) spawnPaneOpAt( | |||||
| 81 | tabStableID := tb.stableID | 81 | tabStableID := tb.stableID | ||
| 82 | generation := tb.layoutGeneration | 82 | generation := tb.layoutGeneration | ||
| 83 | tb.mu.Unlock() | 83 | tb.mu.Unlock() | ||
| 84 | - | initialGeometry := sess.paneGeometry(rectSize(newRect)) | 84 | + | initialGeometry := sess.geometry.paneGeometry(rectSize(newRect)) |
| 85 | 85 | ||||
| 86 | paneStableID, err := newStableID("p") | 86 | paneStableID, err := newStableID("p") | ||
| 87 | if err != nil { | 87 | if err != nil { | ||
| @@ -123,7 +123,7 @@ func (d *Daemon) spawnPaneOpAt( | |||||
| 123 | tb.bumpLayoutGenerationLocked() | 123 | tb.bumpLayoutGenerationLocked() | ||
| 124 | tb.mu.Unlock() | 124 | tb.mu.Unlock() | ||
| 125 | 125 | ||||
| 126 | - | d.applyTabLayout(sess, tb) | 126 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 127 | d.startPaneGoroutines(sess, tb, p) | 127 | d.startPaneGoroutines(sess, tb, p) | ||
| 128 | return paneFocusChange{tab: tb, focused: p, layoutChanged: true}, nil | 128 | return paneFocusChange{tab: tb, focused: p, layoutChanged: true}, nil | ||
| 129 | } | 129 | } | ||
| @@ -188,7 +188,7 @@ func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error { | |||||
| 188 | } | 188 | } | ||
| 189 | tb.mu.Unlock() | 189 | tb.mu.Unlock() | ||
| 190 | if err == nil { | 190 | if err == nil { | ||
| 191 | - | d.applyTabLayout(sess, tb) | 191 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 192 | } | 192 | } | ||
| 193 | return err | 193 | return err | ||
| 194 | } | 194 | } | ||
| @@ -312,7 +312,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool { | |||||
| 312 | _ = d.closeTabLocked(sess, tb, true) | 312 | _ = d.closeTabLocked(sess, tb, true) | ||
| 313 | return true | 313 | return true | ||
| 314 | } | 314 | } | ||
| 315 | - | d.applyTabLayout(sess, tb) | 315 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 316 | for _, ac := range attachments { | 316 | for _, ac := range attachments { | ||
| 317 | if ac.overlays != nil { | 317 | if ac.overlays != nil { | ||
| 318 | ac.overlays.clearCopyModeForPane(p) | 318 | ac.overlays.clearCopyModeForPane(p) | ||
| @@ -380,7 +380,7 @@ func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.Pan | |||||
| 380 | sess.mu.Lock() | 380 | sess.mu.Lock() | ||
| 381 | sess.invalidateViewsLocked() | 381 | sess.invalidateViewsLocked() | ||
| 382 | sess.mu.Unlock() | 382 | sess.mu.Unlock() | ||
| 383 | - | d.applyTabLayout(sess, tb) | 383 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 384 | 384 | ||||
| 385 | for _, viewer := range attachments { | 385 | for _, viewer := range attachments { | ||
| 386 | if viewer.overlays != nil { | 386 | if viewer.overlays != nil { | ||
| @@ -545,7 +545,7 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir | |||||
| 545 | } | 545 | } | ||
| 546 | tb.mu.Unlock() | 546 | tb.mu.Unlock() | ||
| 547 | if change.layoutChanged { | 547 | if change.layoutChanged { | ||
| 548 | - | d.applyTabLayout(sess, tb) | 548 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 549 | } | 549 | } | ||
| 550 | if errors.Is(err, layout.ErrNoPane) { | 550 | if errors.Is(err, layout.ErrNoPane) { | ||
| 551 | return change, errNoNeighbor | 551 | return change, errNoNeighbor | ||
M internal/usecase/daemon/pane_actions_test.go +13 -11
| @@ -84,10 +84,10 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) { | |||
| 84 | 84 | tb.bumpLayoutGenerationLocked() | |
| 85 | 85 | tb.mu.Unlock() | |
| 86 | 86 | ||
| 87 | - | d.applyTabLayout(sess, tb) | |
| 87 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 88 | 88 | // A second accepted failure joins the existing worker rather than adding | |
| 89 | 89 | // another timer/goroutine. | |
| 90 | - | d.scheduleAcceptedTabLayoutRetry(sess, tb) | |
| 90 | + | sess.geometry.scheduleAcceptedTabLayoutRetry(d, sess, tb) | |
| 91 | 91 | for range maxAcceptedTabLayoutRetries { | |
| 92 | 92 | var timer *layoutRetryTimer | |
| 93 | 93 | for timer == nil { | |
| @@ -123,7 +123,7 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) { | |||
| 123 | 123 | tb.size = domain.Size{Cols: 100, Rows: 20} | |
| 124 | 124 | tb.bumpLayoutGenerationLocked() | |
| 125 | 125 | tb.mu.Unlock() | |
| 126 | - | d.applyTabLayout(sess, tb) | |
| 126 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 127 | 127 | var timer *layoutRetryTimer | |
| 128 | 128 | for timer == nil { | |
| 129 | 129 | candidate := <-clock.timers | |
| @@ -177,7 +177,7 @@ func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) { | |||
| 177 | 177 | resized.Do(func() { close(secondResize) }) | |
| 178 | 178 | } | |
| 179 | 179 | } | |
| 180 | - | d.applyTabLayout(sess, tb) | |
| 180 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 181 | 181 | ||
| 182 | 182 | var timer *layoutRetryTimer | |
| 183 | 183 | for timer == nil { | |
| @@ -271,7 +271,7 @@ func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) { | |||
| 271 | 271 | ||
| 272 | 272 | done := make(chan struct{}) | |
| 273 | 273 | go func() { | |
| 274 | - | d.applyTabLayout(sess, tb) | |
| 274 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 275 | 275 | close(done) | |
| 276 | 276 | }() | |
| 277 | 277 | awaitSignal(t, entered, "PTY resize did not start") | |
| @@ -303,7 +303,7 @@ func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) { | |||
| 303 | 303 | ||
| 304 | 304 | done := make(chan struct{}) | |
| 305 | 305 | go func() { | |
| 306 | - | d.applyTabLayout(sess, tb) | |
| 306 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 307 | 307 | close(done) | |
| 308 | 308 | }() | |
| 309 | 309 | awaitSignal(t, entered, "stale PTY resize did not start") | |
| @@ -351,7 +351,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||
| 351 | 351 | tb.mu.Unlock() | |
| 352 | 352 | ||
| 353 | 353 | done := make(chan struct{}) | |
| 354 | - | go func() { d.applyTabLayout(sess, tb); close(done) }() | |
| 354 | + | go func() { sess.geometry.applyTabLayout(d, sess, tb); close(done) }() | |
| 355 | 355 | awaitSignal(t, entered, "old layout apply did not block") | |
| 356 | 356 | closed := make(chan error, 1) | |
| 357 | 357 | go func() { closed <- d.closePane(sess, tb, p2.id, nil, false) }() | |
| @@ -393,7 +393,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||
| 393 | 393 | tb.mu.Unlock() | |
| 394 | 394 | ||
| 395 | 395 | done := make(chan struct{}) | |
| 396 | - | go func() { d.applyTabLayout(sess, tb); close(done) }() | |
| 396 | + | go func() { sess.geometry.applyTabLayout(d, sess, tb); close(done) }() | |
| 397 | 397 | awaitSignal(t, entered, "old stack apply did not block") | |
| 398 | 398 | tb.mu.Lock() | |
| 399 | 399 | require.True(t, focusPlacementLocked(tb, p2.id)) | |
| @@ -422,10 +422,12 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||
| 422 | 422 | p.rect = domain.Rect{Width: 20, Height: 10} | |
| 423 | 423 | ||
| 424 | 424 | oldDone := make(chan struct{}) | |
| 425 | - | go func() { d.applyTabLayout(sess, tb); close(oldDone) }() | |
| 425 | + | go func() { sess.geometry.applyTabLayout(d, sess, tb); close(oldDone) }() | |
| 426 | 426 | awaitSignal(t, entered, "old size apply did not block") | |
| 427 | 427 | resizeDone := make(chan bool, 1) | |
| 428 | - | go func() { resizeDone <- d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true) }() | |
| 428 | + | go func() { | |
| 429 | + | resizeDone <- sess.geometry.requestResize(d, sess, nil, domain.Size{Cols: 100, Rows: 30}, true) | |
| 430 | + | }() | |
| 429 | 431 | // Session resize now stages the target privately until every external | |
| 430 | 432 | // apply validates and the transaction is admitted, so neither the size | |
| 431 | 433 | // nor pane geometry is visible while the old PTY call is blocked. | |
| @@ -591,7 +593,7 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) { | |||
| 591 | 593 | oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once() | |
| 592 | 594 | newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once() | |
| 593 | 595 | ||
| 594 | - | d.applyTabLayout(sess, tb) | |
| 596 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 595 | 597 | ||
| 596 | 598 | require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width) | |
| 597 | 599 | require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width) | |
| @@ -84,10 +84,10 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) { | |||||
| 84 | tb.bumpLayoutGenerationLocked() | 84 | tb.bumpLayoutGenerationLocked() | ||
| 85 | tb.mu.Unlock() | 85 | tb.mu.Unlock() | ||
| 86 | 86 | ||||
| 87 | - | d.applyTabLayout(sess, tb) | 87 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 88 | // A second accepted failure joins the existing worker rather than adding | 88 | // A second accepted failure joins the existing worker rather than adding | ||
| 89 | // another timer/goroutine. | 89 | // another timer/goroutine. | ||
| 90 | - | d.scheduleAcceptedTabLayoutRetry(sess, tb) | 90 | + | sess.geometry.scheduleAcceptedTabLayoutRetry(d, sess, tb) |
| 91 | for range maxAcceptedTabLayoutRetries { | 91 | for range maxAcceptedTabLayoutRetries { | ||
| 92 | var timer *layoutRetryTimer | 92 | var timer *layoutRetryTimer | ||
| 93 | for timer == nil { | 93 | for timer == nil { | ||
| @@ -123,7 +123,7 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) { | |||||
| 123 | tb.size = domain.Size{Cols: 100, Rows: 20} | 123 | tb.size = domain.Size{Cols: 100, Rows: 20} | ||
| 124 | tb.bumpLayoutGenerationLocked() | 124 | tb.bumpLayoutGenerationLocked() | ||
| 125 | tb.mu.Unlock() | 125 | tb.mu.Unlock() | ||
| 126 | - | d.applyTabLayout(sess, tb) | 126 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 127 | var timer *layoutRetryTimer | 127 | var timer *layoutRetryTimer | ||
| 128 | for timer == nil { | 128 | for timer == nil { | ||
| 129 | candidate := <-clock.timers | 129 | candidate := <-clock.timers | ||
| @@ -177,7 +177,7 @@ func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) { | |||||
| 177 | resized.Do(func() { close(secondResize) }) | 177 | resized.Do(func() { close(secondResize) }) | ||
| 178 | } | 178 | } | ||
| 179 | } | 179 | } | ||
| 180 | - | d.applyTabLayout(sess, tb) | 180 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 181 | 181 | ||||
| 182 | var timer *layoutRetryTimer | 182 | var timer *layoutRetryTimer | ||
| 183 | for timer == nil { | 183 | for timer == nil { | ||
| @@ -271,7 +271,7 @@ func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) { | |||||
| 271 | 271 | ||||
| 272 | done := make(chan struct{}) | 272 | done := make(chan struct{}) | ||
| 273 | go func() { | 273 | go func() { | ||
| 274 | - | d.applyTabLayout(sess, tb) | 274 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 275 | close(done) | 275 | close(done) | ||
| 276 | }() | 276 | }() | ||
| 277 | awaitSignal(t, entered, "PTY resize did not start") | 277 | awaitSignal(t, entered, "PTY resize did not start") | ||
| @@ -303,7 +303,7 @@ func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) { | |||||
| 303 | 303 | ||||
| 304 | done := make(chan struct{}) | 304 | done := make(chan struct{}) | ||
| 305 | go func() { | 305 | go func() { | ||
| 306 | - | d.applyTabLayout(sess, tb) | 306 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 307 | close(done) | 307 | close(done) | ||
| 308 | }() | 308 | }() | ||
| 309 | awaitSignal(t, entered, "stale PTY resize did not start") | 309 | awaitSignal(t, entered, "stale PTY resize did not start") | ||
| @@ -351,7 +351,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||||
| 351 | tb.mu.Unlock() | 351 | tb.mu.Unlock() | ||
| 352 | 352 | ||||
| 353 | done := make(chan struct{}) | 353 | done := make(chan struct{}) | ||
| 354 | - | go func() { d.applyTabLayout(sess, tb); close(done) }() | 354 | + | go func() { sess.geometry.applyTabLayout(d, sess, tb); close(done) }() |
| 355 | awaitSignal(t, entered, "old layout apply did not block") | 355 | awaitSignal(t, entered, "old layout apply did not block") | ||
| 356 | closed := make(chan error, 1) | 356 | closed := make(chan error, 1) | ||
| 357 | go func() { closed <- d.closePane(sess, tb, p2.id, nil, false) }() | 357 | go func() { closed <- d.closePane(sess, tb, p2.id, nil, false) }() | ||
| @@ -393,7 +393,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||||
| 393 | tb.mu.Unlock() | 393 | tb.mu.Unlock() | ||
| 394 | 394 | ||||
| 395 | done := make(chan struct{}) | 395 | done := make(chan struct{}) | ||
| 396 | - | go func() { d.applyTabLayout(sess, tb); close(done) }() | 396 | + | go func() { sess.geometry.applyTabLayout(d, sess, tb); close(done) }() |
| 397 | awaitSignal(t, entered, "old stack apply did not block") | 397 | awaitSignal(t, entered, "old stack apply did not block") | ||
| 398 | tb.mu.Lock() | 398 | tb.mu.Lock() | ||
| 399 | require.True(t, focusPlacementLocked(tb, p2.id)) | 399 | require.True(t, focusPlacementLocked(tb, p2.id)) | ||
| @@ -422,10 +422,12 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||||
| 422 | p.rect = domain.Rect{Width: 20, Height: 10} | 422 | p.rect = domain.Rect{Width: 20, Height: 10} | ||
| 423 | 423 | ||||
| 424 | oldDone := make(chan struct{}) | 424 | oldDone := make(chan struct{}) | ||
| 425 | - | go func() { d.applyTabLayout(sess, tb); close(oldDone) }() | 425 | + | go func() { sess.geometry.applyTabLayout(d, sess, tb); close(oldDone) }() |
| 426 | awaitSignal(t, entered, "old size apply did not block") | 426 | awaitSignal(t, entered, "old size apply did not block") | ||
| 427 | resizeDone := make(chan bool, 1) | 427 | resizeDone := make(chan bool, 1) | ||
| 428 | - | go func() { resizeDone <- d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true) }() | 428 | + | go func() { |
| 429 | + | resizeDone <- sess.geometry.requestResize(d, sess, nil, domain.Size{Cols: 100, Rows: 30}, true) | |||
| 430 | + | }() | |||
| 429 | // Session resize now stages the target privately until every external | 431 | // Session resize now stages the target privately until every external | ||
| 430 | // apply validates and the transaction is admitted, so neither the size | 432 | // apply validates and the transaction is admitted, so neither the size | ||
| 431 | // nor pane geometry is visible while the old PTY call is blocked. | 433 | // nor pane geometry is visible while the old PTY call is blocked. | ||
| @@ -591,7 +593,7 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) { | |||||
| 591 | oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once() | 593 | oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once() | ||
| 592 | newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once() | 594 | newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once() | ||
| 593 | 595 | ||||
| 594 | - | d.applyTabLayout(sess, tb) | 596 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 595 | 597 | ||||
| 596 | require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width) | 598 | require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width) | ||
| 597 | require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width) | 599 | require.Equal(t, 20, tb.panes["pane-2"].screen.Frame.Width) | ||
M internal/usecase/daemon/performance_benchmark_test.go +2 -2
| @@ -1132,7 +1132,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||
| 1132 | 1132 | require.Len(f.t, placements, paneCount) | |
| 1133 | 1133 | tb.bumpLayoutGenerationLocked() | |
| 1134 | 1134 | tb.mu.Unlock() | |
| 1135 | - | _, _ = f.d.applyTabLayoutTransaction(f.sess, tb) | |
| 1135 | + | _, _ = f.sess.geometry.applyTabLayoutTransaction(f.d, f.sess, tb) | |
| 1136 | 1136 | tb.mu.Lock() | |
| 1137 | 1137 | panes := tb.panesSnapshot() | |
| 1138 | 1138 | tb.mu.Unlock() | |
| @@ -1272,7 +1272,7 @@ func (f *performanceFixture) retryLatest() { | |||
| 1272 | 1272 | members = append(members, plan.members...) | |
| 1273 | 1273 | } | |
| 1274 | 1274 | failuresBefore, callsBefore := f.pty.metrics() | |
| 1275 | - | f.d.retryResizeMembers(f.sess, f.ac, f.sess.renderCoordinator().attachmentLease(f.ac), epoch, members) | |
| 1275 | + | f.sess.geometry.retryResizeMembers(f.d, f.sess, f.ac, f.sess.renderCoordinator().attachmentLease(f.ac), epoch, members) | |
| 1276 | 1276 | failuresAfter, callsAfter := f.pty.metrics() | |
| 1277 | 1277 | f.ptyFailures += failuresAfter - failuresBefore | |
| 1278 | 1278 | if callsAfter > callsBefore { | |
| @@ -1132,7 +1132,7 @@ func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyR | |||||
| 1132 | require.Len(f.t, placements, paneCount) | 1132 | require.Len(f.t, placements, paneCount) | ||
| 1133 | tb.bumpLayoutGenerationLocked() | 1133 | tb.bumpLayoutGenerationLocked() | ||
| 1134 | tb.mu.Unlock() | 1134 | tb.mu.Unlock() | ||
| 1135 | - | _, _ = f.d.applyTabLayoutTransaction(f.sess, tb) | 1135 | + | _, _ = f.sess.geometry.applyTabLayoutTransaction(f.d, f.sess, tb) |
| 1136 | tb.mu.Lock() | 1136 | tb.mu.Lock() | ||
| 1137 | panes := tb.panesSnapshot() | 1137 | panes := tb.panesSnapshot() | ||
| 1138 | tb.mu.Unlock() | 1138 | tb.mu.Unlock() | ||
| @@ -1272,7 +1272,7 @@ func (f *performanceFixture) retryLatest() { | |||||
| 1272 | members = append(members, plan.members...) | 1272 | members = append(members, plan.members...) | ||
| 1273 | } | 1273 | } | ||
| 1274 | failuresBefore, callsBefore := f.pty.metrics() | 1274 | failuresBefore, callsBefore := f.pty.metrics() | ||
| 1275 | - | f.d.retryResizeMembers(f.sess, f.ac, f.sess.renderCoordinator().attachmentLease(f.ac), epoch, members) | 1275 | + | f.sess.geometry.retryResizeMembers(f.d, f.sess, f.ac, f.sess.renderCoordinator().attachmentLease(f.ac), epoch, members) |
| 1276 | failuresAfter, callsAfter := f.pty.metrics() | 1276 | failuresAfter, callsAfter := f.pty.metrics() | ||
| 1277 | f.ptyFailures += failuresAfter - failuresBefore | 1277 | f.ptyFailures += failuresAfter - failuresBefore | ||
| 1278 | if callsAfter > callsBefore { | 1278 | if callsAfter > callsBefore { | ||
M internal/usecase/daemon/render_coordinator_test.go +4 -4
| @@ -1591,7 +1591,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) { | |||
| 1591 | 1591 | t.Run("immediate reset invalidation", func(t *testing.T) { | |
| 1592 | 1592 | d, sess, ac, _, _, invs := newResizeFixture(t) | |
| 1593 | 1593 | ||
| 1594 | - | require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true)) | |
| 1594 | + | require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 120, Rows: 24}, true)) | |
| 1595 | 1595 | inv := awaitInvalidation(t, invs) | |
| 1596 | 1596 | require.True(t, inv.reset) | |
| 1597 | 1597 | }) | |
| @@ -1617,7 +1617,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) { | |||
| 1617 | 1617 | ||
| 1618 | 1618 | result := make(chan bool, 1) | |
| 1619 | 1619 | go func() { | |
| 1620 | - | result <- d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true) | |
| 1620 | + | result <- sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 120, Rows: 24}, true) | |
| 1621 | 1621 | }() | |
| 1622 | 1622 | <-resizeStarted | |
| 1623 | 1623 | require.NotZero(t, rc.recordResizeRequest(domain.Size{Cols: 140, Rows: 30}, ac)) | |
| @@ -1630,12 +1630,12 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) { | |||
| 1630 | 1630 | t.Run("async schedule and teardown rejection", func(t *testing.T) { | |
| 1631 | 1631 | d, sess, ac, _, clk, _ := newResizeFixture(t) | |
| 1632 | 1632 | ||
| 1633 | - | require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, false)) | |
| 1633 | + | require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 120, Rows: 24}, false)) | |
| 1634 | 1634 | awaitCoordinatorScheduledTimer(t, clk) | |
| 1635 | 1635 | ||
| 1636 | 1636 | // A torn-down coordinator cannot accept a stale attachment's schedule. | |
| 1637 | 1637 | sess.renderCoordinator().beginSessionTeardown().finish() | |
| 1638 | - | require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false)) | |
| 1638 | + | require.False(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 140, Rows: 30}, false)) | |
| 1639 | 1639 | }) | |
| 1640 | 1640 | }) | |
| 1641 | 1641 | } | |
| @@ -1591,7 +1591,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) { | |||||
| 1591 | t.Run("immediate reset invalidation", func(t *testing.T) { | 1591 | t.Run("immediate reset invalidation", func(t *testing.T) { | ||
| 1592 | d, sess, ac, _, _, invs := newResizeFixture(t) | 1592 | d, sess, ac, _, _, invs := newResizeFixture(t) | ||
| 1593 | 1593 | ||||
| 1594 | - | require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true)) | 1594 | + | require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 120, Rows: 24}, true)) |
| 1595 | inv := awaitInvalidation(t, invs) | 1595 | inv := awaitInvalidation(t, invs) | ||
| 1596 | require.True(t, inv.reset) | 1596 | require.True(t, inv.reset) | ||
| 1597 | }) | 1597 | }) | ||
| @@ -1617,7 +1617,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) { | |||||
| 1617 | 1617 | ||||
| 1618 | result := make(chan bool, 1) | 1618 | result := make(chan bool, 1) | ||
| 1619 | go func() { | 1619 | go func() { | ||
| 1620 | - | result <- d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, true) | 1620 | + | result <- sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 120, Rows: 24}, true) |
| 1621 | }() | 1621 | }() | ||
| 1622 | <-resizeStarted | 1622 | <-resizeStarted | ||
| 1623 | require.NotZero(t, rc.recordResizeRequest(domain.Size{Cols: 140, Rows: 30}, ac)) | 1623 | require.NotZero(t, rc.recordResizeRequest(domain.Size{Cols: 140, Rows: 30}, ac)) | ||
| @@ -1630,12 +1630,12 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) { | |||||
| 1630 | t.Run("async schedule and teardown rejection", func(t *testing.T) { | 1630 | t.Run("async schedule and teardown rejection", func(t *testing.T) { | ||
| 1631 | d, sess, ac, _, clk, _ := newResizeFixture(t) | 1631 | d, sess, ac, _, clk, _ := newResizeFixture(t) | ||
| 1632 | 1632 | ||||
| 1633 | - | require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, false)) | 1633 | + | require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 120, Rows: 24}, false)) |
| 1634 | awaitCoordinatorScheduledTimer(t, clk) | 1634 | awaitCoordinatorScheduledTimer(t, clk) | ||
| 1635 | 1635 | ||||
| 1636 | // A torn-down coordinator cannot accept a stale attachment's schedule. | 1636 | // A torn-down coordinator cannot accept a stale attachment's schedule. | ||
| 1637 | sess.renderCoordinator().beginSessionTeardown().finish() | 1637 | sess.renderCoordinator().beginSessionTeardown().finish() | ||
| 1638 | - | require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false)) | 1638 | + | require.False(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 140, Rows: 30}, false)) |
| 1639 | }) | 1639 | }) | ||
| 1640 | }) | 1640 | }) | ||
| 1641 | } | 1641 | } | ||
M internal/usecase/daemon/render_coordinator_types.go +3 -3
| @@ -90,9 +90,9 @@ type resizeRequestMetadata struct { | |||
| 90 | 90 | // lease binds every delayed resize/retry callback to the exact attachment | |
| 91 | 91 | // incarnation that published this request. | |
| 92 | 92 | lease *attachmentLease | |
| 93 | - | geometryClaim uint64 // session geometry claim captured by this request | |
| 94 | - | epoch uint64 // latest requested epoch | |
| 95 | - | committed uint64 // latest published epoch | |
| 93 | + | geometryClaim *sharedPTYClaim // immutable Session geometry claim captured by this request | |
| 94 | + | epoch uint64 // latest requested epoch | |
| 95 | + | committed uint64 // latest published epoch | |
| 96 | 96 | retryAttempts uint8 | |
| 97 | 97 | } | |
| 98 | 98 | ||
| @@ -90,9 +90,9 @@ type resizeRequestMetadata struct { | |||||
| 90 | // lease binds every delayed resize/retry callback to the exact attachment | 90 | // lease binds every delayed resize/retry callback to the exact attachment | ||
| 91 | // incarnation that published this request. | 91 | // incarnation that published this request. | ||
| 92 | lease *attachmentLease | 92 | lease *attachmentLease | ||
| 93 | - | geometryClaim uint64 // session geometry claim captured by this request | 93 | + | geometryClaim *sharedPTYClaim // immutable Session geometry claim captured by this request |
| 94 | - | epoch uint64 // latest requested epoch | 94 | + | epoch uint64 // latest requested epoch |
| 95 | - | committed uint64 // latest published epoch | 95 | + | committed uint64 // latest published epoch |
| 96 | retryAttempts uint8 | 96 | retryAttempts uint8 | ||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
M internal/usecase/daemon/render_resize_deadline.go +5 -5
| @@ -10,14 +10,14 @@ const maxResizeRetryAttempts = 3 | |||
| 10 | 10 | // Resize deadlines and retries retain coordinator-owned attachment, epoch, and | |
| 11 | 11 | // token validation at every callback effect boundary. | |
| 12 | 12 | func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 { | |
| 13 | - | return c.recordResizeRequestForLeaseWithClaim(size, source, c.attachmentLease(source), 0) | |
| 13 | + | return c.recordResizeRequestForLeaseWithClaim(size, source, c.attachmentLease(source), nil) | |
| 14 | 14 | } | |
| 15 | 15 | ||
| 16 | 16 | func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source *attachedClient, lease *attachmentLease) uint64 { | |
| 17 | - | return c.recordResizeRequestForLeaseWithClaim(size, source, lease, 0) | |
| 17 | + | return c.recordResizeRequestForLeaseWithClaim(size, source, lease, nil) | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | - | func (c *renderCoordinator) recordResizeRequestForLeaseWithClaim(size domain.Size, source *attachedClient, lease *attachmentLease, geometryClaim uint64) uint64 { | |
| 20 | + | func (c *renderCoordinator) recordResizeRequestForLeaseWithClaim(size domain.Size, source *attachedClient, lease *attachmentLease, geometryClaim *sharedPTYClaim) uint64 { | |
| 21 | 21 | c.mu.Lock() | |
| 22 | 22 | if !c.leaseCurrentLocked(lease, false) || lease.attachment != source { | |
| 23 | 23 | c.mu.Unlock() | |
| @@ -35,10 +35,10 @@ func (c *renderCoordinator) recordResizeRequestForLeaseWithClaim(size domain.Siz | |||
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | func (c *renderCoordinator) scheduleResizeForLease(size domain.Size, source *attachedClient, lease *attachmentLease, run func(uint64)) uint64 { | |
| 38 | - | return c.scheduleResizeForLeaseWithClaim(size, source, lease, 0, run) | |
| 38 | + | return c.scheduleResizeForLeaseWithClaim(size, source, lease, nil, run) | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | - | func (c *renderCoordinator) scheduleResizeForLeaseWithClaim(size domain.Size, source *attachedClient, lease *attachmentLease, geometryClaim uint64, run func(uint64)) uint64 { | |
| 41 | + | func (c *renderCoordinator) scheduleResizeForLeaseWithClaim(size domain.Size, source *attachedClient, lease *attachmentLease, geometryClaim *sharedPTYClaim, run func(uint64)) uint64 { | |
| 42 | 42 | epoch := c.recordResizeRequestForLeaseWithClaim(size, source, lease, geometryClaim) | |
| 43 | 43 | if epoch == 0 { | |
| 44 | 44 | return 0 | |
| @@ -10,14 +10,14 @@ const maxResizeRetryAttempts = 3 | |||||
| 10 | // Resize deadlines and retries retain coordinator-owned attachment, epoch, and | 10 | // Resize deadlines and retries retain coordinator-owned attachment, epoch, and | ||
| 11 | // token validation at every callback effect boundary. | 11 | // token validation at every callback effect boundary. | ||
| 12 | func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 { | 12 | func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 { | ||
| 13 | - | return c.recordResizeRequestForLeaseWithClaim(size, source, c.attachmentLease(source), 0) | 13 | + | return c.recordResizeRequestForLeaseWithClaim(size, source, c.attachmentLease(source), nil) |
| 14 | } | 14 | } | ||
| 15 | 15 | ||||
| 16 | func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source *attachedClient, lease *attachmentLease) uint64 { | 16 | func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source *attachedClient, lease *attachmentLease) uint64 { | ||
| 17 | - | return c.recordResizeRequestForLeaseWithClaim(size, source, lease, 0) | 17 | + | return c.recordResizeRequestForLeaseWithClaim(size, source, lease, nil) |
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | - | func (c *renderCoordinator) recordResizeRequestForLeaseWithClaim(size domain.Size, source *attachedClient, lease *attachmentLease, geometryClaim uint64) uint64 { | 20 | + | func (c *renderCoordinator) recordResizeRequestForLeaseWithClaim(size domain.Size, source *attachedClient, lease *attachmentLease, geometryClaim *sharedPTYClaim) uint64 { |
| 21 | c.mu.Lock() | 21 | c.mu.Lock() | ||
| 22 | if !c.leaseCurrentLocked(lease, false) || lease.attachment != source { | 22 | if !c.leaseCurrentLocked(lease, false) || lease.attachment != source { | ||
| 23 | c.mu.Unlock() | 23 | c.mu.Unlock() | ||
| @@ -35,10 +35,10 @@ func (c *renderCoordinator) recordResizeRequestForLeaseWithClaim(size domain.Siz | |||||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | func (c *renderCoordinator) scheduleResizeForLease(size domain.Size, source *attachedClient, lease *attachmentLease, run func(uint64)) uint64 { | 37 | func (c *renderCoordinator) scheduleResizeForLease(size domain.Size, source *attachedClient, lease *attachmentLease, run func(uint64)) uint64 { | ||
| 38 | - | return c.scheduleResizeForLeaseWithClaim(size, source, lease, 0, run) | 38 | + | return c.scheduleResizeForLeaseWithClaim(size, source, lease, nil, run) |
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | - | func (c *renderCoordinator) scheduleResizeForLeaseWithClaim(size domain.Size, source *attachedClient, lease *attachmentLease, geometryClaim uint64, run func(uint64)) uint64 { | 41 | + | func (c *renderCoordinator) scheduleResizeForLeaseWithClaim(size domain.Size, source *attachedClient, lease *attachmentLease, geometryClaim *sharedPTYClaim, run func(uint64)) uint64 { |
| 42 | epoch := c.recordResizeRequestForLeaseWithClaim(size, source, lease, geometryClaim) | 42 | epoch := c.recordResizeRequestForLeaseWithClaim(size, source, lease, geometryClaim) | ||
| 43 | if epoch == 0 { | 43 | if epoch == 0 { | ||
| 44 | return 0 | 44 | return 0 | ||
D internal/usecase/daemon/resize_publication.go +0 -100
| @@ -1,100 +0,0 @@ | |||
| 1 | - | package daemon | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/vev/internal/domain" | |
| 5 | - | "github.com/bnema/vev/internal/ports" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | type resizeCommitPublication struct { | |
| 9 | - | members []resizeMember | |
| 10 | - | session *session | |
| 11 | - | attachment *attachedClient | |
| 12 | - | lease *attachmentLease | |
| 13 | - | epoch uint64 | |
| 14 | - | effect *attachmentEffect | |
| 15 | - | coordinator *renderCoordinator | |
| 16 | - | observer ports.RuntimeObserver | |
| 17 | - | mark ports.RuntimeMark | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | func (p resizeCommitPublication) current() bool { | |
| 21 | - | if !resizeMembersOwnerCurrent(p.members) { | |
| 22 | - | return false | |
| 23 | - | } | |
| 24 | - | if p.attachment == nil { | |
| 25 | - | return true | |
| 26 | - | } | |
| 27 | - | // Attached resize publication retains the exact admitted effect; lifecycle | |
| 28 | - | // identity cannot change until that effect ends. | |
| 29 | - | if !p.effect.current() || p.effect.sess != p.session || | |
| 30 | - | p.effect.ac != p.attachment || p.effect.lease != p.lease || p.coordinator == nil { | |
| 31 | - | return false | |
| 32 | - | } | |
| 33 | - | return p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false) | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // emit performs observer work only after every resize-owner fence and | |
| 37 | - | // attachment sendMu are released. The generation-bound publication is checked | |
| 38 | - | // once more so a move that wins after geometry publication but before observer | |
| 39 | - | // admission suppresses stale source telemetry. | |
| 40 | - | func (p resizeCommitPublication) emit() bool { | |
| 41 | - | if !p.current() { | |
| 42 | - | return false | |
| 43 | - | } | |
| 44 | - | if p.observer != nil { | |
| 45 | - | p.observer.ObserveRuntime(p.mark) | |
| 46 | - | } | |
| 47 | - | return true | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // publishResizeCommit linearizes attachment geometry and an immutable telemetry | |
| 51 | - | // reservation with pane ownership. sendMu is acquired before resize fences, so | |
| 52 | - | // the canonical rule never acquires an attachment lock while a pane fence is | |
| 53 | - | // held. Observer callbacks run only after both lock families are released. | |
| 54 | - | func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *attachmentEffect, size domain.Size) bool { | |
| 55 | - | if ac != nil && ticket == nil { | |
| 56 | - | return false | |
| 57 | - | } | |
| 58 | - | d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication) | |
| 59 | - | if ac != nil { | |
| 60 | - | ac.sendMu.Lock() | |
| 61 | - | if d.afterResizeCommitSendLocked != nil { | |
| 62 | - | d.afterResizeCommitSendLocked() | |
| 63 | - | } | |
| 64 | - | } | |
| 65 | - | fences := acquireResizeOwnerPostEffectFences(members) | |
| 66 | - | if fences == nil { | |
| 67 | - | if ac != nil { | |
| 68 | - | ac.sendMu.Unlock() | |
| 69 | - | } | |
| 70 | - | return false | |
| 71 | - | } | |
| 72 | - | publication := resizeCommitPublication{ | |
| 73 | - | members: append([]resizeMember(nil), members...), | |
| 74 | - | session: sess, | |
| 75 | - | attachment: ac, | |
| 76 | - | lease: lease, | |
| 77 | - | epoch: epoch, | |
| 78 | - | observer: d.runtimeObserver, | |
| 79 | - | mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true), | |
| 80 | - | } | |
| 81 | - | if ticket != nil { | |
| 82 | - | publication.effect = ticket | |
| 83 | - | publication.coordinator = sess.renderCoordinator() | |
| 84 | - | } | |
| 85 | - | if !publication.current() { | |
| 86 | - | fences.Release() | |
| 87 | - | if ac != nil { | |
| 88 | - | ac.sendMu.Unlock() | |
| 89 | - | } | |
| 90 | - | return false | |
| 91 | - | } | |
| 92 | - | if ac != nil { | |
| 93 | - | ac.setSize(size) | |
| 94 | - | } | |
| 95 | - | fences.Release() | |
| 96 | - | if ac != nil { | |
| 97 | - | ac.sendMu.Unlock() | |
| 98 | - | } | |
| 99 | - | return publication.emit() | |
| 100 | - | } | |
| @@ -1,100 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "github.com/bnema/vev/internal/domain" | |||
| 5 | - | "github.com/bnema/vev/internal/ports" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | type resizeCommitPublication struct { | |||
| 9 | - | members []resizeMember | |||
| 10 | - | session *session | |||
| 11 | - | attachment *attachedClient | |||
| 12 | - | lease *attachmentLease | |||
| 13 | - | epoch uint64 | |||
| 14 | - | effect *attachmentEffect | |||
| 15 | - | coordinator *renderCoordinator | |||
| 16 | - | observer ports.RuntimeObserver | |||
| 17 | - | mark ports.RuntimeMark | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | func (p resizeCommitPublication) current() bool { | |||
| 21 | - | if !resizeMembersOwnerCurrent(p.members) { | |||
| 22 | - | return false | |||
| 23 | - | } | |||
| 24 | - | if p.attachment == nil { | |||
| 25 | - | return true | |||
| 26 | - | } | |||
| 27 | - | // Attached resize publication retains the exact admitted effect; lifecycle | |||
| 28 | - | // identity cannot change until that effect ends. | |||
| 29 | - | if !p.effect.current() || p.effect.sess != p.session || | |||
| 30 | - | p.effect.ac != p.attachment || p.effect.lease != p.lease || p.coordinator == nil { | |||
| 31 | - | return false | |||
| 32 | - | } | |||
| 33 | - | return p.coordinator.resizeCurrentForLease(p.epoch, p.attachment, p.lease, false) | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | // emit performs observer work only after every resize-owner fence and | |||
| 37 | - | // attachment sendMu are released. The generation-bound publication is checked | |||
| 38 | - | // once more so a move that wins after geometry publication but before observer | |||
| 39 | - | // admission suppresses stale source telemetry. | |||
| 40 | - | func (p resizeCommitPublication) emit() bool { | |||
| 41 | - | if !p.current() { | |||
| 42 | - | return false | |||
| 43 | - | } | |||
| 44 | - | if p.observer != nil { | |||
| 45 | - | p.observer.ObserveRuntime(p.mark) | |||
| 46 | - | } | |||
| 47 | - | return true | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | // publishResizeCommit linearizes attachment geometry and an immutable telemetry | |||
| 51 | - | // reservation with pane ownership. sendMu is acquired before resize fences, so | |||
| 52 | - | // the canonical rule never acquires an attachment lock while a pane fence is | |||
| 53 | - | // held. Observer callbacks run only after both lock families are released. | |||
| 54 | - | func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, ticket *attachmentEffect, size domain.Size) bool { | |||
| 55 | - | if ac != nil && ticket == nil { | |||
| 56 | - | return false | |||
| 57 | - | } | |||
| 58 | - | d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication) | |||
| 59 | - | if ac != nil { | |||
| 60 | - | ac.sendMu.Lock() | |||
| 61 | - | if d.afterResizeCommitSendLocked != nil { | |||
| 62 | - | d.afterResizeCommitSendLocked() | |||
| 63 | - | } | |||
| 64 | - | } | |||
| 65 | - | fences := acquireResizeOwnerPostEffectFences(members) | |||
| 66 | - | if fences == nil { | |||
| 67 | - | if ac != nil { | |||
| 68 | - | ac.sendMu.Unlock() | |||
| 69 | - | } | |||
| 70 | - | return false | |||
| 71 | - | } | |||
| 72 | - | publication := resizeCommitPublication{ | |||
| 73 | - | members: append([]resizeMember(nil), members...), | |||
| 74 | - | session: sess, | |||
| 75 | - | attachment: ac, | |||
| 76 | - | lease: lease, | |||
| 77 | - | epoch: epoch, | |||
| 78 | - | observer: d.runtimeObserver, | |||
| 79 | - | mark: ports.NewRuntimeMark("daemon", ports.RuntimeResizeCommitted, 0, true), | |||
| 80 | - | } | |||
| 81 | - | if ticket != nil { | |||
| 82 | - | publication.effect = ticket | |||
| 83 | - | publication.coordinator = sess.renderCoordinator() | |||
| 84 | - | } | |||
| 85 | - | if !publication.current() { | |||
| 86 | - | fences.Release() | |||
| 87 | - | if ac != nil { | |||
| 88 | - | ac.sendMu.Unlock() | |||
| 89 | - | } | |||
| 90 | - | return false | |||
| 91 | - | } | |||
| 92 | - | if ac != nil { | |||
| 93 | - | ac.setSize(size) | |||
| 94 | - | } | |||
| 95 | - | fences.Release() | |||
| 96 | - | if ac != nil { | |||
| 97 | - | ac.sendMu.Unlock() | |||
| 98 | - | } | |||
| 99 | - | return publication.emit() | |||
| 100 | - | } | |||
M internal/usecase/daemon/resume.go +3 -3
| @@ -408,7 +408,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool { | |||
| 408 | 408 | captured := ac.transportSnapshot().transport | |
| 409 | 409 | d.mu.Unlock() | |
| 410 | 410 | if sess != nil { | |
| 411 | - | d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go") | |
| 411 | + | sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go") | |
| 412 | 412 | } | |
| 413 | 413 | d.watchParkedTimer(token, rearmed) | |
| 414 | 414 | if captured != nil { | |
| @@ -557,7 +557,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||
| 557 | 557 | if rc := sess.renderCoordinator(); rc != nil { | |
| 558 | 558 | rc.noteDetach(ac) | |
| 559 | 559 | } | |
| 560 | - | d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go") | |
| 560 | + | sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go") | |
| 561 | 561 | d.unregisterPreview(ac) | |
| 562 | 562 | if !d.parkAttachment(sess, ac) { | |
| 563 | 563 | // Detach already published; retire the captured link exactly once so | |
| @@ -618,7 +618,7 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size) | |||
| 618 | 618 | // Resume publication normally schedules reconciliation asynchronously; | |
| 619 | 619 | // complete the claiming terminal's geometry before the resumed | |
| 620 | 620 | // attachment is allowed to produce its first frame. | |
| 621 | - | d.recalculateSessionGeometry(sess, resumed) | |
| 621 | + | sess.geometry.reconcile(d, sess, resumed) | |
| 622 | 622 | // A resumed unsupported attachment bypasses finishAttachedClient. Check | |
| 623 | 623 | // the restored scene at this boundary so suppression is explained once. | |
| 624 | 624 | d.warnUnsupportedGraphics(resumed) | |
| @@ -408,7 +408,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool { | |||||
| 408 | captured := ac.transportSnapshot().transport | 408 | captured := ac.transportSnapshot().transport | ||
| 409 | d.mu.Unlock() | 409 | d.mu.Unlock() | ||
| 410 | if sess != nil { | 410 | if sess != nil { | ||
| 411 | - | d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go") | 411 | + | sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go") |
| 412 | } | 412 | } | ||
| 413 | d.watchParkedTimer(token, rearmed) | 413 | d.watchParkedTimer(token, rearmed) | ||
| 414 | if captured != nil { | 414 | if captured != nil { | ||
| @@ -557,7 +557,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||||
| 557 | if rc := sess.renderCoordinator(); rc != nil { | 557 | if rc := sess.renderCoordinator(); rc != nil { | ||
| 558 | rc.noteDetach(ac) | 558 | rc.noteDetach(ac) | ||
| 559 | } | 559 | } | ||
| 560 | - | d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go") | 560 | + | sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go") |
| 561 | d.unregisterPreview(ac) | 561 | d.unregisterPreview(ac) | ||
| 562 | if !d.parkAttachment(sess, ac) { | 562 | if !d.parkAttachment(sess, ac) { | ||
| 563 | // Detach already published; retire the captured link exactly once so | 563 | // Detach already published; retire the captured link exactly once so | ||
| @@ -618,7 +618,7 @@ func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size) | |||||
| 618 | // Resume publication normally schedules reconciliation asynchronously; | 618 | // Resume publication normally schedules reconciliation asynchronously; | ||
| 619 | // complete the claiming terminal's geometry before the resumed | 619 | // complete the claiming terminal's geometry before the resumed | ||
| 620 | // attachment is allowed to produce its first frame. | 620 | // attachment is allowed to produce its first frame. | ||
| 621 | - | d.recalculateSessionGeometry(sess, resumed) | 621 | + | sess.geometry.reconcile(d, sess, resumed) |
| 622 | // A resumed unsupported attachment bypasses finishAttachedClient. Check | 622 | // A resumed unsupported attachment bypasses finishAttachedClient. Check | ||
| 623 | // the restored scene at this boundary so suppression is explained once. | 623 | // the restored scene at this boundary so suppression is explained once. | ||
| 624 | d.warnUnsupportedGraphics(resumed) | 624 | d.warnUnsupportedGraphics(resumed) | ||
M internal/usecase/daemon/resume_test.go +1 -1
| @@ -1217,7 +1217,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||
| 1217 | 1217 | tb.panes[closed.id] = closed | |
| 1218 | 1218 | tb.bumpLayoutGenerationLocked() | |
| 1219 | 1219 | tb.mu.Unlock() | |
| 1220 | - | d.applyTabLayout(sess, tb) | |
| 1220 | + | sess.geometry.applyTabLayout(d, sess, tb) | |
| 1221 | 1221 | survivor.screen.Write([]byte("survivor")) | |
| 1222 | 1222 | closed.screen.Write([]byte("closed")) | |
| 1223 | 1223 | d.paint(sess, ac, true, nil) | |
| @@ -1217,7 +1217,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||||
| 1217 | tb.panes[closed.id] = closed | 1217 | tb.panes[closed.id] = closed | ||
| 1218 | tb.bumpLayoutGenerationLocked() | 1218 | tb.bumpLayoutGenerationLocked() | ||
| 1219 | tb.mu.Unlock() | 1219 | tb.mu.Unlock() | ||
| 1220 | - | d.applyTabLayout(sess, tb) | 1220 | + | sess.geometry.applyTabLayout(d, sess, tb) |
| 1221 | survivor.screen.Write([]byte("survivor")) | 1221 | survivor.screen.Write([]byte("survivor")) | ||
| 1222 | closed.screen.Write([]byte("closed")) | 1222 | closed.screen.Write([]byte("closed")) | ||
| 1223 | d.paint(sess, ac, true, nil) | 1223 | d.paint(sess, ac, true, nil) | ||
M internal/usecase/daemon/session.go +4 -4
| @@ -287,7 +287,7 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme | |||
| 287 | 287 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable")) | |
| 288 | 288 | } | |
| 289 | 289 | tbSize := contentSize(sz) | |
| 290 | - | initialPaneGeometry := scalePaneGeometry(geometry, tbSize) | |
| 290 | + | initialPaneGeometry := scaleSharedPTYGeometry(geometry, tbSize) | |
| 291 | 291 | var names []string | |
| 292 | 292 | var tabRecords []domain.CatalogueTabRecord | |
| 293 | 293 | if resuming && len(stopped.tabRecords) != 0 { | |
| @@ -653,10 +653,10 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom | |||
| 653 | 653 | sess.mu.Unlock() | |
| 654 | 654 | tbSize := contentSize(sess.fullViewportSize()) | |
| 655 | 655 | claimGeometry := domain.Geometry{Size: tbSize} | |
| 656 | - | if source, ok := sess.geometrySourceSnapshot(); ok { | |
| 657 | - | claimGeometry = scalePaneGeometry(source.geometry, tbSize) | |
| 656 | + | if source, ok := sess.geometry.sourceSnapshot(sess); ok { | |
| 657 | + | claimGeometry = scaleSharedPTYGeometry(source.geometry, tbSize) | |
| 658 | 658 | } else if ac != nil { | |
| 659 | - | claimGeometry = scalePaneGeometry(ac.geometrySnapshot(), tbSize) | |
| 659 | + | claimGeometry = scaleSharedPTYGeometry(ac.geometrySnapshot(), tbSize) | |
| 660 | 660 | } | |
| 661 | 661 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | |
| 662 | 662 | if err != nil { | |
| @@ -287,7 +287,7 @@ func (d *Daemon) createSessionLockedWithModeAndInactiveFence(name string, epheme | |||||
| 287 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable")) | 287 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable")) | ||
| 288 | } | 288 | } | ||
| 289 | tbSize := contentSize(sz) | 289 | tbSize := contentSize(sz) | ||
| 290 | - | initialPaneGeometry := scalePaneGeometry(geometry, tbSize) | 290 | + | initialPaneGeometry := scaleSharedPTYGeometry(geometry, tbSize) |
| 291 | var names []string | 291 | var names []string | ||
| 292 | var tabRecords []domain.CatalogueTabRecord | 292 | var tabRecords []domain.CatalogueTabRecord | ||
| 293 | if resuming && len(stopped.tabRecords) != 0 { | 293 | if resuming && len(stopped.tabRecords) != 0 { | ||
| @@ -653,10 +653,10 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom | |||||
| 653 | sess.mu.Unlock() | 653 | sess.mu.Unlock() | ||
| 654 | tbSize := contentSize(sess.fullViewportSize()) | 654 | tbSize := contentSize(sess.fullViewportSize()) | ||
| 655 | claimGeometry := domain.Geometry{Size: tbSize} | 655 | claimGeometry := domain.Geometry{Size: tbSize} | ||
| 656 | - | if source, ok := sess.geometrySourceSnapshot(); ok { | 656 | + | if source, ok := sess.geometry.sourceSnapshot(sess); ok { |
| 657 | - | claimGeometry = scalePaneGeometry(source.geometry, tbSize) | 657 | + | claimGeometry = scaleSharedPTYGeometry(source.geometry, tbSize) |
| 658 | } else if ac != nil { | 658 | } else if ac != nil { | ||
| 659 | - | claimGeometry = scalePaneGeometry(ac.geometrySnapshot(), tbSize) | 659 | + | claimGeometry = scaleSharedPTYGeometry(ac.geometrySnapshot(), tbSize) |
| 660 | } | 660 | } | ||
| 661 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | 661 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | ||
| 662 | if err != nil { | 662 | if err != nil { | ||
M internal/usecase/daemon/session_core.go +9 -15
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | // sessionCore is the state shared by every attachment-capable session. It is | |
| 11 | 11 | // embedded as the first field of session so the existing s.mu selector remains | |
| 12 | 12 | // the one lock guarding both attachment membership and local tab state. | |
| 13 | - | // Geometry claim paths use the partial order mu -> geometryMu -> sizeMu; no | |
| 13 | + | // Geometry claim paths use the partial order mu -> geometry.mu -> sizeMu; no | |
| 14 | 14 | // path acquires an architectural lock while holding sizeMu. | |
| 15 | 15 | type sessionCore struct { | |
| 16 | 16 | mu sync.Mutex | |
| @@ -25,20 +25,14 @@ type sessionCore struct { | |||
| 25 | 25 | attachments map[*attachedClient]struct{} | |
| 26 | 26 | attachmentOrder map[*attachedClient]uint64 | |
| 27 | 27 | nextAttachmentID uint64 | |
| 28 | - | // geometryOwner is the attachment whose latest attach/resume/resize claim | |
| 29 | - | // controls shared PTY/layout geometry. The pointer is published atomically | |
| 30 | - | // so resize transactions can reject stale claims without taking session.mu. | |
| 31 | - | // geometryMu serializes claim publication with the final shared-layout commit. | |
| 32 | - | geometryMu sync.Mutex | |
| 33 | - | geometryOwner atomic.Pointer[attachedClient] | |
| 34 | - | appliedGeometry domain.Geometry // guarded by geometryMu | |
| 35 | - | // geometryClaimSeq is mutated while mu and geometryMu are held; its value | |
| 36 | - | // is copied into attachedClient.geometryClaim. | |
| 37 | - | geometryClaimSeq uint64 | |
| 38 | - | createdAt int64 | |
| 39 | - | incarnation domain.IncarnationID | |
| 40 | - | mruAt atomic.Uint64 | |
| 41 | - | coordinator atomic.Pointer[renderCoordinator] | |
| 28 | + | // geometry owns immutable Attachment claims, latest-valid selection, and | |
| 29 | + | // accepted shared PTY geometry. Its zero value is ready for Session literals | |
| 30 | + | // used by restore and focused tests. | |
| 31 | + | geometry sharedPTYGeometry | |
| 32 | + | createdAt int64 | |
| 33 | + | incarnation domain.IncarnationID | |
| 34 | + | mruAt atomic.Uint64 | |
| 35 | + | coordinator atomic.Pointer[renderCoordinator] | |
| 42 | 36 | } | |
| 43 | 37 | ||
| 44 | 38 | func (s *session) core() *sessionCore { | |
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | // sessionCore is the state shared by every attachment-capable session. It is | 10 | // sessionCore is the state shared by every attachment-capable session. It is | ||
| 11 | // embedded as the first field of session so the existing s.mu selector remains | 11 | // embedded as the first field of session so the existing s.mu selector remains | ||
| 12 | // the one lock guarding both attachment membership and local tab state. | 12 | // the one lock guarding both attachment membership and local tab state. | ||
| 13 | - | // Geometry claim paths use the partial order mu -> geometryMu -> sizeMu; no | 13 | + | // Geometry claim paths use the partial order mu -> geometry.mu -> sizeMu; no |
| 14 | // path acquires an architectural lock while holding sizeMu. | 14 | // path acquires an architectural lock while holding sizeMu. | ||
| 15 | type sessionCore struct { | 15 | type sessionCore struct { | ||
| 16 | mu sync.Mutex | 16 | mu sync.Mutex | ||
| @@ -25,20 +25,14 @@ type sessionCore struct { | |||||
| 25 | attachments map[*attachedClient]struct{} | 25 | attachments map[*attachedClient]struct{} | ||
| 26 | attachmentOrder map[*attachedClient]uint64 | 26 | attachmentOrder map[*attachedClient]uint64 | ||
| 27 | nextAttachmentID uint64 | 27 | nextAttachmentID uint64 | ||
| 28 | - | // geometryOwner is the attachment whose latest attach/resume/resize claim | 28 | + | // geometry owns immutable Attachment claims, latest-valid selection, and |
| 29 | - | // controls shared PTY/layout geometry. The pointer is published atomically | 29 | + | // accepted shared PTY geometry. Its zero value is ready for Session literals |
| 30 | - | // so resize transactions can reject stale claims without taking session.mu. | 30 | + | // used by restore and focused tests. |
| 31 | - | // geometryMu serializes claim publication with the final shared-layout commit. | 31 | + | geometry sharedPTYGeometry |
| 32 | - | geometryMu sync.Mutex | 32 | + | createdAt int64 |
| 33 | - | geometryOwner atomic.Pointer[attachedClient] | 33 | + | incarnation domain.IncarnationID |
| 34 | - | appliedGeometry domain.Geometry // guarded by geometryMu | 34 | + | mruAt atomic.Uint64 |
| 35 | - | // geometryClaimSeq is mutated while mu and geometryMu are held; its value | 35 | + | coordinator atomic.Pointer[renderCoordinator] |
| 36 | - | // is copied into attachedClient.geometryClaim. | |||
| 37 | - | geometryClaimSeq uint64 | |||
| 38 | - | createdAt int64 | |||
| 39 | - | incarnation domain.IncarnationID | |||
| 40 | - | mruAt atomic.Uint64 | |||
| 41 | - | coordinator atomic.Pointer[renderCoordinator] | |||
| 42 | } | 36 | } | ||
| 43 | 37 | ||||
| 44 | func (s *session) core() *sessionCore { | 38 | func (s *session) core() *sessionCore { | ||
M internal/usecase/daemon/session_geometry.go +219 -89
| @@ -1,67 +1,212 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/internal/domain" | |
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | "sync/atomic" | |
| 4 | 6 | ||
| 5 | - | // sessionGeometrySource identifies the attachment whose latest attach, | |
| 6 | - | // resume, or resize claim controls shared PTY/layout geometry. claim is zero | |
| 7 | - | // only for legacy/headless test fixtures that construct attachment maps | |
| 8 | - | // directly instead of publishing them through the attachment registry. | |
| 9 | - | type sessionGeometrySource struct { | |
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // sharedPTYClaim is one immutable attach, resume, or resize claim for shared | |
| 11 | + | // Session PTY geometry. A Session retains only the latest claim per registered | |
| 12 | + | // Attachment; historical claims from the same Attachment are never fallback | |
| 13 | + | // candidates. | |
| 14 | + | type sharedPTYClaim struct { | |
| 10 | 15 | attachment *attachedClient | |
| 11 | 16 | geometry domain.Geometry | |
| 12 | - | claim uint64 | |
| 17 | + | sequence uint64 | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // sharedPTYGeometry is the concrete Session Module for shared PTY geometry. | |
| 21 | + | // It owns claim publication, latest-valid selection, and accepted geometry. | |
| 22 | + | // Attachment, render-coordinator, Tab, Pane, floating, and Move state retain | |
| 23 | + | // their independent owners and are consumed through their existing fences. | |
| 24 | + | type sharedPTYGeometry struct { | |
| 25 | + | mu sync.Mutex | |
| 26 | + | latest atomic.Pointer[sharedPTYClaim] | |
| 27 | + | claims map[*attachedClient]*sharedPTYClaim // session.mu -> mu | |
| 28 | + | sequence uint64 // session.mu -> mu | |
| 29 | + | applied domain.Geometry // mu | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // latestValidClaimLocked selects the most recent valid remaining Attachment | |
| 33 | + | // claim. The caller holds both session.mu and g.mu. | |
| 34 | + | func (g *sharedPTYGeometry) latestValidClaimLocked(core *sessionCore, exclude *attachedClient) *sharedPTYClaim { | |
| 35 | + | if g == nil || core == nil { | |
| 36 | + | return nil | |
| 37 | + | } | |
| 38 | + | var latest *sharedPTYClaim | |
| 39 | + | var latestOrder uint64 | |
| 40 | + | for attachment := range core.attachments { | |
| 41 | + | if attachment == nil || attachment == exclude || !attachment.geometrySnapshot().Valid() { | |
| 42 | + | continue | |
| 43 | + | } | |
| 44 | + | claim := g.claims[attachment] | |
| 45 | + | if claim == nil || !claim.geometry.Valid() { | |
| 46 | + | continue | |
| 47 | + | } | |
| 48 | + | order := core.attachmentOrder[attachment] | |
| 49 | + | if latest == nil || claim.sequence > latest.sequence || | |
| 50 | + | (claim.sequence == latest.sequence && order > latestOrder) { | |
| 51 | + | latest = claim | |
| 52 | + | latestOrder = order | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | return latest | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // refreshLocked repairs an invalid publication without creating a newer | |
| 59 | + | // claim. The caller holds session.mu. | |
| 60 | + | func (g *sharedPTYGeometry) refreshLocked(core *sessionCore) { | |
| 61 | + | if g == nil || core == nil { | |
| 62 | + | return | |
| 63 | + | } | |
| 64 | + | g.mu.Lock() | |
| 65 | + | defer g.mu.Unlock() | |
| 66 | + | latest := g.latest.Load() | |
| 67 | + | if latest != nil && latest.attachment != nil && g.claims[latest.attachment] == latest && | |
| 68 | + | latest.geometry.Valid() && latest.attachment.geometrySnapshot().Valid() { | |
| 69 | + | if _, registered := core.attachments[latest.attachment]; registered { | |
| 70 | + | return | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | g.latest.Store(g.latestValidClaimLocked(core, nil)) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // claimLocked publishes a new immutable claim. The caller holds session.mu; | |
| 77 | + | // claim publication then follows the session.mu -> geometry.mu order. | |
| 78 | + | func (g *sharedPTYGeometry) claimLocked(core *sessionCore, attachment *attachedClient, geometry domain.Geometry) *sharedPTYClaim { | |
| 79 | + | if g == nil || core == nil || attachment == nil || !geometry.Valid() { | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | g.mu.Lock() | |
| 83 | + | defer g.mu.Unlock() | |
| 84 | + | if g.claims == nil { | |
| 85 | + | g.claims = make(map[*attachedClient]*sharedPTYClaim) | |
| 86 | + | } | |
| 87 | + | g.sequence++ | |
| 88 | + | claim := &sharedPTYClaim{ | |
| 89 | + | attachment: attachment, | |
| 90 | + | geometry: geometry.NormalizePixels(), | |
| 91 | + | sequence: g.sequence, | |
| 92 | + | } | |
| 93 | + | g.claims[attachment] = claim | |
| 94 | + | g.latest.Store(claim) | |
| 95 | + | return claim | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // removeAttachmentLocked removes the exact Attachment claim and republishes | |
| 99 | + | // the most recent valid remaining claimant. The caller holds session.mu. | |
| 100 | + | func (g *sharedPTYGeometry) removeAttachmentLocked(core *sessionCore, attachment *attachedClient) { | |
| 101 | + | if g == nil || core == nil || attachment == nil { | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | g.mu.Lock() | |
| 105 | + | defer g.mu.Unlock() | |
| 106 | + | delete(g.claims, attachment) | |
| 107 | + | latest := g.latest.Load() | |
| 108 | + | if latest == nil || latest.attachment == attachment { | |
| 109 | + | g.latest.Store(g.latestValidClaimLocked(core, attachment)) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (g *sharedPTYGeometry) claimAttachment(sess *session, attachment *attachedClient) (*sharedPTYClaim, bool) { | |
| 114 | + | if attachment == nil { | |
| 115 | + | return nil, false | |
| 116 | + | } | |
| 117 | + | return g.claimGeometry(sess, attachment, attachment.geometrySnapshot()) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (g *sharedPTYGeometry) claimSize(sess *session, attachment *attachedClient, size domain.Size) (*sharedPTYClaim, bool) { | |
| 121 | + | if attachment == nil { | |
| 122 | + | return nil, false | |
| 123 | + | } | |
| 124 | + | // Layout and floating requests carry only shared cells. Preserve the | |
| 125 | + | // claimant's complete terminal pixel pair for pane mapping and fallback. | |
| 126 | + | geometry := attachment.geometrySnapshot() | |
| 127 | + | geometry.Size = size | |
| 128 | + | return g.claimGeometry(sess, attachment, geometry) | |
| 13 | 129 | } | |
| 14 | 130 | ||
| 15 | - | // geometrySourceSnapshot returns the latest live attachment claim. The | |
| 16 | - | // attachment registry publishes geometryOwner under the session lock, and this | |
| 17 | - | // function reads the owner, claim, and claim size under that same lock. Later | |
| 18 | - | // revalidation at the layout publication fence is lock-free so a stale claim | |
| 19 | - | // can be rejected without taking session.mu. | |
| 20 | - | func (s *session) geometrySourceSnapshot() (sessionGeometrySource, bool) { | |
| 21 | - | if s == nil { | |
| 22 | - | return sessionGeometrySource{}, false | |
| 131 | + | func (g *sharedPTYGeometry) claimGeometry(sess *session, attachment *attachedClient, geometry domain.Geometry) (*sharedPTYClaim, bool) { | |
| 132 | + | if g == nil || sess == nil || attachment == nil || !geometry.Valid() { | |
| 133 | + | return nil, false | |
| 134 | + | } | |
| 135 | + | sess.mu.Lock() | |
| 136 | + | defer sess.mu.Unlock() | |
| 137 | + | if _, registered := sess.attachments[attachment]; !registered { | |
| 138 | + | return nil, false | |
| 23 | 139 | } | |
| 140 | + | claim := g.claimLocked(sess.core(), attachment, geometry) | |
| 141 | + | return claim, claim != nil | |
| 142 | + | } | |
| 24 | 143 | ||
| 25 | - | s.mu.Lock() | |
| 26 | - | s.refreshGeometryOwnerLocked() | |
| 27 | - | owner := s.geometryOwner.Load() | |
| 28 | - | var geometry domain.Geometry | |
| 29 | - | var claim uint64 | |
| 30 | - | if owner != nil { | |
| 31 | - | geometry = owner.geometryClaimSnapshot() | |
| 32 | - | claim = owner.geometryClaim.Load() | |
| 144 | + | // release removes a claim that did not reach coordinator admission. It removes | |
| 145 | + | // only the exact current claim and never restores an older claim from the same | |
| 146 | + | // Attachment. | |
| 147 | + | func (g *sharedPTYGeometry) release(sess *session, claim *sharedPTYClaim) { | |
| 148 | + | if g == nil || sess == nil || claim == nil || claim.attachment == nil { | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | sess.mu.Lock() | |
| 152 | + | defer sess.mu.Unlock() | |
| 153 | + | g.mu.Lock() | |
| 154 | + | defer g.mu.Unlock() | |
| 155 | + | if g.latest.Load() != claim || g.claims[claim.attachment] != claim { | |
| 156 | + | return | |
| 33 | 157 | } | |
| 34 | - | s.mu.Unlock() | |
| 158 | + | delete(g.claims, claim.attachment) | |
| 159 | + | g.latest.Store(g.latestValidClaimLocked(sess.core(), claim.attachment)) | |
| 160 | + | } | |
| 35 | 161 | ||
| 36 | - | if owner == nil || !geometry.Valid() { | |
| 37 | - | return sessionGeometrySource{}, false | |
| 162 | + | // sourceSnapshot returns the latest live immutable claim. Refresh takes | |
| 163 | + | // session.mu only before Tab locks are involved; final transaction validation | |
| 164 | + | // uses current and remains lock-free. | |
| 165 | + | func (g *sharedPTYGeometry) sourceSnapshot(sess *session) (*sharedPTYClaim, bool) { | |
| 166 | + | if g == nil || sess == nil { | |
| 167 | + | return nil, false | |
| 168 | + | } | |
| 169 | + | sess.mu.Lock() | |
| 170 | + | g.refreshLocked(sess.core()) | |
| 171 | + | claim := g.latest.Load() | |
| 172 | + | sess.mu.Unlock() | |
| 173 | + | if claim == nil || claim.attachment == nil || !claim.geometry.Valid() { | |
| 174 | + | return nil, false | |
| 38 | 175 | } | |
| 39 | - | return sessionGeometrySource{attachment: owner, geometry: geometry, claim: claim}, true | |
| 176 | + | return claim, true | |
| 40 | 177 | } | |
| 41 | 178 | ||
| 42 | - | // geometryClaimTokenCurrent is deliberately lock-free. applySessionLayout invokes | |
| 43 | - | // its current callback while tab locks are held, so taking session.mu here | |
| 44 | - | // would reverse the session -> tab ordering used by layout mutation paths. | |
| 45 | - | func (s *session) geometryClaimTokenCurrent(attachment *attachedClient, claim uint64) bool { | |
| 46 | - | if s == nil || attachment == nil || claim == 0 { | |
| 47 | - | return true | |
| 179 | + | // current is deliberately one lock-free immutable-token comparison. Session | |
| 180 | + | // layout publication calls it while Tab locks and geometry.mu are held. | |
| 181 | + | func (g *sharedPTYGeometry) current(claim *sharedPTYClaim) bool { | |
| 182 | + | return g != nil && claim != nil && g.latest.Load() == claim | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (g *sharedPTYGeometry) appliedSnapshot() domain.Geometry { | |
| 186 | + | if g == nil { | |
| 187 | + | return domain.Geometry{} | |
| 48 | 188 | } | |
| 49 | - | return s.geometryOwner.Load() == attachment && attachment.geometryClaim.Load() == claim | |
| 189 | + | g.mu.Lock() | |
| 190 | + | defer g.mu.Unlock() | |
| 191 | + | return g.applied | |
| 50 | 192 | } | |
| 51 | 193 | ||
| 52 | - | func (s *session) geometryClaimCurrent(source sessionGeometrySource) bool { | |
| 53 | - | return s.geometryClaimTokenCurrent(source.attachment, source.claim) && | |
| 54 | - | (source.attachment == nil || source.attachment.geometryClaimSnapshot() == source.geometry) | |
| 194 | + | func (g *sharedPTYGeometry) publishAppliedIfCurrent(claim *sharedPTYClaim) { | |
| 195 | + | if g == nil || claim == nil { | |
| 196 | + | return | |
| 197 | + | } | |
| 198 | + | g.mu.Lock() | |
| 199 | + | if g.latest.Load() == claim { | |
| 200 | + | g.applied = claim.geometry | |
| 201 | + | } | |
| 202 | + | g.mu.Unlock() | |
| 55 | 203 | } | |
| 56 | 204 | ||
| 57 | - | // paneGeometry maps the controlling terminal's optional pixel dimensions to a | |
| 58 | - | // pane's cell rectangle. Integer division deliberately truncates: the kernel | |
| 59 | - | // winsize and CSI reports cannot represent fractional pixels. | |
| 60 | - | // scalePaneGeometry maps a full terminal claim to one pane rectangle. Pixel | |
| 61 | - | // dimensions are deliberately scaled with integer truncation, matching the | |
| 62 | - | // mapping used by paneGeometry, but it is also usable while a session is still | |
| 63 | - | // private and has not published its attachment owner. | |
| 64 | - | func scalePaneGeometry(full domain.Geometry, size domain.Size) domain.Geometry { | |
| 205 | + | // scaleSharedPTYGeometry is the sole cell/pixel mapping policy. Integer | |
| 206 | + | // division deliberately truncates because kernel winsize and CSI reports | |
| 207 | + | // cannot represent fractional pixels. Session construction uses it before a | |
| 208 | + | // claim can be published; live paths call paneGeometry below. | |
| 209 | + | func scaleSharedPTYGeometry(full domain.Geometry, size domain.Size) domain.Geometry { | |
| 65 | 210 | geometry := domain.Geometry{Size: size} | |
| 66 | 211 | if !full.Valid() || !size.Valid() || !full.PixelsKnown() { | |
| 67 | 212 | return geometry | |
| @@ -71,62 +216,51 @@ func scalePaneGeometry(full domain.Geometry, size domain.Size) domain.Geometry { | |||
| 71 | 216 | return geometry.NormalizePixels() | |
| 72 | 217 | } | |
| 73 | 218 | ||
| 74 | - | func (s *session) paneGeometry(size domain.Size) domain.Geometry { | |
| 75 | - | geometry := domain.Geometry{Size: size} | |
| 76 | - | if s == nil || !size.Valid() { | |
| 77 | - | return geometry | |
| 219 | + | func (g *sharedPTYGeometry) paneGeometry(size domain.Size) domain.Geometry { | |
| 220 | + | if g == nil { | |
| 221 | + | return domain.Geometry{Size: size} | |
| 78 | 222 | } | |
| 79 | - | owner := s.geometryOwner.Load() | |
| 80 | - | if owner == nil { | |
| 81 | - | return geometry | |
| 223 | + | claim := g.latest.Load() | |
| 224 | + | if claim == nil { | |
| 225 | + | return domain.Geometry{Size: size} | |
| 82 | 226 | } | |
| 83 | - | return scalePaneGeometry(owner.geometryClaimSnapshot(), size) | |
| 227 | + | return scaleSharedPTYGeometry(claim.geometry, size) | |
| 84 | 228 | } | |
| 85 | 229 | ||
| 86 | - | // sessionGeometryViewport is called with a non-nil session by the daemon | |
| 87 | - | // geometry paths; it returns ok == false when no valid claim exists. | |
| 88 | - | func sessionGeometryViewport(sess *session) (domain.Size, sessionGeometrySource, bool) { | |
| 89 | - | source, ok := sess.geometrySourceSnapshot() | |
| 230 | + | // viewport returns false when no valid Attachment claim exists. | |
| 231 | + | func (g *sharedPTYGeometry) viewport(sess *session) (domain.Size, *sharedPTYClaim, bool) { | |
| 232 | + | claim, ok := g.sourceSnapshot(sess) | |
| 90 | 233 | if !ok { | |
| 91 | - | return domain.Size{}, sessionGeometrySource{}, false | |
| 234 | + | return domain.Size{}, nil, false | |
| 92 | 235 | } | |
| 93 | - | content := contentSize(source.geometry.Size) | |
| 94 | - | return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true | |
| 236 | + | content := contentSize(claim.geometry.Size) | |
| 237 | + | return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, claim, true | |
| 95 | 238 | } | |
| 96 | 239 | ||
| 97 | - | // recalculateSessionGeometry reconciles shared PTY/layout geometry with the | |
| 98 | - | // latest live attachment claim. An explicit source records a new claim before | |
| 99 | - | // the snapshot; attach and resume paths already claimed ownership when they | |
| 100 | - | // entered the attachment registry. If the owner detaches, unregistering the | |
| 101 | - | // attachment selects the most recently claimed remaining attachment. | |
| 102 | - | func (d *Daemon) recalculateSessionGeometry(sess *session, source *attachedClient) bool { | |
| 240 | + | // reconcile applies the latest live Attachment claim to shared Session | |
| 241 | + | // PTY/layout geometry. An explicit source records a new claim first. | |
| 242 | + | func (g *sharedPTYGeometry) reconcile(d *Daemon, sess *session, source *attachedClient) bool { | |
| 103 | 243 | if sess == nil { | |
| 104 | 244 | return false | |
| 105 | 245 | } | |
| 106 | 246 | if source != nil { | |
| 107 | - | if _, ok := sess.claimGeometryOwner(source); !ok { | |
| 247 | + | if _, ok := g.claimAttachment(sess, source); !ok { | |
| 108 | 248 | return false | |
| 109 | 249 | } | |
| 110 | 250 | } | |
| 111 | - | want, owner, ok := sessionGeometryViewport(sess) | |
| 251 | + | want, claim, ok := g.viewport(sess) | |
| 112 | 252 | if !ok { | |
| 113 | 253 | return false | |
| 114 | 254 | } | |
| 115 | - | current := func() bool { return sess.geometryClaimCurrent(owner) } | |
| 116 | - | sess.geometryMu.Lock() | |
| 117 | - | appliedGeometry := sess.appliedGeometry | |
| 118 | - | sess.geometryMu.Unlock() | |
| 119 | - | pixelGeometryChanged := (appliedGeometry.PixelsKnown() || owner.geometry.PixelsKnown()) && appliedGeometry != owner.geometry | |
| 255 | + | current := func() bool { return g.current(claim) } | |
| 256 | + | applied := g.appliedSnapshot() | |
| 257 | + | pixelGeometryChanged := (applied.PixelsKnown() || claim.geometry.PixelsKnown()) && applied != claim.geometry | |
| 120 | 258 | if !pixelGeometryChanged && contentSize(sess.fullViewportSize()) == contentSize(want) { | |
| 121 | 259 | return false | |
| 122 | 260 | } | |
| 123 | - | failed, ok := d.applySessionLayout(sess, want, current, nil) | |
| 261 | + | failed, ok := g.applySessionLayout(d, sess, want, current, nil) | |
| 124 | 262 | if ok { | |
| 125 | - | sess.geometryMu.Lock() | |
| 126 | - | if current() { | |
| 127 | - | sess.appliedGeometry = owner.geometry | |
| 128 | - | } | |
| 129 | - | sess.geometryMu.Unlock() | |
| 263 | + | g.publishAppliedIfCurrent(claim) | |
| 130 | 264 | } | |
| 131 | 265 | if ok && len(failed) != 0 { | |
| 132 | 266 | seen := make(map[*tab]struct{}, len(failed)) | |
| @@ -138,29 +272,25 @@ func (d *Daemon) recalculateSessionGeometry(sess *session, source *attachedClien | |||
| 138 | 272 | continue | |
| 139 | 273 | } | |
| 140 | 274 | seen[member.tab] = struct{}{} | |
| 141 | - | d.scheduleAcceptedTabLayoutRetry(sess, member.tab) | |
| 275 | + | g.scheduleAcceptedTabLayoutRetry(d, sess, member.tab) | |
| 142 | 276 | } | |
| 143 | 277 | } | |
| 144 | 278 | return ok | |
| 145 | 279 | } | |
| 146 | 280 | ||
| 147 | - | // recalculateSessionGeometryAndInvalidate publishes a shared render wake after | |
| 148 | - | // canonical geometry changes. A nil source uses the latest attach/resume/resize | |
| 149 | - | // claim already published by the attachment registry. The wake is asynchronous | |
| 150 | - | // so a slow peer cannot block a resize or detach. | |
| 151 | - | func (d *Daemon) recalculateSessionGeometryAndInvalidate(sess *session, source *attachedClient, producer string) bool { | |
| 152 | - | if !d.recalculateSessionGeometry(sess, source) { | |
| 281 | + | func (g *sharedPTYGeometry) reconcileAndInvalidate(d *Daemon, sess *session, source *attachedClient, producer string) bool { | |
| 282 | + | if !g.reconcile(d, sess, source) { | |
| 153 | 283 | return false | |
| 154 | 284 | } | |
| 155 | 285 | d.invalidateRender(sess, nil, true, producer) | |
| 156 | 286 | return true | |
| 157 | 287 | } | |
| 158 | 288 | ||
| 159 | - | func (d *Daemon) recalculateSessionGeometryAndInvalidateAsync(sess *session, producer string) { | |
| 160 | - | if d == nil { | |
| 289 | + | func (g *sharedPTYGeometry) reconcileAndInvalidateAsync(d *Daemon, sess *session, producer string) { | |
| 290 | + | if g == nil || d == nil { | |
| 161 | 291 | return | |
| 162 | 292 | } | |
| 163 | 293 | d.sessWg.Go(func() { | |
| 164 | - | d.recalculateSessionGeometryAndInvalidate(sess, nil, producer) | |
| 294 | + | g.reconcileAndInvalidate(d, sess, nil, producer) | |
| 165 | 295 | }) | |
| 166 | 296 | } | |
| @@ -1,67 +1,212 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/vev/internal/domain" | 3 | + | import ( |
| 4 | + | "sync" | |||
| 5 | + | "sync/atomic" | |||
| 4 | 6 | ||||
| 5 | - | // sessionGeometrySource identifies the attachment whose latest attach, | 7 | + | "github.com/bnema/vev/internal/domain" |
| 6 | - | // resume, or resize claim controls shared PTY/layout geometry. claim is zero | 8 | + | ) |
| 7 | - | // only for legacy/headless test fixtures that construct attachment maps | 9 | + | |
| 8 | - | // directly instead of publishing them through the attachment registry. | 10 | + | // sharedPTYClaim is one immutable attach, resume, or resize claim for shared |
| 9 | - | type sessionGeometrySource struct { | 11 | + | // Session PTY geometry. A Session retains only the latest claim per registered |
| 12 | + | // Attachment; historical claims from the same Attachment are never fallback | |||
| 13 | + | // candidates. | |||
| 14 | + | type sharedPTYClaim struct { | |||
| 10 | attachment *attachedClient | 15 | attachment *attachedClient | ||
| 11 | geometry domain.Geometry | 16 | geometry domain.Geometry | ||
| 12 | - | claim uint64 | 17 | + | sequence uint64 |
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // sharedPTYGeometry is the concrete Session Module for shared PTY geometry. | |||
| 21 | + | // It owns claim publication, latest-valid selection, and accepted geometry. | |||
| 22 | + | // Attachment, render-coordinator, Tab, Pane, floating, and Move state retain | |||
| 23 | + | // their independent owners and are consumed through their existing fences. | |||
| 24 | + | type sharedPTYGeometry struct { | |||
| 25 | + | mu sync.Mutex | |||
| 26 | + | latest atomic.Pointer[sharedPTYClaim] | |||
| 27 | + | claims map[*attachedClient]*sharedPTYClaim // session.mu -> mu | |||
| 28 | + | sequence uint64 // session.mu -> mu | |||
| 29 | + | applied domain.Geometry // mu | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // latestValidClaimLocked selects the most recent valid remaining Attachment | |||
| 33 | + | // claim. The caller holds both session.mu and g.mu. | |||
| 34 | + | func (g *sharedPTYGeometry) latestValidClaimLocked(core *sessionCore, exclude *attachedClient) *sharedPTYClaim { | |||
| 35 | + | if g == nil || core == nil { | |||
| 36 | + | return nil | |||
| 37 | + | } | |||
| 38 | + | var latest *sharedPTYClaim | |||
| 39 | + | var latestOrder uint64 | |||
| 40 | + | for attachment := range core.attachments { | |||
| 41 | + | if attachment == nil || attachment == exclude || !attachment.geometrySnapshot().Valid() { | |||
| 42 | + | continue | |||
| 43 | + | } | |||
| 44 | + | claim := g.claims[attachment] | |||
| 45 | + | if claim == nil || !claim.geometry.Valid() { | |||
| 46 | + | continue | |||
| 47 | + | } | |||
| 48 | + | order := core.attachmentOrder[attachment] | |||
| 49 | + | if latest == nil || claim.sequence > latest.sequence || | |||
| 50 | + | (claim.sequence == latest.sequence && order > latestOrder) { | |||
| 51 | + | latest = claim | |||
| 52 | + | latestOrder = order | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | return latest | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // refreshLocked repairs an invalid publication without creating a newer | |||
| 59 | + | // claim. The caller holds session.mu. | |||
| 60 | + | func (g *sharedPTYGeometry) refreshLocked(core *sessionCore) { | |||
| 61 | + | if g == nil || core == nil { | |||
| 62 | + | return | |||
| 63 | + | } | |||
| 64 | + | g.mu.Lock() | |||
| 65 | + | defer g.mu.Unlock() | |||
| 66 | + | latest := g.latest.Load() | |||
| 67 | + | if latest != nil && latest.attachment != nil && g.claims[latest.attachment] == latest && | |||
| 68 | + | latest.geometry.Valid() && latest.attachment.geometrySnapshot().Valid() { | |||
| 69 | + | if _, registered := core.attachments[latest.attachment]; registered { | |||
| 70 | + | return | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | g.latest.Store(g.latestValidClaimLocked(core, nil)) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // claimLocked publishes a new immutable claim. The caller holds session.mu; | |||
| 77 | + | // claim publication then follows the session.mu -> geometry.mu order. | |||
| 78 | + | func (g *sharedPTYGeometry) claimLocked(core *sessionCore, attachment *attachedClient, geometry domain.Geometry) *sharedPTYClaim { | |||
| 79 | + | if g == nil || core == nil || attachment == nil || !geometry.Valid() { | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
| 82 | + | g.mu.Lock() | |||
| 83 | + | defer g.mu.Unlock() | |||
| 84 | + | if g.claims == nil { | |||
| 85 | + | g.claims = make(map[*attachedClient]*sharedPTYClaim) | |||
| 86 | + | } | |||
| 87 | + | g.sequence++ | |||
| 88 | + | claim := &sharedPTYClaim{ | |||
| 89 | + | attachment: attachment, | |||
| 90 | + | geometry: geometry.NormalizePixels(), | |||
| 91 | + | sequence: g.sequence, | |||
| 92 | + | } | |||
| 93 | + | g.claims[attachment] = claim | |||
| 94 | + | g.latest.Store(claim) | |||
| 95 | + | return claim | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // removeAttachmentLocked removes the exact Attachment claim and republishes | |||
| 99 | + | // the most recent valid remaining claimant. The caller holds session.mu. | |||
| 100 | + | func (g *sharedPTYGeometry) removeAttachmentLocked(core *sessionCore, attachment *attachedClient) { | |||
| 101 | + | if g == nil || core == nil || attachment == nil { | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | g.mu.Lock() | |||
| 105 | + | defer g.mu.Unlock() | |||
| 106 | + | delete(g.claims, attachment) | |||
| 107 | + | latest := g.latest.Load() | |||
| 108 | + | if latest == nil || latest.attachment == attachment { | |||
| 109 | + | g.latest.Store(g.latestValidClaimLocked(core, attachment)) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (g *sharedPTYGeometry) claimAttachment(sess *session, attachment *attachedClient) (*sharedPTYClaim, bool) { | |||
| 114 | + | if attachment == nil { | |||
| 115 | + | return nil, false | |||
| 116 | + | } | |||
| 117 | + | return g.claimGeometry(sess, attachment, attachment.geometrySnapshot()) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func (g *sharedPTYGeometry) claimSize(sess *session, attachment *attachedClient, size domain.Size) (*sharedPTYClaim, bool) { | |||
| 121 | + | if attachment == nil { | |||
| 122 | + | return nil, false | |||
| 123 | + | } | |||
| 124 | + | // Layout and floating requests carry only shared cells. Preserve the | |||
| 125 | + | // claimant's complete terminal pixel pair for pane mapping and fallback. | |||
| 126 | + | geometry := attachment.geometrySnapshot() | |||
| 127 | + | geometry.Size = size | |||
| 128 | + | return g.claimGeometry(sess, attachment, geometry) | |||
| 13 | } | 129 | } | ||
| 14 | 130 | ||||
| 15 | - | // geometrySourceSnapshot returns the latest live attachment claim. The | 131 | + | func (g *sharedPTYGeometry) claimGeometry(sess *session, attachment *attachedClient, geometry domain.Geometry) (*sharedPTYClaim, bool) { |
| 16 | - | // attachment registry publishes geometryOwner under the session lock, and this | 132 | + | if g == nil || sess == nil || attachment == nil || !geometry.Valid() { |
| 17 | - | // function reads the owner, claim, and claim size under that same lock. Later | 133 | + | return nil, false |
| 18 | - | // revalidation at the layout publication fence is lock-free so a stale claim | 134 | + | } |
| 19 | - | // can be rejected without taking session.mu. | 135 | + | sess.mu.Lock() |
| 20 | - | func (s *session) geometrySourceSnapshot() (sessionGeometrySource, bool) { | 136 | + | defer sess.mu.Unlock() |
| 21 | - | if s == nil { | 137 | + | if _, registered := sess.attachments[attachment]; !registered { |
| 22 | - | return sessionGeometrySource{}, false | 138 | + | return nil, false |
| 23 | } | 139 | } | ||
| 140 | + | claim := g.claimLocked(sess.core(), attachment, geometry) | |||
| 141 | + | return claim, claim != nil | |||
| 142 | + | } | |||
| 24 | 143 | ||||
| 25 | - | s.mu.Lock() | 144 | + | // release removes a claim that did not reach coordinator admission. It removes |
| 26 | - | s.refreshGeometryOwnerLocked() | 145 | + | // only the exact current claim and never restores an older claim from the same |
| 27 | - | owner := s.geometryOwner.Load() | 146 | + | // Attachment. |
| 28 | - | var geometry domain.Geometry | 147 | + | func (g *sharedPTYGeometry) release(sess *session, claim *sharedPTYClaim) { |
| 29 | - | var claim uint64 | 148 | + | if g == nil || sess == nil || claim == nil || claim.attachment == nil { |
| 30 | - | if owner != nil { | 149 | + | return |
| 31 | - | geometry = owner.geometryClaimSnapshot() | 150 | + | } |
| 32 | - | claim = owner.geometryClaim.Load() | 151 | + | sess.mu.Lock() |
| 152 | + | defer sess.mu.Unlock() | |||
| 153 | + | g.mu.Lock() | |||
| 154 | + | defer g.mu.Unlock() | |||
| 155 | + | if g.latest.Load() != claim || g.claims[claim.attachment] != claim { | |||
| 156 | + | return | |||
| 33 | } | 157 | } | ||
| 34 | - | s.mu.Unlock() | 158 | + | delete(g.claims, claim.attachment) |
| 159 | + | g.latest.Store(g.latestValidClaimLocked(sess.core(), claim.attachment)) | |||
| 160 | + | } | |||
| 35 | 161 | ||||
| 36 | - | if owner == nil || !geometry.Valid() { | 162 | + | // sourceSnapshot returns the latest live immutable claim. Refresh takes |
| 37 | - | return sessionGeometrySource{}, false | 163 | + | // session.mu only before Tab locks are involved; final transaction validation |
| 164 | + | // uses current and remains lock-free. | |||
| 165 | + | func (g *sharedPTYGeometry) sourceSnapshot(sess *session) (*sharedPTYClaim, bool) { | |||
| 166 | + | if g == nil || sess == nil { | |||
| 167 | + | return nil, false | |||
| 168 | + | } | |||
| 169 | + | sess.mu.Lock() | |||
| 170 | + | g.refreshLocked(sess.core()) | |||
| 171 | + | claim := g.latest.Load() | |||
| 172 | + | sess.mu.Unlock() | |||
| 173 | + | if claim == nil || claim.attachment == nil || !claim.geometry.Valid() { | |||
| 174 | + | return nil, false | |||
| 38 | } | 175 | } | ||
| 39 | - | return sessionGeometrySource{attachment: owner, geometry: geometry, claim: claim}, true | 176 | + | return claim, true |
| 40 | } | 177 | } | ||
| 41 | 178 | ||||
| 42 | - | // geometryClaimTokenCurrent is deliberately lock-free. applySessionLayout invokes | 179 | + | // current is deliberately one lock-free immutable-token comparison. Session |
| 43 | - | // its current callback while tab locks are held, so taking session.mu here | 180 | + | // layout publication calls it while Tab locks and geometry.mu are held. |
| 44 | - | // would reverse the session -> tab ordering used by layout mutation paths. | 181 | + | func (g *sharedPTYGeometry) current(claim *sharedPTYClaim) bool { |
| 45 | - | func (s *session) geometryClaimTokenCurrent(attachment *attachedClient, claim uint64) bool { | 182 | + | return g != nil && claim != nil && g.latest.Load() == claim |
| 46 | - | if s == nil || attachment == nil || claim == 0 { | 183 | + | } |
| 47 | - | return true | 184 | + | |
| 185 | + | func (g *sharedPTYGeometry) appliedSnapshot() domain.Geometry { | |||
| 186 | + | if g == nil { | |||
| 187 | + | return domain.Geometry{} | |||
| 48 | } | 188 | } | ||
| 49 | - | return s.geometryOwner.Load() == attachment && attachment.geometryClaim.Load() == claim | 189 | + | g.mu.Lock() |
| 190 | + | defer g.mu.Unlock() | |||
| 191 | + | return g.applied | |||
| 50 | } | 192 | } | ||
| 51 | 193 | ||||
| 52 | - | func (s *session) geometryClaimCurrent(source sessionGeometrySource) bool { | 194 | + | func (g *sharedPTYGeometry) publishAppliedIfCurrent(claim *sharedPTYClaim) { |
| 53 | - | return s.geometryClaimTokenCurrent(source.attachment, source.claim) && | 195 | + | if g == nil || claim == nil { |
| 54 | - | (source.attachment == nil || source.attachment.geometryClaimSnapshot() == source.geometry) | 196 | + | return |
| 197 | + | } | |||
| 198 | + | g.mu.Lock() | |||
| 199 | + | if g.latest.Load() == claim { | |||
| 200 | + | g.applied = claim.geometry | |||
| 201 | + | } | |||
| 202 | + | g.mu.Unlock() | |||
| 55 | } | 203 | } | ||
| 56 | 204 | ||||
| 57 | - | // paneGeometry maps the controlling terminal's optional pixel dimensions to a | 205 | + | // scaleSharedPTYGeometry is the sole cell/pixel mapping policy. Integer |
| 58 | - | // pane's cell rectangle. Integer division deliberately truncates: the kernel | 206 | + | // division deliberately truncates because kernel winsize and CSI reports |
| 59 | - | // winsize and CSI reports cannot represent fractional pixels. | 207 | + | // cannot represent fractional pixels. Session construction uses it before a |
| 60 | - | // scalePaneGeometry maps a full terminal claim to one pane rectangle. Pixel | 208 | + | // claim can be published; live paths call paneGeometry below. |
| 61 | - | // dimensions are deliberately scaled with integer truncation, matching the | 209 | + | func scaleSharedPTYGeometry(full domain.Geometry, size domain.Size) domain.Geometry { |
| 62 | - | // mapping used by paneGeometry, but it is also usable while a session is still | |||
| 63 | - | // private and has not published its attachment owner. | |||
| 64 | - | func scalePaneGeometry(full domain.Geometry, size domain.Size) domain.Geometry { | |||
| 65 | geometry := domain.Geometry{Size: size} | 210 | geometry := domain.Geometry{Size: size} | ||
| 66 | if !full.Valid() || !size.Valid() || !full.PixelsKnown() { | 211 | if !full.Valid() || !size.Valid() || !full.PixelsKnown() { | ||
| 67 | return geometry | 212 | return geometry | ||
| @@ -71,62 +216,51 @@ func scalePaneGeometry(full domain.Geometry, size domain.Size) domain.Geometry { | |||||
| 71 | return geometry.NormalizePixels() | 216 | return geometry.NormalizePixels() | ||
| 72 | } | 217 | } | ||
| 73 | 218 | ||||
| 74 | - | func (s *session) paneGeometry(size domain.Size) domain.Geometry { | 219 | + | func (g *sharedPTYGeometry) paneGeometry(size domain.Size) domain.Geometry { |
| 75 | - | geometry := domain.Geometry{Size: size} | 220 | + | if g == nil { |
| 76 | - | if s == nil || !size.Valid() { | 221 | + | return domain.Geometry{Size: size} |
| 77 | - | return geometry | |||
| 78 | } | 222 | } | ||
| 79 | - | owner := s.geometryOwner.Load() | 223 | + | claim := g.latest.Load() |
| 80 | - | if owner == nil { | 224 | + | if claim == nil { |
| 81 | - | return geometry | 225 | + | return domain.Geometry{Size: size} |
| 82 | } | 226 | } | ||
| 83 | - | return scalePaneGeometry(owner.geometryClaimSnapshot(), size) | 227 | + | return scaleSharedPTYGeometry(claim.geometry, size) |
| 84 | } | 228 | } | ||
| 85 | 229 | ||||
| 86 | - | // sessionGeometryViewport is called with a non-nil session by the daemon | 230 | + | // viewport returns false when no valid Attachment claim exists. |
| 87 | - | // geometry paths; it returns ok == false when no valid claim exists. | 231 | + | func (g *sharedPTYGeometry) viewport(sess *session) (domain.Size, *sharedPTYClaim, bool) { |
| 88 | - | func sessionGeometryViewport(sess *session) (domain.Size, sessionGeometrySource, bool) { | 232 | + | claim, ok := g.sourceSnapshot(sess) |
| 89 | - | source, ok := sess.geometrySourceSnapshot() | |||
| 90 | if !ok { | 233 | if !ok { | ||
| 91 | - | return domain.Size{}, sessionGeometrySource{}, false | 234 | + | return domain.Size{}, nil, false |
| 92 | } | 235 | } | ||
| 93 | - | content := contentSize(source.geometry.Size) | 236 | + | content := contentSize(claim.geometry.Size) |
| 94 | - | return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true | 237 | + | return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, claim, true |
| 95 | } | 238 | } | ||
| 96 | 239 | ||||
| 97 | - | // recalculateSessionGeometry reconciles shared PTY/layout geometry with the | 240 | + | // reconcile applies the latest live Attachment claim to shared Session |
| 98 | - | // latest live attachment claim. An explicit source records a new claim before | 241 | + | // PTY/layout geometry. An explicit source records a new claim first. |
| 99 | - | // the snapshot; attach and resume paths already claimed ownership when they | 242 | + | func (g *sharedPTYGeometry) reconcile(d *Daemon, sess *session, source *attachedClient) bool { |
| 100 | - | // entered the attachment registry. If the owner detaches, unregistering the | |||
| 101 | - | // attachment selects the most recently claimed remaining attachment. | |||
| 102 | - | func (d *Daemon) recalculateSessionGeometry(sess *session, source *attachedClient) bool { | |||
| 103 | if sess == nil { | 243 | if sess == nil { | ||
| 104 | return false | 244 | return false | ||
| 105 | } | 245 | } | ||
| 106 | if source != nil { | 246 | if source != nil { | ||
| 107 | - | if _, ok := sess.claimGeometryOwner(source); !ok { | 247 | + | if _, ok := g.claimAttachment(sess, source); !ok { |
| 108 | return false | 248 | return false | ||
| 109 | } | 249 | } | ||
| 110 | } | 250 | } | ||
| 111 | - | want, owner, ok := sessionGeometryViewport(sess) | 251 | + | want, claim, ok := g.viewport(sess) |
| 112 | if !ok { | 252 | if !ok { | ||
| 113 | return false | 253 | return false | ||
| 114 | } | 254 | } | ||
| 115 | - | current := func() bool { return sess.geometryClaimCurrent(owner) } | 255 | + | current := func() bool { return g.current(claim) } |
| 116 | - | sess.geometryMu.Lock() | 256 | + | applied := g.appliedSnapshot() |
| 117 | - | appliedGeometry := sess.appliedGeometry | 257 | + | pixelGeometryChanged := (applied.PixelsKnown() || claim.geometry.PixelsKnown()) && applied != claim.geometry |
| 118 | - | sess.geometryMu.Unlock() | |||
| 119 | - | pixelGeometryChanged := (appliedGeometry.PixelsKnown() || owner.geometry.PixelsKnown()) && appliedGeometry != owner.geometry | |||
| 120 | if !pixelGeometryChanged && contentSize(sess.fullViewportSize()) == contentSize(want) { | 258 | if !pixelGeometryChanged && contentSize(sess.fullViewportSize()) == contentSize(want) { | ||
| 121 | return false | 259 | return false | ||
| 122 | } | 260 | } | ||
| 123 | - | failed, ok := d.applySessionLayout(sess, want, current, nil) | 261 | + | failed, ok := g.applySessionLayout(d, sess, want, current, nil) |
| 124 | if ok { | 262 | if ok { | ||
| 125 | - | sess.geometryMu.Lock() | 263 | + | g.publishAppliedIfCurrent(claim) |
| 126 | - | if current() { | |||
| 127 | - | sess.appliedGeometry = owner.geometry | |||
| 128 | - | } | |||
| 129 | - | sess.geometryMu.Unlock() | |||
| 130 | } | 264 | } | ||
| 131 | if ok && len(failed) != 0 { | 265 | if ok && len(failed) != 0 { | ||
| 132 | seen := make(map[*tab]struct{}, len(failed)) | 266 | seen := make(map[*tab]struct{}, len(failed)) | ||
| @@ -138,29 +272,25 @@ func (d *Daemon) recalculateSessionGeometry(sess *session, source *attachedClien | |||||
| 138 | continue | 272 | continue | ||
| 139 | } | 273 | } | ||
| 140 | seen[member.tab] = struct{}{} | 274 | seen[member.tab] = struct{}{} | ||
| 141 | - | d.scheduleAcceptedTabLayoutRetry(sess, member.tab) | 275 | + | g.scheduleAcceptedTabLayoutRetry(d, sess, member.tab) |
| 142 | } | 276 | } | ||
| 143 | } | 277 | } | ||
| 144 | return ok | 278 | return ok | ||
| 145 | } | 279 | } | ||
| 146 | 280 | ||||
| 147 | - | // recalculateSessionGeometryAndInvalidate publishes a shared render wake after | 281 | + | func (g *sharedPTYGeometry) reconcileAndInvalidate(d *Daemon, sess *session, source *attachedClient, producer string) bool { |
| 148 | - | // canonical geometry changes. A nil source uses the latest attach/resume/resize | 282 | + | if !g.reconcile(d, sess, source) { |
| 149 | - | // claim already published by the attachment registry. The wake is asynchronous | |||
| 150 | - | // so a slow peer cannot block a resize or detach. | |||
| 151 | - | func (d *Daemon) recalculateSessionGeometryAndInvalidate(sess *session, source *attachedClient, producer string) bool { | |||
| 152 | - | if !d.recalculateSessionGeometry(sess, source) { | |||
| 153 | return false | 283 | return false | ||
| 154 | } | 284 | } | ||
| 155 | d.invalidateRender(sess, nil, true, producer) | 285 | d.invalidateRender(sess, nil, true, producer) | ||
| 156 | return true | 286 | return true | ||
| 157 | } | 287 | } | ||
| 158 | 288 | ||||
| 159 | - | func (d *Daemon) recalculateSessionGeometryAndInvalidateAsync(sess *session, producer string) { | 289 | + | func (g *sharedPTYGeometry) reconcileAndInvalidateAsync(d *Daemon, sess *session, producer string) { |
| 160 | - | if d == nil { | 290 | + | if g == nil || d == nil { |
| 161 | return | 291 | return | ||
| 162 | } | 292 | } | ||
| 163 | d.sessWg.Go(func() { | 293 | d.sessWg.Go(func() { | ||
| 164 | - | d.recalculateSessionGeometryAndInvalidate(sess, nil, producer) | 294 | + | g.reconcileAndInvalidate(d, sess, nil, producer) |
| 165 | }) | 295 | }) | ||
| 166 | } | 296 | } | ||
A internal/usecase/daemon/shared_pty_geometry_test.go +95 -0
| @@ -0,0 +1,95 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestSharedPTYGeometryCharacterization(t *testing.T) { | |
| 12 | + | tests := []struct { | |
| 13 | + | name string | |
| 14 | + | run func(*testing.T) | |
| 15 | + | }{ | |
| 16 | + | { | |
| 17 | + | name: "rejected claim restores most recent remaining attachment", | |
| 18 | + | run: func(t *testing.T) { | |
| 19 | + | sess := &session{} | |
| 20 | + | first := &attachedClient{} | |
| 21 | + | first.setGeometry(domain.Geometry{ | |
| 22 | + | Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480, | |
| 23 | + | }) | |
| 24 | + | second := &attachedClient{} | |
| 25 | + | second.setGeometry(domain.Geometry{ | |
| 26 | + | Size: domain.Size{Cols: 100, Rows: 30}, PixelWidth: 1000, PixelHeight: 600, | |
| 27 | + | }) | |
| 28 | + | require.True(t, sess.registerAttachment(first)) | |
| 29 | + | require.True(t, sess.registerAttachment(second)) | |
| 30 | + | ||
| 31 | + | claim, claimed := sess.geometry.claimSize(sess, second, domain.Size{Cols: 120, Rows: 40}) | |
| 32 | + | require.True(t, claimed) | |
| 33 | + | sess.geometry.release(sess, claim) | |
| 34 | + | ||
| 35 | + | source, ok := sess.geometry.sourceSnapshot(sess) | |
| 36 | + | require.True(t, ok) | |
| 37 | + | require.Same(t, first, source.attachment) | |
| 38 | + | require.Equal(t, first.geometrySnapshot(), source.geometry) | |
| 39 | + | }, | |
| 40 | + | }, | |
| 41 | + | { | |
| 42 | + | name: "losing shared claim retains attachment local geometry", | |
| 43 | + | run: func(t *testing.T) { | |
| 44 | + | pty, releasePTY := newBlockingPTY(t) | |
| 45 | + | t.Cleanup(releasePTY) | |
| 46 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 47 | + | firstTransport, _ := newCapturingTransport(t) | |
| 48 | + | sess, first, err := d.route(ports.Hello{ | |
| 49 | + | Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", | |
| 50 | + | Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}, | |
| 51 | + | }, firstTransport) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | secondTransport, _ := newCapturingTransport(t) | |
| 54 | + | _, second, err := d.route(ports.Hello{ | |
| 55 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", | |
| 56 | + | Size: domain.Size{Cols: 100, Rows: 40}, ClientID: [16]byte{2}, | |
| 57 | + | }, secondTransport) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | ||
| 60 | + | rc := sess.renderCoordinator() | |
| 61 | + | lease := rc.attachmentLease(second) | |
| 62 | + | require.True(t, rc.markAttachmentReady(lease)) | |
| 63 | + | token := sess.captureAttachmentCapability(second, secondTransport) | |
| 64 | + | token.lease = lease | |
| 65 | + | second.installTestAttachmentCapability(token) | |
| 66 | + | effect, admitted := second.beginAttachmentEffect(token) | |
| 67 | + | require.True(t, admitted) | |
| 68 | + | defer effect.End() | |
| 69 | + | ||
| 70 | + | requested := domain.Geometry{ | |
| 71 | + | Size: domain.Size{Cols: 120, Rows: 50}, PixelWidth: 1200, PixelHeight: 1000, | |
| 72 | + | } | |
| 73 | + | var superseded bool | |
| 74 | + | d.beforeSessionResizePublication = func() { | |
| 75 | + | if superseded { | |
| 76 | + | return | |
| 77 | + | } | |
| 78 | + | superseded = true | |
| 79 | + | _, claimed := sess.geometry.claimAttachment(sess, first) | |
| 80 | + | require.True(t, claimed) | |
| 81 | + | } | |
| 82 | + | d.resizeAttachmentGeometryForLease(effect, requested) | |
| 83 | + | ||
| 84 | + | require.Equal(t, requested, second.geometrySnapshot(), "a losing shared claim must retain its Attachment-local window") | |
| 85 | + | source, ok := sess.geometry.sourceSnapshot(sess) | |
| 86 | + | require.True(t, ok) | |
| 87 | + | require.Same(t, first, source.attachment) | |
| 88 | + | }, | |
| 89 | + | }, | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | for _, tt := range tests { | |
| 93 | + | t.Run(tt.name, tt.run) | |
| 94 | + | } | |
| 95 | + | } | |
| @@ -0,0 +1,95 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestSharedPTYGeometryCharacterization(t *testing.T) { | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | run func(*testing.T) | |||
| 15 | + | }{ | |||
| 16 | + | { | |||
| 17 | + | name: "rejected claim restores most recent remaining attachment", | |||
| 18 | + | run: func(t *testing.T) { | |||
| 19 | + | sess := &session{} | |||
| 20 | + | first := &attachedClient{} | |||
| 21 | + | first.setGeometry(domain.Geometry{ | |||
| 22 | + | Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 800, PixelHeight: 480, | |||
| 23 | + | }) | |||
| 24 | + | second := &attachedClient{} | |||
| 25 | + | second.setGeometry(domain.Geometry{ | |||
| 26 | + | Size: domain.Size{Cols: 100, Rows: 30}, PixelWidth: 1000, PixelHeight: 600, | |||
| 27 | + | }) | |||
| 28 | + | require.True(t, sess.registerAttachment(first)) | |||
| 29 | + | require.True(t, sess.registerAttachment(second)) | |||
| 30 | + | ||||
| 31 | + | claim, claimed := sess.geometry.claimSize(sess, second, domain.Size{Cols: 120, Rows: 40}) | |||
| 32 | + | require.True(t, claimed) | |||
| 33 | + | sess.geometry.release(sess, claim) | |||
| 34 | + | ||||
| 35 | + | source, ok := sess.geometry.sourceSnapshot(sess) | |||
| 36 | + | require.True(t, ok) | |||
| 37 | + | require.Same(t, first, source.attachment) | |||
| 38 | + | require.Equal(t, first.geometrySnapshot(), source.geometry) | |||
| 39 | + | }, | |||
| 40 | + | }, | |||
| 41 | + | { | |||
| 42 | + | name: "losing shared claim retains attachment local geometry", | |||
| 43 | + | run: func(t *testing.T) { | |||
| 44 | + | pty, releasePTY := newBlockingPTY(t) | |||
| 45 | + | t.Cleanup(releasePTY) | |||
| 46 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 47 | + | firstTransport, _ := newCapturingTransport(t) | |||
| 48 | + | sess, first, err := d.route(ports.Hello{ | |||
| 49 | + | Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", | |||
| 50 | + | Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}, | |||
| 51 | + | }, firstTransport) | |||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | secondTransport, _ := newCapturingTransport(t) | |||
| 54 | + | _, second, err := d.route(ports.Hello{ | |||
| 55 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", | |||
| 56 | + | Size: domain.Size{Cols: 100, Rows: 40}, ClientID: [16]byte{2}, | |||
| 57 | + | }, secondTransport) | |||
| 58 | + | require.NoError(t, err) | |||
| 59 | + | ||||
| 60 | + | rc := sess.renderCoordinator() | |||
| 61 | + | lease := rc.attachmentLease(second) | |||
| 62 | + | require.True(t, rc.markAttachmentReady(lease)) | |||
| 63 | + | token := sess.captureAttachmentCapability(second, secondTransport) | |||
| 64 | + | token.lease = lease | |||
| 65 | + | second.installTestAttachmentCapability(token) | |||
| 66 | + | effect, admitted := second.beginAttachmentEffect(token) | |||
| 67 | + | require.True(t, admitted) | |||
| 68 | + | defer effect.End() | |||
| 69 | + | ||||
| 70 | + | requested := domain.Geometry{ | |||
| 71 | + | Size: domain.Size{Cols: 120, Rows: 50}, PixelWidth: 1200, PixelHeight: 1000, | |||
| 72 | + | } | |||
| 73 | + | var superseded bool | |||
| 74 | + | d.beforeSessionResizePublication = func() { | |||
| 75 | + | if superseded { | |||
| 76 | + | return | |||
| 77 | + | } | |||
| 78 | + | superseded = true | |||
| 79 | + | _, claimed := sess.geometry.claimAttachment(sess, first) | |||
| 80 | + | require.True(t, claimed) | |||
| 81 | + | } | |||
| 82 | + | d.resizeAttachmentGeometryForLease(effect, requested) | |||
| 83 | + | ||||
| 84 | + | require.Equal(t, requested, second.geometrySnapshot(), "a losing shared claim must retain its Attachment-local window") | |||
| 85 | + | source, ok := sess.geometry.sourceSnapshot(sess) | |||
| 86 | + | require.True(t, ok) | |||
| 87 | + | require.Same(t, first, source.attachment) | |||
| 88 | + | }, | |||
| 89 | + | }, | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | for _, tt := range tests { | |||
| 93 | + | t.Run(tt.name, tt.run) | |||
| 94 | + | } | |||
| 95 | + | } | |||
M internal/usecase/daemon/transactional_resize.go +51 -51
| @@ -90,7 +90,7 @@ func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) pre | |||
| 90 | 90 | paneSize := rectSize(placement.Content) | |
| 91 | 91 | plan.members = append(plan.members, resizeMember{ | |
| 92 | 92 | session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content, | |
| 93 | - | geometry: sess.paneGeometry(paneSize), | |
| 93 | + | geometry: sess.geometry.paneGeometry(paneSize), | |
| 94 | 94 | }) | |
| 95 | 95 | } | |
| 96 | 96 | } | |
| @@ -99,7 +99,7 @@ func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) pre | |||
| 99 | 99 | ||
| 100 | 100 | // applyPreparedTabMembers performs only the external PTY phase. A successful | |
| 101 | 101 | // resize leaves the parser gate closed until the complete plan is validated. | |
| 102 | - | func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |
| 102 | + | func (g *sharedPTYGeometry) applyPreparedTabMembers(d *Daemon, plan *preparedTabLayout) { | |
| 103 | 103 | for i := range plan.members { | |
| 104 | 104 | member := &plan.members[i] | |
| 105 | 105 | if !member.geometry.Valid() { | |
| @@ -219,7 +219,7 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool { | |||
| 219 | 219 | // cancelStalePreparedGates releases gates for members which disappeared from | |
| 220 | 220 | // the next solved layout (for example a stack member collapsed by a focus | |
| 221 | 221 | // change). Members that remain are deliberately left gated for the retry. | |
| 222 | - | func (d *Daemon) cancelStalePreparedGates(sess *session, tb *tab, plan *preparedTabLayout) { | |
| 222 | + | func (g *sharedPTYGeometry) cancelStalePreparedGates(d *Daemon, sess *session, tb *tab, plan *preparedTabLayout) { | |
| 223 | 223 | tb.mu.Lock() | |
| 224 | 224 | latest := prepareTabLayoutLocked(sess, tb) | |
| 225 | 225 | tb.mu.Unlock() | |
| @@ -241,7 +241,7 @@ func (d *Daemon) cancelStalePreparedGates(sess *session, tb *tab, plan *prepared | |||
| 241 | 241 | } | |
| 242 | 242 | } | |
| 243 | 243 | ||
| 244 | - | func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool) { | |
| 244 | + | func (g *sharedPTYGeometry) finishPreparedTabMembers(d *Daemon, plan *preparedTabLayout, accepted bool) { | |
| 245 | 245 | if !accepted { | |
| 246 | 246 | // cancelStalePreparedGates owns stale-plan cancellation. Keeping this | |
| 247 | 247 | // path empty ensures a removed member's pending bytes are replayed once, | |
| @@ -262,11 +262,11 @@ func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool | |||
| 262 | 262 | // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns | |
| 263 | 263 | // one per-tab single-writer loop, but never holds tab or pane state locks while | |
| 264 | 264 | // invoking PTY.Resize. | |
| 265 | - | func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) { | |
| 266 | - | return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...) | |
| 265 | + | func (g *sharedPTYGeometry) applyTabLayoutTransaction(d *Daemon, sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) { | |
| 266 | + | return g.applyTabLayoutTransactionWithNotice(d, sess, tb, true, current...) | |
| 267 | 267 | } | |
| 268 | 268 | ||
| 269 | - | func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) { | |
| 269 | + | func (g *sharedPTYGeometry) applyTabLayoutTransactionWithNotice(d *Daemon, sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) { | |
| 270 | 270 | if tb == nil { | |
| 271 | 271 | return nil, false | |
| 272 | 272 | } | |
| @@ -282,7 +282,7 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||
| 282 | 282 | tb.mu.Lock() | |
| 283 | 283 | plan := prepareTabLayoutLocked(sess, tb) | |
| 284 | 284 | tb.mu.Unlock() | |
| 285 | - | d.applyPreparedTabMembers(&plan) | |
| 285 | + | g.applyPreparedTabMembers(d, &plan) | |
| 286 | 286 | ||
| 287 | 287 | tb.mu.Lock() | |
| 288 | 288 | accepted := validatePreparedTabLayoutLocked(tb, &plan) | |
| @@ -293,9 +293,9 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||
| 293 | 293 | accepted = commitPreparedTabLayoutLocked(&plan) | |
| 294 | 294 | } | |
| 295 | 295 | tb.mu.Unlock() | |
| 296 | - | d.finishPreparedTabMembers(&plan, accepted) | |
| 296 | + | g.finishPreparedTabMembers(d, &plan, accepted) | |
| 297 | 297 | if !accepted { | |
| 298 | - | d.cancelStalePreparedGates(sess, tb, &plan) | |
| 298 | + | g.cancelStalePreparedGates(d, sess, tb, &plan) | |
| 299 | 299 | if len(current) != 0 && !current[0]() { | |
| 300 | 300 | for i := range plan.members { | |
| 301 | 301 | member := &plan.members[i] | |
| @@ -324,14 +324,14 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||
| 324 | 324 | } | |
| 325 | 325 | } | |
| 326 | 326 | ||
| 327 | - | func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool { | |
| 328 | - | failed, ok := d.applyTabLayoutTransaction(sess, tb) | |
| 327 | + | func (g *sharedPTYGeometry) applyTabLayout(d *Daemon, sess *session, tb *tab) bool { | |
| 328 | + | failed, ok := g.applyTabLayoutTransaction(d, sess, tb) | |
| 329 | 329 | if !ok { | |
| 330 | 330 | return false | |
| 331 | 331 | } | |
| 332 | 332 | markSnapshotDirty(sess) | |
| 333 | 333 | if len(failed) != 0 { | |
| 334 | - | d.scheduleAcceptedTabLayoutRetry(sess, tb) | |
| 334 | + | g.scheduleAcceptedTabLayoutRetry(d, sess, tb) | |
| 335 | 335 | } | |
| 336 | 336 | return true | |
| 337 | 337 | } | |
| @@ -341,7 +341,7 @@ const maxAcceptedTabLayoutRetries = 3 | |||
| 341 | 341 | // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The | |
| 342 | 342 | // worker is deduplicated, derives cancellation from the tab lifecycle, and | |
| 343 | 343 | // suppresses repeat degradation notices after the accepted initial failure. | |
| 344 | - | func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | |
| 344 | + | func (g *sharedPTYGeometry) scheduleAcceptedTabLayoutRetry(d *Daemon, sess *session, tb *tab) { | |
| 345 | 345 | if d.clock == nil || tb == nil || tb.ctx == nil { | |
| 346 | 346 | return | |
| 347 | 347 | } | |
| @@ -382,7 +382,7 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | |||
| 382 | 382 | if ctx.Err() != nil { | |
| 383 | 383 | return | |
| 384 | 384 | } | |
| 385 | - | failed, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { return ctx.Err() == nil }) | |
| 385 | + | failed, ok := g.applyTabLayoutTransactionWithNotice(d, sess, tb, false, func() bool { return ctx.Err() == nil }) | |
| 386 | 386 | if !ok || len(failed) == 0 { | |
| 387 | 387 | return | |
| 388 | 388 | } | |
| @@ -430,14 +430,14 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo | |||
| 430 | 430 | // used for retryable PTY work. Floating state is not part of a tiled layout | |
| 431 | 431 | // generation, so its slot generation and exact pane identity validate its | |
| 432 | 432 | // publication instead. | |
| 433 | - | func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) { | |
| 434 | - | return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil) | |
| 433 | + | func (g *sharedPTYGeometry) applyVisibleFloatingLayout(d *Daemon, sess *session, tb *tab, current func() bool) ([]resizeMember, bool) { | |
| 434 | + | return g.applyVisibleFloatingLayoutForMember(d, sess, tb, current, nil) | |
| 435 | 435 | } | |
| 436 | 436 | ||
| 437 | 437 | // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the | |
| 438 | 438 | // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes, | |
| 439 | 439 | // so accepting a replacement here would silently retry unrelated geometry. | |
| 440 | - | func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) { | |
| 440 | + | func (g *sharedPTYGeometry) applyVisibleFloatingLayoutForMember(d *Daemon, sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) { | |
| 441 | 441 | if tb == nil { | |
| 442 | 442 | return nil, true | |
| 443 | 443 | } | |
| @@ -488,10 +488,10 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 488 | 488 | ptyRect := geometry.ptyRect() | |
| 489 | 489 | plan := preparedTabLayout{members: []resizeMember{{ | |
| 490 | 490 | session: sess, tab: tb, pane: p, owner: owner, rect: ptyRect, | |
| 491 | - | geometry: sess.paneGeometry(rectSize(ptyRect)), floating: geometry, | |
| 491 | + | geometry: sess.geometry.paneGeometry(rectSize(ptyRect)), floating: geometry, | |
| 492 | 492 | isFloating: true, floatingGeneration: generation, | |
| 493 | 493 | }}} | |
| 494 | - | d.applyPreparedTabMembers(&plan) | |
| 494 | + | g.applyPreparedTabMembers(d, &plan) | |
| 495 | 495 | ||
| 496 | 496 | // The PTY may have accepted an intermediate size, but a hidden, replaced, | |
| 497 | 497 | // relaunched, or resized slot must never receive this attempt's geometry. | |
| @@ -531,7 +531,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 531 | 531 | return nil, true | |
| 532 | 532 | } | |
| 533 | 533 | if plan.members[0].ok { | |
| 534 | - | d.finishPreparedTabMembers(&plan, true) | |
| 534 | + | g.finishPreparedTabMembers(d, &plan, true) | |
| 535 | 535 | return nil, true | |
| 536 | 536 | } | |
| 537 | 537 | return plan.members, true | |
| @@ -542,7 +542,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 542 | 542 | // stale per-tab retry, no succeeding attempt is owned by this call, so every | |
| 543 | 543 | // successful external resize must reopen its parser gate at the old screen | |
| 544 | 544 | // size. | |
| 545 | - | func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) { | |
| 545 | + | func (g *sharedPTYGeometry) releasePreparedSessionGates(d *Daemon, plans []*preparedTabLayout) { | |
| 546 | 546 | for _, plan := range plans { | |
| 547 | 547 | if plan == nil { | |
| 548 | 548 | continue | |
| @@ -563,7 +563,7 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) { | |||
| 563 | 563 | // transaction: all PTYs are applied and plans validated first, then the | |
| 564 | 564 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | |
| 565 | 565 | // snapshot dirtiness, or resize telemetry becomes visible. | |
| 566 | - | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { | |
| 566 | + | func (g *sharedPTYGeometry) applySessionLayout(d *Daemon, sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { | |
| 567 | 567 | if sess == nil { | |
| 568 | 568 | return nil, false | |
| 569 | 569 | } | |
| @@ -589,7 +589,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 589 | 589 | plans = append(plans, &plan) | |
| 590 | 590 | } | |
| 591 | 591 | for _, plan := range plans { | |
| 592 | - | d.applyPreparedTabMembers(plan) | |
| 592 | + | g.applyPreparedTabMembers(d, plan) | |
| 593 | 593 | } | |
| 594 | 594 | ||
| 595 | 595 | // Hold every tab lock across final validation, epoch admission, and all | |
| @@ -614,7 +614,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 614 | 614 | // Claim publication and shared geometry commit use one fence. The hook | |
| 615 | 615 | // above remains outside the fence so tests and lifecycle callbacks can | |
| 616 | 616 | // publish a newer claim; the final validation then rejects this stale plan. | |
| 617 | - | sess.geometryMu.Lock() | |
| 617 | + | g.mu.Lock() | |
| 618 | 618 | if valid && sess.ctx != nil && sess.ctx.Err() != nil { | |
| 619 | 619 | valid = false | |
| 620 | 620 | } | |
| @@ -633,12 +633,12 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 633 | 633 | plan.tab.size = plan.size | |
| 634 | 634 | } | |
| 635 | 635 | } | |
| 636 | - | sess.geometryMu.Unlock() | |
| 636 | + | g.mu.Unlock() | |
| 637 | 637 | for i := len(tabs) - 1; i >= 0; i-- { | |
| 638 | 638 | tabs[i].mu.Unlock() | |
| 639 | 639 | } | |
| 640 | 640 | if !valid { | |
| 641 | - | d.releasePreparedSessionGates(plans) | |
| 641 | + | g.releasePreparedSessionGates(d, plans) | |
| 642 | 642 | // Headless requests have no attachment epoch. A live tab mutation | |
| 643 | 643 | // still invalidates their plan, but must trigger a fresh prepare rather | |
| 644 | 644 | // than being mistaken for a canceled request. | |
| @@ -652,7 +652,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 652 | 652 | ||
| 653 | 653 | failed := make([]resizeMember, 0) | |
| 654 | 654 | for _, plan := range plans { | |
| 655 | - | d.finishPreparedTabMembers(plan, true) | |
| 655 | + | g.finishPreparedTabMembers(d, plan, true) | |
| 656 | 656 | for _, member := range plan.members { | |
| 657 | 657 | if !member.ok { | |
| 658 | 658 | failed = append(failed, member) | |
| @@ -660,7 +660,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 660 | 660 | } | |
| 661 | 661 | } | |
| 662 | 662 | for _, tb := range tabs { | |
| 663 | - | floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tb, current) | |
| 663 | + | floatingFailed, ok := g.applyVisibleFloatingLayout(d, sess, tb, current) | |
| 664 | 664 | if !ok { | |
| 665 | 665 | return nil, false | |
| 666 | 666 | } | |
| @@ -689,16 +689,16 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 689 | 689 | } | |
| 690 | 690 | } | |
| 691 | 691 | ||
| 692 | - | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | |
| 692 | + | func (g *sharedPTYGeometry) runResizeTransaction(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | |
| 693 | 693 | rc := sess.renderCoordinator() | |
| 694 | 694 | if rc == nil { | |
| 695 | 695 | return false | |
| 696 | 696 | } | |
| 697 | 697 | snap := rc.resizeSnapshot() | |
| 698 | 698 | geometryClaim := snap.geometryClaim | |
| 699 | - | if geometryClaim == 0 { | |
| 699 | + | if geometryClaim == nil { | |
| 700 | 700 | var claimed bool | |
| 701 | - | geometryClaim, claimed = sess.claimGeometryOwnerForSize(ac, snap.size) | |
| 701 | + | geometryClaim, claimed = g.claimSize(sess, ac, snap.size) | |
| 702 | 702 | if !claimed { | |
| 703 | 703 | return false | |
| 704 | 704 | } | |
| @@ -709,14 +709,14 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||
| 709 | 709 | } | |
| 710 | 710 | defer effect.End() | |
| 711 | 711 | current := func() bool { | |
| 712 | - | return effect.current() && sess.geometryClaimTokenCurrent(ac, geometryClaim) && | |
| 712 | + | return effect.current() && sess.geometry.current(geometryClaim) && | |
| 713 | 713 | rc.resizeCurrentForLease(epoch, ac, lease, false) | |
| 714 | 714 | } | |
| 715 | 715 | if !current() { | |
| 716 | 716 | return false | |
| 717 | 717 | } | |
| 718 | 718 | d.exitCopyMode(ac) | |
| 719 | - | failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool { | |
| 719 | + | failed, ok := g.applySessionLayout(d, sess, snap.size, current, func() bool { | |
| 720 | 720 | return rc.resizeCurrentForLease(epoch, ac, lease, true) | |
| 721 | 721 | }) | |
| 722 | 722 | if !ok { | |
| @@ -737,7 +737,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||
| 737 | 737 | return false | |
| 738 | 738 | } | |
| 739 | 739 | if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) { | |
| 740 | - | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | |
| 740 | + | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { g.retryResizeMembers(d, sess, ac, lease, epoch, failed) }) | |
| 741 | 741 | } | |
| 742 | 742 | if !current() { | |
| 743 | 743 | return false | |
| @@ -762,7 +762,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||
| 762 | 762 | // prepared tab transaction. The captured members identify retry candidates | |
| 763 | 763 | // only: their rectangles must never cross this delayed boundary, because any | |
| 764 | 764 | // later layout mutation may have changed their geometry or removed them. | |
| 765 | - | func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) { | |
| 765 | + | func (g *sharedPTYGeometry) retryResizeMembers(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) { | |
| 766 | 766 | rc := sess.renderCoordinator() | |
| 767 | 767 | if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) { | |
| 768 | 768 | return | |
| @@ -811,7 +811,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 811 | 811 | // pointers, and solved rectangles, and validates them again before any | |
| 812 | 812 | // VT/rectangle publication. It also preserves the resize gate and | |
| 813 | 813 | // degradation notice behavior for another failed external attempt. | |
| 814 | - | freshFailed, ok := d.applyTabLayoutTransaction(sess, tb, func() bool { | |
| 814 | + | freshFailed, ok := g.applyTabLayoutTransaction(d, sess, tb, func() bool { | |
| 815 | 815 | return rc.retryCurrentForLease(epoch, ac, lease) | |
| 816 | 816 | }) | |
| 817 | 817 | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | |
| @@ -856,7 +856,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 856 | 856 | if !currentSlot || !retryPending { | |
| 857 | 857 | continue | |
| 858 | 858 | } | |
| 859 | - | freshFailed, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool { | |
| 859 | + | freshFailed, ok := g.applyVisibleFloatingLayoutForMember(d, sess, member.tab, func() bool { | |
| 860 | 860 | return rc.retryCurrentForLease(epoch, ac, lease) | |
| 861 | 861 | }, &member) | |
| 862 | 862 | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | |
| @@ -875,7 +875,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 875 | 875 | } | |
| 876 | 876 | } | |
| 877 | 877 | if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) { | |
| 878 | - | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | |
| 878 | + | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { g.retryResizeMembers(d, sess, ac, lease, epoch, failed) }) | |
| 879 | 879 | } | |
| 880 | 880 | if succeeded { | |
| 881 | 881 | // Retry completion changes VT state after the original layout commit. | |
| @@ -885,20 +885,20 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||
| 885 | 885 | } | |
| 886 | 886 | } | |
| 887 | 887 | ||
| 888 | - | // requestTransactionalResize reports reset invalidation completion for immediate | |
| 888 | + | // requestResize reports reset invalidation completion for immediate | |
| 889 | 889 | // attached requests, and coordinator schedule acceptance for async requests. | |
| 890 | 890 | // Headless requests have no reset invalidation and report geometry completion. | |
| 891 | - | func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool { | |
| 891 | + | func (g *sharedPTYGeometry) requestResize(d *Daemon, sess *session, ac *attachedClient, size domain.Size, immediate bool) bool { | |
| 892 | 892 | if ac == nil { | |
| 893 | - | return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate) | |
| 893 | + | return g.requestResizeForLease(d, sess, nil, nil, size, immediate) | |
| 894 | 894 | } | |
| 895 | 895 | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 896 | - | return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate) | |
| 896 | + | return g.requestResizeForLease(d, sess, ac, rc.attachmentLease(ac), size, immediate) | |
| 897 | 897 | } | |
| 898 | 898 | ||
| 899 | - | // requestTransactionalResizeForLease carries the connection's captured lease | |
| 899 | + | // requestResizeForLease carries the Connection's captured lease | |
| 900 | 900 | // through the full resize transaction and every delayed retry callback. | |
| 901 | - | func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedClient, lease *attachmentLease, size domain.Size, immediate bool) bool { | |
| 901 | + | func (g *sharedPTYGeometry) requestResizeForLease(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, size domain.Size, immediate bool) bool { | |
| 902 | 902 | valid := sess != nil && size.Valid() | |
| 903 | 903 | d.observeRuntime(ports.RuntimeResizeRequested, 0, valid) | |
| 904 | 904 | if !valid { | |
| @@ -907,28 +907,28 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC | |||
| 907 | 907 | if ac == nil { | |
| 908 | 908 | // Headless geometry has no coordinator/transport to coalesce, but keeps | |
| 909 | 909 | // the same prepare/apply/commit ordering. | |
| 910 | - | _, ok := d.applySessionLayout(sess, size, nil, nil) | |
| 910 | + | _, ok := g.applySessionLayout(d, sess, size, nil, nil) | |
| 911 | 911 | return ok | |
| 912 | 912 | } | |
| 913 | 913 | rc := sess.renderCoordinator() | |
| 914 | 914 | if rc == nil || !rc.leaseCurrent(lease, true) { | |
| 915 | 915 | return false | |
| 916 | 916 | } | |
| 917 | - | geometryClaim, claimed := sess.claimGeometryOwnerForSize(ac, size) | |
| 917 | + | geometryClaim, claimed := g.claimSize(sess, ac, size) | |
| 918 | 918 | if !claimed { | |
| 919 | 919 | return false | |
| 920 | 920 | } | |
| 921 | 921 | if immediate { | |
| 922 | 922 | epoch := rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim) | |
| 923 | 923 | if epoch == 0 { | |
| 924 | - | sess.releaseGeometryClaim(ac, geometryClaim) | |
| 924 | + | g.release(sess, geometryClaim) | |
| 925 | 925 | return false | |
| 926 | 926 | } | |
| 927 | - | return d.runResizeTransaction(sess, ac, lease, epoch) | |
| 927 | + | return g.runResizeTransaction(d, sess, ac, lease, epoch) | |
| 928 | 928 | } | |
| 929 | - | epoch := rc.scheduleResizeForLeaseWithClaim(size, ac, lease, geometryClaim, func(epoch uint64) { d.runResizeTransaction(sess, ac, lease, epoch) }) | |
| 929 | + | epoch := rc.scheduleResizeForLeaseWithClaim(size, ac, lease, geometryClaim, func(epoch uint64) { g.runResizeTransaction(d, sess, ac, lease, epoch) }) | |
| 930 | 930 | if epoch == 0 { | |
| 931 | - | sess.releaseGeometryClaim(ac, geometryClaim) | |
| 931 | + | g.release(sess, geometryClaim) | |
| 932 | 932 | } | |
| 933 | 933 | return epoch != 0 | |
| 934 | 934 | } | |
| @@ -90,7 +90,7 @@ func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) pre | |||||
| 90 | paneSize := rectSize(placement.Content) | 90 | paneSize := rectSize(placement.Content) | ||
| 91 | plan.members = append(plan.members, resizeMember{ | 91 | plan.members = append(plan.members, resizeMember{ | ||
| 92 | session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content, | 92 | session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content, | ||
| 93 | - | geometry: sess.paneGeometry(paneSize), | 93 | + | geometry: sess.geometry.paneGeometry(paneSize), |
| 94 | }) | 94 | }) | ||
| 95 | } | 95 | } | ||
| 96 | } | 96 | } | ||
| @@ -99,7 +99,7 @@ func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) pre | |||||
| 99 | 99 | ||||
| 100 | // applyPreparedTabMembers performs only the external PTY phase. A successful | 100 | // applyPreparedTabMembers performs only the external PTY phase. A successful | ||
| 101 | // resize leaves the parser gate closed until the complete plan is validated. | 101 | // resize leaves the parser gate closed until the complete plan is validated. | ||
| 102 | - | func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | 102 | + | func (g *sharedPTYGeometry) applyPreparedTabMembers(d *Daemon, plan *preparedTabLayout) { |
| 103 | for i := range plan.members { | 103 | for i := range plan.members { | ||
| 104 | member := &plan.members[i] | 104 | member := &plan.members[i] | ||
| 105 | if !member.geometry.Valid() { | 105 | if !member.geometry.Valid() { | ||
| @@ -219,7 +219,7 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool { | |||||
| 219 | // cancelStalePreparedGates releases gates for members which disappeared from | 219 | // cancelStalePreparedGates releases gates for members which disappeared from | ||
| 220 | // the next solved layout (for example a stack member collapsed by a focus | 220 | // the next solved layout (for example a stack member collapsed by a focus | ||
| 221 | // change). Members that remain are deliberately left gated for the retry. | 221 | // change). Members that remain are deliberately left gated for the retry. | ||
| 222 | - | func (d *Daemon) cancelStalePreparedGates(sess *session, tb *tab, plan *preparedTabLayout) { | 222 | + | func (g *sharedPTYGeometry) cancelStalePreparedGates(d *Daemon, sess *session, tb *tab, plan *preparedTabLayout) { |
| 223 | tb.mu.Lock() | 223 | tb.mu.Lock() | ||
| 224 | latest := prepareTabLayoutLocked(sess, tb) | 224 | latest := prepareTabLayoutLocked(sess, tb) | ||
| 225 | tb.mu.Unlock() | 225 | tb.mu.Unlock() | ||
| @@ -241,7 +241,7 @@ func (d *Daemon) cancelStalePreparedGates(sess *session, tb *tab, plan *prepared | |||||
| 241 | } | 241 | } | ||
| 242 | } | 242 | } | ||
| 243 | 243 | ||||
| 244 | - | func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool) { | 244 | + | func (g *sharedPTYGeometry) finishPreparedTabMembers(d *Daemon, plan *preparedTabLayout, accepted bool) { |
| 245 | if !accepted { | 245 | if !accepted { | ||
| 246 | // cancelStalePreparedGates owns stale-plan cancellation. Keeping this | 246 | // cancelStalePreparedGates owns stale-plan cancellation. Keeping this | ||
| 247 | // path empty ensures a removed member's pending bytes are replayed once, | 247 | // path empty ensures a removed member's pending bytes are replayed once, | ||
| @@ -262,11 +262,11 @@ func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool | |||||
| 262 | // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns | 262 | // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns | ||
| 263 | // one per-tab single-writer loop, but never holds tab or pane state locks while | 263 | // one per-tab single-writer loop, but never holds tab or pane state locks while | ||
| 264 | // invoking PTY.Resize. | 264 | // invoking PTY.Resize. | ||
| 265 | - | func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) { | 265 | + | func (g *sharedPTYGeometry) applyTabLayoutTransaction(d *Daemon, sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) { |
| 266 | - | return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...) | 266 | + | return g.applyTabLayoutTransactionWithNotice(d, sess, tb, true, current...) |
| 267 | } | 267 | } | ||
| 268 | 268 | ||||
| 269 | - | func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) { | 269 | + | func (g *sharedPTYGeometry) applyTabLayoutTransactionWithNotice(d *Daemon, sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) { |
| 270 | if tb == nil { | 270 | if tb == nil { | ||
| 271 | return nil, false | 271 | return nil, false | ||
| 272 | } | 272 | } | ||
| @@ -282,7 +282,7 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||||
| 282 | tb.mu.Lock() | 282 | tb.mu.Lock() | ||
| 283 | plan := prepareTabLayoutLocked(sess, tb) | 283 | plan := prepareTabLayoutLocked(sess, tb) | ||
| 284 | tb.mu.Unlock() | 284 | tb.mu.Unlock() | ||
| 285 | - | d.applyPreparedTabMembers(&plan) | 285 | + | g.applyPreparedTabMembers(d, &plan) |
| 286 | 286 | ||||
| 287 | tb.mu.Lock() | 287 | tb.mu.Lock() | ||
| 288 | accepted := validatePreparedTabLayoutLocked(tb, &plan) | 288 | accepted := validatePreparedTabLayoutLocked(tb, &plan) | ||
| @@ -293,9 +293,9 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||||
| 293 | accepted = commitPreparedTabLayoutLocked(&plan) | 293 | accepted = commitPreparedTabLayoutLocked(&plan) | ||
| 294 | } | 294 | } | ||
| 295 | tb.mu.Unlock() | 295 | tb.mu.Unlock() | ||
| 296 | - | d.finishPreparedTabMembers(&plan, accepted) | 296 | + | g.finishPreparedTabMembers(d, &plan, accepted) |
| 297 | if !accepted { | 297 | if !accepted { | ||
| 298 | - | d.cancelStalePreparedGates(sess, tb, &plan) | 298 | + | g.cancelStalePreparedGates(d, sess, tb, &plan) |
| 299 | if len(current) != 0 && !current[0]() { | 299 | if len(current) != 0 && !current[0]() { | ||
| 300 | for i := range plan.members { | 300 | for i := range plan.members { | ||
| 301 | member := &plan.members[i] | 301 | member := &plan.members[i] | ||
| @@ -324,14 +324,14 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||||
| 324 | } | 324 | } | ||
| 325 | } | 325 | } | ||
| 326 | 326 | ||||
| 327 | - | func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool { | 327 | + | func (g *sharedPTYGeometry) applyTabLayout(d *Daemon, sess *session, tb *tab) bool { |
| 328 | - | failed, ok := d.applyTabLayoutTransaction(sess, tb) | 328 | + | failed, ok := g.applyTabLayoutTransaction(d, sess, tb) |
| 329 | if !ok { | 329 | if !ok { | ||
| 330 | return false | 330 | return false | ||
| 331 | } | 331 | } | ||
| 332 | markSnapshotDirty(sess) | 332 | markSnapshotDirty(sess) | ||
| 333 | if len(failed) != 0 { | 333 | if len(failed) != 0 { | ||
| 334 | - | d.scheduleAcceptedTabLayoutRetry(sess, tb) | 334 | + | g.scheduleAcceptedTabLayoutRetry(d, sess, tb) |
| 335 | } | 335 | } | ||
| 336 | return true | 336 | return true | ||
| 337 | } | 337 | } | ||
| @@ -341,7 +341,7 @@ const maxAcceptedTabLayoutRetries = 3 | |||||
| 341 | // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The | 341 | // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The | ||
| 342 | // worker is deduplicated, derives cancellation from the tab lifecycle, and | 342 | // worker is deduplicated, derives cancellation from the tab lifecycle, and | ||
| 343 | // suppresses repeat degradation notices after the accepted initial failure. | 343 | // suppresses repeat degradation notices after the accepted initial failure. | ||
| 344 | - | func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | 344 | + | func (g *sharedPTYGeometry) scheduleAcceptedTabLayoutRetry(d *Daemon, sess *session, tb *tab) { |
| 345 | if d.clock == nil || tb == nil || tb.ctx == nil { | 345 | if d.clock == nil || tb == nil || tb.ctx == nil { | ||
| 346 | return | 346 | return | ||
| 347 | } | 347 | } | ||
| @@ -382,7 +382,7 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | |||||
| 382 | if ctx.Err() != nil { | 382 | if ctx.Err() != nil { | ||
| 383 | return | 383 | return | ||
| 384 | } | 384 | } | ||
| 385 | - | failed, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { return ctx.Err() == nil }) | 385 | + | failed, ok := g.applyTabLayoutTransactionWithNotice(d, sess, tb, false, func() bool { return ctx.Err() == nil }) |
| 386 | if !ok || len(failed) == 0 { | 386 | if !ok || len(failed) == 0 { | ||
| 387 | return | 387 | return | ||
| 388 | } | 388 | } | ||
| @@ -430,14 +430,14 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo | |||||
| 430 | // used for retryable PTY work. Floating state is not part of a tiled layout | 430 | // used for retryable PTY work. Floating state is not part of a tiled layout | ||
| 431 | // generation, so its slot generation and exact pane identity validate its | 431 | // generation, so its slot generation and exact pane identity validate its | ||
| 432 | // publication instead. | 432 | // publication instead. | ||
| 433 | - | func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) { | 433 | + | func (g *sharedPTYGeometry) applyVisibleFloatingLayout(d *Daemon, sess *session, tb *tab, current func() bool) ([]resizeMember, bool) { |
| 434 | - | return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil) | 434 | + | return g.applyVisibleFloatingLayoutForMember(d, sess, tb, current, nil) |
| 435 | } | 435 | } | ||
| 436 | 436 | ||||
| 437 | // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the | 437 | // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the | ||
| 438 | // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes, | 438 | // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes, | ||
| 439 | // so accepting a replacement here would silently retry unrelated geometry. | 439 | // so accepting a replacement here would silently retry unrelated geometry. | ||
| 440 | - | func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) { | 440 | + | func (g *sharedPTYGeometry) applyVisibleFloatingLayoutForMember(d *Daemon, sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) { |
| 441 | if tb == nil { | 441 | if tb == nil { | ||
| 442 | return nil, true | 442 | return nil, true | ||
| 443 | } | 443 | } | ||
| @@ -488,10 +488,10 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||||
| 488 | ptyRect := geometry.ptyRect() | 488 | ptyRect := geometry.ptyRect() | ||
| 489 | plan := preparedTabLayout{members: []resizeMember{{ | 489 | plan := preparedTabLayout{members: []resizeMember{{ | ||
| 490 | session: sess, tab: tb, pane: p, owner: owner, rect: ptyRect, | 490 | session: sess, tab: tb, pane: p, owner: owner, rect: ptyRect, | ||
| 491 | - | geometry: sess.paneGeometry(rectSize(ptyRect)), floating: geometry, | 491 | + | geometry: sess.geometry.paneGeometry(rectSize(ptyRect)), floating: geometry, |
| 492 | isFloating: true, floatingGeneration: generation, | 492 | isFloating: true, floatingGeneration: generation, | ||
| 493 | }}} | 493 | }}} | ||
| 494 | - | d.applyPreparedTabMembers(&plan) | 494 | + | g.applyPreparedTabMembers(d, &plan) |
| 495 | 495 | ||||
| 496 | // The PTY may have accepted an intermediate size, but a hidden, replaced, | 496 | // The PTY may have accepted an intermediate size, but a hidden, replaced, | ||
| 497 | // relaunched, or resized slot must never receive this attempt's geometry. | 497 | // relaunched, or resized slot must never receive this attempt's geometry. | ||
| @@ -531,7 +531,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||||
| 531 | return nil, true | 531 | return nil, true | ||
| 532 | } | 532 | } | ||
| 533 | if plan.members[0].ok { | 533 | if plan.members[0].ok { | ||
| 534 | - | d.finishPreparedTabMembers(&plan, true) | 534 | + | g.finishPreparedTabMembers(d, &plan, true) |
| 535 | return nil, true | 535 | return nil, true | ||
| 536 | } | 536 | } | ||
| 537 | return plan.members, true | 537 | return plan.members, true | ||
| @@ -542,7 +542,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||||
| 542 | // stale per-tab retry, no succeeding attempt is owned by this call, so every | 542 | // stale per-tab retry, no succeeding attempt is owned by this call, so every | ||
| 543 | // successful external resize must reopen its parser gate at the old screen | 543 | // successful external resize must reopen its parser gate at the old screen | ||
| 544 | // size. | 544 | // size. | ||
| 545 | - | func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) { | 545 | + | func (g *sharedPTYGeometry) releasePreparedSessionGates(d *Daemon, plans []*preparedTabLayout) { |
| 546 | for _, plan := range plans { | 546 | for _, plan := range plans { | ||
| 547 | if plan == nil { | 547 | if plan == nil { | ||
| 548 | continue | 548 | continue | ||
| @@ -563,7 +563,7 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) { | |||||
| 563 | // transaction: all PTYs are applied and plans validated first, then the | 563 | // transaction: all PTYs are applied and plans validated first, then the | ||
| 564 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | 564 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | ||
| 565 | // snapshot dirtiness, or resize telemetry becomes visible. | 565 | // snapshot dirtiness, or resize telemetry becomes visible. | ||
| 566 | - | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { | 566 | + | func (g *sharedPTYGeometry) applySessionLayout(d *Daemon, sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { |
| 567 | if sess == nil { | 567 | if sess == nil { | ||
| 568 | return nil, false | 568 | return nil, false | ||
| 569 | } | 569 | } | ||
| @@ -589,7 +589,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 589 | plans = append(plans, &plan) | 589 | plans = append(plans, &plan) | ||
| 590 | } | 590 | } | ||
| 591 | for _, plan := range plans { | 591 | for _, plan := range plans { | ||
| 592 | - | d.applyPreparedTabMembers(plan) | 592 | + | g.applyPreparedTabMembers(d, plan) |
| 593 | } | 593 | } | ||
| 594 | 594 | ||||
| 595 | // Hold every tab lock across final validation, epoch admission, and all | 595 | // Hold every tab lock across final validation, epoch admission, and all | ||
| @@ -614,7 +614,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 614 | // Claim publication and shared geometry commit use one fence. The hook | 614 | // Claim publication and shared geometry commit use one fence. The hook | ||
| 615 | // above remains outside the fence so tests and lifecycle callbacks can | 615 | // above remains outside the fence so tests and lifecycle callbacks can | ||
| 616 | // publish a newer claim; the final validation then rejects this stale plan. | 616 | // publish a newer claim; the final validation then rejects this stale plan. | ||
| 617 | - | sess.geometryMu.Lock() | 617 | + | g.mu.Lock() |
| 618 | if valid && sess.ctx != nil && sess.ctx.Err() != nil { | 618 | if valid && sess.ctx != nil && sess.ctx.Err() != nil { | ||
| 619 | valid = false | 619 | valid = false | ||
| 620 | } | 620 | } | ||
| @@ -633,12 +633,12 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 633 | plan.tab.size = plan.size | 633 | plan.tab.size = plan.size | ||
| 634 | } | 634 | } | ||
| 635 | } | 635 | } | ||
| 636 | - | sess.geometryMu.Unlock() | 636 | + | g.mu.Unlock() |
| 637 | for i := len(tabs) - 1; i >= 0; i-- { | 637 | for i := len(tabs) - 1; i >= 0; i-- { | ||
| 638 | tabs[i].mu.Unlock() | 638 | tabs[i].mu.Unlock() | ||
| 639 | } | 639 | } | ||
| 640 | if !valid { | 640 | if !valid { | ||
| 641 | - | d.releasePreparedSessionGates(plans) | 641 | + | g.releasePreparedSessionGates(d, plans) |
| 642 | // Headless requests have no attachment epoch. A live tab mutation | 642 | // Headless requests have no attachment epoch. A live tab mutation | ||
| 643 | // still invalidates their plan, but must trigger a fresh prepare rather | 643 | // still invalidates their plan, but must trigger a fresh prepare rather | ||
| 644 | // than being mistaken for a canceled request. | 644 | // than being mistaken for a canceled request. | ||
| @@ -652,7 +652,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 652 | 652 | ||||
| 653 | failed := make([]resizeMember, 0) | 653 | failed := make([]resizeMember, 0) | ||
| 654 | for _, plan := range plans { | 654 | for _, plan := range plans { | ||
| 655 | - | d.finishPreparedTabMembers(plan, true) | 655 | + | g.finishPreparedTabMembers(d, plan, true) |
| 656 | for _, member := range plan.members { | 656 | for _, member := range plan.members { | ||
| 657 | if !member.ok { | 657 | if !member.ok { | ||
| 658 | failed = append(failed, member) | 658 | failed = append(failed, member) | ||
| @@ -660,7 +660,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 660 | } | 660 | } | ||
| 661 | } | 661 | } | ||
| 662 | for _, tb := range tabs { | 662 | for _, tb := range tabs { | ||
| 663 | - | floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tb, current) | 663 | + | floatingFailed, ok := g.applyVisibleFloatingLayout(d, sess, tb, current) |
| 664 | if !ok { | 664 | if !ok { | ||
| 665 | return nil, false | 665 | return nil, false | ||
| 666 | } | 666 | } | ||
| @@ -689,16 +689,16 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 689 | } | 689 | } | ||
| 690 | } | 690 | } | ||
| 691 | 691 | ||||
| 692 | - | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | 692 | + | func (g *sharedPTYGeometry) runResizeTransaction(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { |
| 693 | rc := sess.renderCoordinator() | 693 | rc := sess.renderCoordinator() | ||
| 694 | if rc == nil { | 694 | if rc == nil { | ||
| 695 | return false | 695 | return false | ||
| 696 | } | 696 | } | ||
| 697 | snap := rc.resizeSnapshot() | 697 | snap := rc.resizeSnapshot() | ||
| 698 | geometryClaim := snap.geometryClaim | 698 | geometryClaim := snap.geometryClaim | ||
| 699 | - | if geometryClaim == 0 { | 699 | + | if geometryClaim == nil { |
| 700 | var claimed bool | 700 | var claimed bool | ||
| 701 | - | geometryClaim, claimed = sess.claimGeometryOwnerForSize(ac, snap.size) | 701 | + | geometryClaim, claimed = g.claimSize(sess, ac, snap.size) |
| 702 | if !claimed { | 702 | if !claimed { | ||
| 703 | return false | 703 | return false | ||
| 704 | } | 704 | } | ||
| @@ -709,14 +709,14 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||||
| 709 | } | 709 | } | ||
| 710 | defer effect.End() | 710 | defer effect.End() | ||
| 711 | current := func() bool { | 711 | current := func() bool { | ||
| 712 | - | return effect.current() && sess.geometryClaimTokenCurrent(ac, geometryClaim) && | 712 | + | return effect.current() && sess.geometry.current(geometryClaim) && |
| 713 | rc.resizeCurrentForLease(epoch, ac, lease, false) | 713 | rc.resizeCurrentForLease(epoch, ac, lease, false) | ||
| 714 | } | 714 | } | ||
| 715 | if !current() { | 715 | if !current() { | ||
| 716 | return false | 716 | return false | ||
| 717 | } | 717 | } | ||
| 718 | d.exitCopyMode(ac) | 718 | d.exitCopyMode(ac) | ||
| 719 | - | failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool { | 719 | + | failed, ok := g.applySessionLayout(d, sess, snap.size, current, func() bool { |
| 720 | return rc.resizeCurrentForLease(epoch, ac, lease, true) | 720 | return rc.resizeCurrentForLease(epoch, ac, lease, true) | ||
| 721 | }) | 721 | }) | ||
| 722 | if !ok { | 722 | if !ok { | ||
| @@ -737,7 +737,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||||
| 737 | return false | 737 | return false | ||
| 738 | } | 738 | } | ||
| 739 | if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) { | 739 | if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) { | ||
| 740 | - | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | 740 | + | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { g.retryResizeMembers(d, sess, ac, lease, epoch, failed) }) |
| 741 | } | 741 | } | ||
| 742 | if !current() { | 742 | if !current() { | ||
| 743 | return false | 743 | return false | ||
| @@ -762,7 +762,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||||
| 762 | // prepared tab transaction. The captured members identify retry candidates | 762 | // prepared tab transaction. The captured members identify retry candidates | ||
| 763 | // only: their rectangles must never cross this delayed boundary, because any | 763 | // only: their rectangles must never cross this delayed boundary, because any | ||
| 764 | // later layout mutation may have changed their geometry or removed them. | 764 | // later layout mutation may have changed their geometry or removed them. | ||
| 765 | - | func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) { | 765 | + | func (g *sharedPTYGeometry) retryResizeMembers(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) { |
| 766 | rc := sess.renderCoordinator() | 766 | rc := sess.renderCoordinator() | ||
| 767 | if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) { | 767 | if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) { | ||
| 768 | return | 768 | return | ||
| @@ -811,7 +811,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||||
| 811 | // pointers, and solved rectangles, and validates them again before any | 811 | // pointers, and solved rectangles, and validates them again before any | ||
| 812 | // VT/rectangle publication. It also preserves the resize gate and | 812 | // VT/rectangle publication. It also preserves the resize gate and | ||
| 813 | // degradation notice behavior for another failed external attempt. | 813 | // degradation notice behavior for another failed external attempt. | ||
| 814 | - | freshFailed, ok := d.applyTabLayoutTransaction(sess, tb, func() bool { | 814 | + | freshFailed, ok := g.applyTabLayoutTransaction(d, sess, tb, func() bool { |
| 815 | return rc.retryCurrentForLease(epoch, ac, lease) | 815 | return rc.retryCurrentForLease(epoch, ac, lease) | ||
| 816 | }) | 816 | }) | ||
| 817 | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | 817 | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | ||
| @@ -856,7 +856,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||||
| 856 | if !currentSlot || !retryPending { | 856 | if !currentSlot || !retryPending { | ||
| 857 | continue | 857 | continue | ||
| 858 | } | 858 | } | ||
| 859 | - | freshFailed, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool { | 859 | + | freshFailed, ok := g.applyVisibleFloatingLayoutForMember(d, sess, member.tab, func() bool { |
| 860 | return rc.retryCurrentForLease(epoch, ac, lease) | 860 | return rc.retryCurrentForLease(epoch, ac, lease) | ||
| 861 | }, &member) | 861 | }, &member) | ||
| 862 | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | 862 | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | ||
| @@ -875,7 +875,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||||
| 875 | } | 875 | } | ||
| 876 | } | 876 | } | ||
| 877 | if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) { | 877 | if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) { | ||
| 878 | - | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | 878 | + | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { g.retryResizeMembers(d, sess, ac, lease, epoch, failed) }) |
| 879 | } | 879 | } | ||
| 880 | if succeeded { | 880 | if succeeded { | ||
| 881 | // Retry completion changes VT state after the original layout commit. | 881 | // Retry completion changes VT state after the original layout commit. | ||
| @@ -885,20 +885,20 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at | |||||
| 885 | } | 885 | } | ||
| 886 | } | 886 | } | ||
| 887 | 887 | ||||
| 888 | - | // requestTransactionalResize reports reset invalidation completion for immediate | 888 | + | // requestResize reports reset invalidation completion for immediate |
| 889 | // attached requests, and coordinator schedule acceptance for async requests. | 889 | // attached requests, and coordinator schedule acceptance for async requests. | ||
| 890 | // Headless requests have no reset invalidation and report geometry completion. | 890 | // Headless requests have no reset invalidation and report geometry completion. | ||
| 891 | - | func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool { | 891 | + | func (g *sharedPTYGeometry) requestResize(d *Daemon, sess *session, ac *attachedClient, size domain.Size, immediate bool) bool { |
| 892 | if ac == nil { | 892 | if ac == nil { | ||
| 893 | - | return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate) | 893 | + | return g.requestResizeForLease(d, sess, nil, nil, size, immediate) |
| 894 | } | 894 | } | ||
| 895 | rc := d.attachCoordinator(sess, nil, ac, true) | 895 | rc := d.attachCoordinator(sess, nil, ac, true) | ||
| 896 | - | return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate) | 896 | + | return g.requestResizeForLease(d, sess, ac, rc.attachmentLease(ac), size, immediate) |
| 897 | } | 897 | } | ||
| 898 | 898 | ||||
| 899 | - | // requestTransactionalResizeForLease carries the connection's captured lease | 899 | + | // requestResizeForLease carries the Connection's captured lease |
| 900 | // through the full resize transaction and every delayed retry callback. | 900 | // through the full resize transaction and every delayed retry callback. | ||
| 901 | - | func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedClient, lease *attachmentLease, size domain.Size, immediate bool) bool { | 901 | + | func (g *sharedPTYGeometry) requestResizeForLease(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, size domain.Size, immediate bool) bool { |
| 902 | valid := sess != nil && size.Valid() | 902 | valid := sess != nil && size.Valid() | ||
| 903 | d.observeRuntime(ports.RuntimeResizeRequested, 0, valid) | 903 | d.observeRuntime(ports.RuntimeResizeRequested, 0, valid) | ||
| 904 | if !valid { | 904 | if !valid { | ||
| @@ -907,28 +907,28 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC | |||||
| 907 | if ac == nil { | 907 | if ac == nil { | ||
| 908 | // Headless geometry has no coordinator/transport to coalesce, but keeps | 908 | // Headless geometry has no coordinator/transport to coalesce, but keeps | ||
| 909 | // the same prepare/apply/commit ordering. | 909 | // the same prepare/apply/commit ordering. | ||
| 910 | - | _, ok := d.applySessionLayout(sess, size, nil, nil) | 910 | + | _, ok := g.applySessionLayout(d, sess, size, nil, nil) |
| 911 | return ok | 911 | return ok | ||
| 912 | } | 912 | } | ||
| 913 | rc := sess.renderCoordinator() | 913 | rc := sess.renderCoordinator() | ||
| 914 | if rc == nil || !rc.leaseCurrent(lease, true) { | 914 | if rc == nil || !rc.leaseCurrent(lease, true) { | ||
| 915 | return false | 915 | return false | ||
| 916 | } | 916 | } | ||
| 917 | - | geometryClaim, claimed := sess.claimGeometryOwnerForSize(ac, size) | 917 | + | geometryClaim, claimed := g.claimSize(sess, ac, size) |
| 918 | if !claimed { | 918 | if !claimed { | ||
| 919 | return false | 919 | return false | ||
| 920 | } | 920 | } | ||
| 921 | if immediate { | 921 | if immediate { | ||
| 922 | epoch := rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim) | 922 | epoch := rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim) | ||
| 923 | if epoch == 0 { | 923 | if epoch == 0 { | ||
| 924 | - | sess.releaseGeometryClaim(ac, geometryClaim) | 924 | + | g.release(sess, geometryClaim) |
| 925 | return false | 925 | return false | ||
| 926 | } | 926 | } | ||
| 927 | - | return d.runResizeTransaction(sess, ac, lease, epoch) | 927 | + | return g.runResizeTransaction(d, sess, ac, lease, epoch) |
| 928 | } | 928 | } | ||
| 929 | - | epoch := rc.scheduleResizeForLeaseWithClaim(size, ac, lease, geometryClaim, func(epoch uint64) { d.runResizeTransaction(sess, ac, lease, epoch) }) | 929 | + | epoch := rc.scheduleResizeForLeaseWithClaim(size, ac, lease, geometryClaim, func(epoch uint64) { g.runResizeTransaction(d, sess, ac, lease, epoch) }) |
| 930 | if epoch == 0 { | 930 | if epoch == 0 { | ||
| 931 | - | sess.releaseGeometryClaim(ac, geometryClaim) | 931 | + | g.release(sess, geometryClaim) |
| 932 | } | 932 | } | ||
| 933 | return epoch != 0 | 933 | return epoch != 0 | ||
| 934 | } | 934 | } | ||
M internal/usecase/daemon/transactional_resize_test.go +25 -21
| @@ -210,7 +210,7 @@ func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) { | |||
| 210 | 210 | tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane() | |
| 211 | 211 | d.beforeSessionResizePublication = sess.cancel | |
| 212 | 212 | ||
| 213 | - | _, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) | |
| 213 | + | _, ok := sess.geometry.applySessionLayout(d, sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) | |
| 214 | 214 | ||
| 215 | 215 | require.False(t, ok) | |
| 216 | 216 | require.Len(t, pty.requested(), 1, "cancellation must stop before a stale retry applies another PTY resize") | |
| @@ -272,7 +272,7 @@ func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) { | |||
| 272 | 272 | } | |
| 273 | 273 | d, sess, _, _ := newManualSessionWithPTYs(t, first, second) | |
| 274 | 274 | ||
| 275 | - | failed, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) | |
| 275 | + | failed, ok := sess.geometry.applySessionLayout(d, sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) | |
| 276 | 276 | ||
| 277 | 277 | require.Equal(t, tc.wantOK, ok) | |
| 278 | 278 | require.Len(t, failed, tc.wantFailed) | |
| @@ -334,10 +334,10 @@ func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing | |||
| 334 | 334 | sess.snapEligible.Store(true) | |
| 335 | 335 | tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane() | |
| 336 | 336 | ||
| 337 | - | require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) | |
| 337 | + | require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) | |
| 338 | 338 | rc := sess.renderCoordinator() | |
| 339 | 339 | snapshot := rc.resizeSnapshot() | |
| 340 | - | d.retryResizeMembers(sess, ac, rc.attachmentLease(ac), snapshot.epoch, []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}}) | |
| 340 | + | sess.geometry.retryResizeMembers(d, sess, ac, rc.attachmentLease(ac), snapshot.epoch, []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}}) | |
| 341 | 341 | ||
| 342 | 342 | sess.snapshotMu.Lock() | |
| 343 | 343 | generation := sess.snapshotGeneration | |
| @@ -432,7 +432,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||
| 432 | 432 | require.NotZero(t, newer) | |
| 433 | 433 | } | |
| 434 | 434 | ||
| 435 | - | require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) | |
| 435 | + | require.False(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) | |
| 436 | 436 | d.beforeSessionResizePublication = nil | |
| 437 | 437 | require.False(t, sess.snapDirty.Load(), "rejected epoch must not dirty the snapshot") | |
| 438 | 438 | for _, tb := range sess.tabs { | |
| @@ -442,7 +442,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||
| 442 | 442 | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "rejected epoch published a VT size") | |
| 443 | 443 | } | |
| 444 | 444 | ||
| 445 | - | require.True(t, d.runResizeTransaction(sess, ac, lease, newer)) | |
| 445 | + | require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, newer)) | |
| 446 | 446 | for _, tb := range sess.tabs { | |
| 447 | 447 | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size) | |
| 448 | 448 | p := tb.focusedPane() | |
| @@ -487,7 +487,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) { | |||
| 487 | 487 | <-release | |
| 488 | 488 | } | |
| 489 | 489 | done := make(chan bool, 1) | |
| 490 | - | go func() { done <- d.runResizeTransaction(sess, ac, lease, epoch) }() | |
| 490 | + | go func() { done <- sess.geometry.runResizeTransaction(d, sess, ac, lease, epoch) }() | |
| 491 | 491 | <-entered | |
| 492 | 492 | newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease) | |
| 493 | 493 | require.NotZero(t, newer) | |
| @@ -514,11 +514,11 @@ func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) { | |||
| 514 | 514 | tb.bumpLayoutGenerationLocked() | |
| 515 | 515 | tb.mu.Unlock() | |
| 516 | 516 | ||
| 517 | - | d.finishPreparedTabMembers(&plan, false) | |
| 517 | + | sess.geometry.finishPreparedTabMembers(d, &plan, false) | |
| 518 | 518 | p.mu.Lock() | |
| 519 | 519 | require.True(t, p.resizeApplying, "stale finalization must leave cancellation to the fresh-plan path") | |
| 520 | 520 | p.mu.Unlock() | |
| 521 | - | d.cancelStalePreparedGates(sess, tb, &plan) | |
| 521 | + | sess.geometry.cancelStalePreparedGates(d, sess, tb, &plan) | |
| 522 | 522 | p.mu.Lock() | |
| 523 | 523 | require.False(t, p.resizeApplying) | |
| 524 | 524 | require.Empty(t, p.resizePending) | |
| @@ -538,7 +538,7 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) { | |||
| 538 | 538 | }) | |
| 539 | 539 | } | |
| 540 | 540 | ||
| 541 | - | require.True(t, d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true)) | |
| 541 | + | require.True(t, sess.geometry.requestResize(d, sess, nil, domain.Size{Cols: 100, Rows: 30}, true)) | |
| 542 | 542 | require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 100, Rows: 28}}, pty.requested()) | |
| 543 | 543 | tb.mu.Lock() | |
| 544 | 544 | require.Equal(t, domain.Size{Cols: 100, Rows: 28}, tb.size) | |
| @@ -558,7 +558,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |||
| 558 | 558 | rc := d.attachCoordinator(source, nil, ac, true) | |
| 559 | 559 | lease := rc.attachmentLease(ac) | |
| 560 | 560 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | |
| 561 | - | require.True(t, d.runResizeTransaction(source, ac, lease, epoch)) | |
| 561 | + | require.True(t, source.geometry.runResizeTransaction(d, source, ac, lease, epoch)) | |
| 562 | 562 | ||
| 563 | 563 | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}} | |
| 564 | 564 | publishPaneOwner(popup, destination, tb, 7) | |
| @@ -572,7 +572,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |||
| 572 | 572 | source.snapshotMu.Unlock() | |
| 573 | 573 | requested := popupPTY.requested() | |
| 574 | 574 | ||
| 575 | - | d.retryResizeMembers(source, ac, lease, epoch, []resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}}) | |
| 575 | + | source.geometry.retryResizeMembers(d, source, ac, lease, epoch, []resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}}) | |
| 576 | 576 | ||
| 577 | 577 | require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY") | |
| 578 | 578 | popup.mu.Lock() | |
| @@ -598,7 +598,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |||
| 598 | 598 | t.Run(tc.name, func(t *testing.T) { | |
| 599 | 599 | d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 600 | 600 | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 601 | - | require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true)) | |
| 601 | + | require.True(t, sess.geometry.requestResize(d, sess, nil, tc.from, true)) | |
| 602 | 602 | ||
| 603 | 603 | tb := testAttachmentTab(sess) | |
| 604 | 604 | fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg) | |
| @@ -615,7 +615,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |||
| 615 | 615 | installTestFloating(tb, popup, true) | |
| 616 | 616 | ||
| 617 | 617 | done := make(chan bool, 1) | |
| 618 | - | go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }() | |
| 618 | + | go func() { done <- sess.geometry.requestResize(d, sess, nil, tc.to, true) }() | |
| 619 | 619 | <-entered | |
| 620 | 620 | popup.mu.Lock() | |
| 621 | 621 | require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode") | |
| @@ -664,7 +664,8 @@ func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure( | |||
| 664 | 664 | } | |
| 665 | 665 | } | |
| 666 | 666 | ||
| 667 | - | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |
| 667 | + | sess := &session{tabs: []*tab{tb}} | |
| 668 | + | failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) | |
| 668 | 669 | ||
| 669 | 670 | require.True(t, ok) | |
| 670 | 671 | require.Len(t, failed, 1) | |
| @@ -704,7 +705,8 @@ func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t * | |||
| 704 | 705 | d := newTestDaemon(t, nil, stubClock{}) | |
| 705 | 706 | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 706 | 707 | ||
| 707 | - | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |
| 708 | + | sess := &session{tabs: []*tab{tb}} | |
| 709 | + | failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) | |
| 708 | 710 | ||
| 709 | 711 | require.True(t, ok) | |
| 710 | 712 | require.Empty(t, failed) | |
| @@ -730,7 +732,8 @@ func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) { | |||
| 730 | 732 | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 731 | 733 | calls := 0 | |
| 732 | 734 | ||
| 733 | - | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { | |
| 735 | + | sess := &session{tabs: []*tab{tb}} | |
| 736 | + | failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, func() bool { | |
| 734 | 737 | calls++ | |
| 735 | 738 | return calls == 1 | |
| 736 | 739 | }) | |
| @@ -759,7 +762,8 @@ func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t | |||
| 759 | 762 | d := newTestDaemon(t, nil, stubClock{}) | |
| 760 | 763 | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 761 | 764 | ||
| 762 | - | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false }) | |
| 765 | + | sess := &session{tabs: []*tab{tb}} | |
| 766 | + | failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, func() bool { return false }) | |
| 763 | 767 | ||
| 764 | 768 | require.False(t, ok) | |
| 765 | 769 | require.Empty(t, failed) | |
| @@ -784,11 +788,11 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) | |||
| 784 | 788 | lease := rc.attachmentLease(ac) | |
| 785 | 789 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | |
| 786 | 790 | require.NotZero(t, epoch) | |
| 787 | - | require.True(t, d.runResizeTransaction(sess, ac, lease, epoch)) | |
| 791 | + | require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, epoch)) | |
| 788 | 792 | first := popupPTY.requested() | |
| 789 | 793 | require.Len(t, first, 1) | |
| 790 | 794 | ||
| 791 | - | d.retryResizeMembers(sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}}) | |
| 795 | + | sess.geometry.retryResizeMembers(d, sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}}) | |
| 792 | 796 | retried := popupPTY.requested() | |
| 793 | 797 | require.Len(t, retried, 2) | |
| 794 | 798 | require.Equal(t, first[0], retried[1], "retry must use the current validated floating slot geometry") | |
| @@ -802,7 +806,7 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) | |||
| 802 | 806 | tb.mu.Lock() | |
| 803 | 807 | tb.floating = floatingSlot{state: floatingVisible, pane: replacement, generation: 8} | |
| 804 | 808 | tb.mu.Unlock() | |
| 805 | - | d.retryResizeMembers(sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}}) | |
| 809 | + | sess.geometry.retryResizeMembers(d, sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}}) | |
| 806 | 810 | require.Empty(t, replacementPTY.requested(), "a replaced floating slot must not inherit an old retry") | |
| 807 | 811 | } | |
| 808 | 812 | ||
| @@ -210,7 +210,7 @@ func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) { | |||||
| 210 | tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane() | 210 | tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane() | ||
| 211 | d.beforeSessionResizePublication = sess.cancel | 211 | d.beforeSessionResizePublication = sess.cancel | ||
| 212 | 212 | ||||
| 213 | - | _, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) | 213 | + | _, ok := sess.geometry.applySessionLayout(d, sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) |
| 214 | 214 | ||||
| 215 | require.False(t, ok) | 215 | require.False(t, ok) | ||
| 216 | require.Len(t, pty.requested(), 1, "cancellation must stop before a stale retry applies another PTY resize") | 216 | require.Len(t, pty.requested(), 1, "cancellation must stop before a stale retry applies another PTY resize") | ||
| @@ -272,7 +272,7 @@ func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) { | |||||
| 272 | } | 272 | } | ||
| 273 | d, sess, _, _ := newManualSessionWithPTYs(t, first, second) | 273 | d, sess, _, _ := newManualSessionWithPTYs(t, first, second) | ||
| 274 | 274 | ||||
| 275 | - | failed, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) | 275 | + | failed, ok := sess.geometry.applySessionLayout(d, sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) |
| 276 | 276 | ||||
| 277 | require.Equal(t, tc.wantOK, ok) | 277 | require.Equal(t, tc.wantOK, ok) | ||
| 278 | require.Len(t, failed, tc.wantFailed) | 278 | require.Len(t, failed, tc.wantFailed) | ||
| @@ -334,10 +334,10 @@ func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing | |||||
| 334 | sess.snapEligible.Store(true) | 334 | sess.snapEligible.Store(true) | ||
| 335 | tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane() | 335 | tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane() | ||
| 336 | 336 | ||||
| 337 | - | require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) | 337 | + | require.True(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) |
| 338 | rc := sess.renderCoordinator() | 338 | rc := sess.renderCoordinator() | ||
| 339 | snapshot := rc.resizeSnapshot() | 339 | snapshot := rc.resizeSnapshot() | ||
| 340 | - | d.retryResizeMembers(sess, ac, rc.attachmentLease(ac), snapshot.epoch, []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}}) | 340 | + | sess.geometry.retryResizeMembers(d, sess, ac, rc.attachmentLease(ac), snapshot.epoch, []resizeMember{{session: sess, tab: tb, pane: p, rect: p.rect}}) |
| 341 | 341 | ||||
| 342 | sess.snapshotMu.Lock() | 342 | sess.snapshotMu.Lock() | ||
| 343 | generation := sess.snapshotGeneration | 343 | generation := sess.snapshotGeneration | ||
| @@ -432,7 +432,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||||
| 432 | require.NotZero(t, newer) | 432 | require.NotZero(t, newer) | ||
| 433 | } | 433 | } | ||
| 434 | 434 | ||||
| 435 | - | require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) | 435 | + | require.False(t, sess.geometry.requestResize(d, sess, ac, domain.Size{Cols: 100, Rows: 30}, true)) |
| 436 | d.beforeSessionResizePublication = nil | 436 | d.beforeSessionResizePublication = nil | ||
| 437 | require.False(t, sess.snapDirty.Load(), "rejected epoch must not dirty the snapshot") | 437 | require.False(t, sess.snapDirty.Load(), "rejected epoch must not dirty the snapshot") | ||
| 438 | for _, tb := range sess.tabs { | 438 | for _, tb := range sess.tabs { | ||
| @@ -442,7 +442,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing | |||||
| 442 | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "rejected epoch published a VT size") | 442 | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "rejected epoch published a VT size") | ||
| 443 | } | 443 | } | ||
| 444 | 444 | ||||
| 445 | - | require.True(t, d.runResizeTransaction(sess, ac, lease, newer)) | 445 | + | require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, newer)) |
| 446 | for _, tb := range sess.tabs { | 446 | for _, tb := range sess.tabs { | ||
| 447 | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size) | 447 | require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size) | ||
| 448 | p := tb.focusedPane() | 448 | p := tb.focusedPane() | ||
| @@ -487,7 +487,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) { | |||||
| 487 | <-release | 487 | <-release | ||
| 488 | } | 488 | } | ||
| 489 | done := make(chan bool, 1) | 489 | done := make(chan bool, 1) | ||
| 490 | - | go func() { done <- d.runResizeTransaction(sess, ac, lease, epoch) }() | 490 | + | go func() { done <- sess.geometry.runResizeTransaction(d, sess, ac, lease, epoch) }() |
| 491 | <-entered | 491 | <-entered | ||
| 492 | newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease) | 492 | newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease) | ||
| 493 | require.NotZero(t, newer) | 493 | require.NotZero(t, newer) | ||
| @@ -514,11 +514,11 @@ func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) { | |||||
| 514 | tb.bumpLayoutGenerationLocked() | 514 | tb.bumpLayoutGenerationLocked() | ||
| 515 | tb.mu.Unlock() | 515 | tb.mu.Unlock() | ||
| 516 | 516 | ||||
| 517 | - | d.finishPreparedTabMembers(&plan, false) | 517 | + | sess.geometry.finishPreparedTabMembers(d, &plan, false) |
| 518 | p.mu.Lock() | 518 | p.mu.Lock() | ||
| 519 | require.True(t, p.resizeApplying, "stale finalization must leave cancellation to the fresh-plan path") | 519 | require.True(t, p.resizeApplying, "stale finalization must leave cancellation to the fresh-plan path") | ||
| 520 | p.mu.Unlock() | 520 | p.mu.Unlock() | ||
| 521 | - | d.cancelStalePreparedGates(sess, tb, &plan) | 521 | + | sess.geometry.cancelStalePreparedGates(d, sess, tb, &plan) |
| 522 | p.mu.Lock() | 522 | p.mu.Lock() | ||
| 523 | require.False(t, p.resizeApplying) | 523 | require.False(t, p.resizeApplying) | ||
| 524 | require.Empty(t, p.resizePending) | 524 | require.Empty(t, p.resizePending) | ||
| @@ -538,7 +538,7 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) { | |||||
| 538 | }) | 538 | }) | ||
| 539 | } | 539 | } | ||
| 540 | 540 | ||||
| 541 | - | require.True(t, d.requestTransactionalResize(sess, nil, domain.Size{Cols: 100, Rows: 30}, true)) | 541 | + | require.True(t, sess.geometry.requestResize(d, sess, nil, domain.Size{Cols: 100, Rows: 30}, true)) |
| 542 | require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 100, Rows: 28}}, pty.requested()) | 542 | require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 100, Rows: 28}}, pty.requested()) | ||
| 543 | tb.mu.Lock() | 543 | tb.mu.Lock() | ||
| 544 | require.Equal(t, domain.Size{Cols: 100, Rows: 28}, tb.size) | 544 | require.Equal(t, domain.Size{Cols: 100, Rows: 28}, tb.size) | ||
| @@ -558,7 +558,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |||||
| 558 | rc := d.attachCoordinator(source, nil, ac, true) | 558 | rc := d.attachCoordinator(source, nil, ac, true) | ||
| 559 | lease := rc.attachmentLease(ac) | 559 | lease := rc.attachmentLease(ac) | ||
| 560 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | 560 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | ||
| 561 | - | require.True(t, d.runResizeTransaction(source, ac, lease, epoch)) | 561 | + | require.True(t, source.geometry.runResizeTransaction(d, source, ac, lease, epoch)) |
| 562 | 562 | ||||
| 563 | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}} | 563 | destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}} | ||
| 564 | publishPaneOwner(popup, destination, tb, 7) | 564 | publishPaneOwner(popup, destination, tb, 7) | ||
| @@ -572,7 +572,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |||||
| 572 | source.snapshotMu.Unlock() | 572 | source.snapshotMu.Unlock() | ||
| 573 | requested := popupPTY.requested() | 573 | requested := popupPTY.requested() | ||
| 574 | 574 | ||||
| 575 | - | d.retryResizeMembers(source, ac, lease, epoch, []resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}}) | 575 | + | source.geometry.retryResizeMembers(d, source, ac, lease, epoch, []resizeMember{{session: source, tab: tb, pane: popup, owner: owner, isFloating: true, floatingGeneration: 7}}) |
| 576 | 576 | ||||
| 577 | require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY") | 577 | require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY") | ||
| 578 | popup.mu.Lock() | 578 | popup.mu.Lock() | ||
| @@ -598,7 +598,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |||||
| 598 | t.Run(tc.name, func(t *testing.T) { | 598 | t.Run(tc.name, func(t *testing.T) { | ||
| 599 | d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | 599 | d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | ||
| 600 | d.ApplyConfig(domain.Config{Floating: cfg}) | 600 | d.ApplyConfig(domain.Config{Floating: cfg}) | ||
| 601 | - | require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true)) | 601 | + | require.True(t, sess.geometry.requestResize(d, sess, nil, tc.from, true)) |
| 602 | 602 | ||||
| 603 | tb := testAttachmentTab(sess) | 603 | tb := testAttachmentTab(sess) | ||
| 604 | fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg) | 604 | fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg) | ||
| @@ -615,7 +615,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |||||
| 615 | installTestFloating(tb, popup, true) | 615 | installTestFloating(tb, popup, true) | ||
| 616 | 616 | ||||
| 617 | done := make(chan bool, 1) | 617 | done := make(chan bool, 1) | ||
| 618 | - | go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }() | 618 | + | go func() { done <- sess.geometry.requestResize(d, sess, nil, tc.to, true) }() |
| 619 | <-entered | 619 | <-entered | ||
| 620 | popup.mu.Lock() | 620 | popup.mu.Lock() | ||
| 621 | require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode") | 621 | require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode") | ||
| @@ -664,7 +664,8 @@ func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure( | |||||
| 664 | } | 664 | } | ||
| 665 | } | 665 | } | ||
| 666 | 666 | ||||
| 667 | - | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | 667 | + | sess := &session{tabs: []*tab{tb}} |
| 668 | + | failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) | |||
| 668 | 669 | ||||
| 669 | require.True(t, ok) | 670 | require.True(t, ok) | ||
| 670 | require.Len(t, failed, 1) | 671 | require.Len(t, failed, 1) | ||
| @@ -704,7 +705,8 @@ func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t * | |||||
| 704 | d := newTestDaemon(t, nil, stubClock{}) | 705 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 705 | d.ApplyConfig(domain.Config{Floating: cfg}) | 706 | d.ApplyConfig(domain.Config{Floating: cfg}) | ||
| 706 | 707 | ||||
| 707 | - | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | 708 | + | sess := &session{tabs: []*tab{tb}} |
| 709 | + | failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil) | |||
| 708 | 710 | ||||
| 709 | require.True(t, ok) | 711 | require.True(t, ok) | ||
| 710 | require.Empty(t, failed) | 712 | require.Empty(t, failed) | ||
| @@ -730,7 +732,8 @@ func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) { | |||||
| 730 | d.ApplyConfig(domain.Config{Floating: cfg}) | 732 | d.ApplyConfig(domain.Config{Floating: cfg}) | ||
| 731 | calls := 0 | 733 | calls := 0 | ||
| 732 | 734 | ||||
| 733 | - | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { | 735 | + | sess := &session{tabs: []*tab{tb}} |
| 736 | + | failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, func() bool { | |||
| 734 | calls++ | 737 | calls++ | ||
| 735 | return calls == 1 | 738 | return calls == 1 | ||
| 736 | }) | 739 | }) | ||
| @@ -759,7 +762,8 @@ func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t | |||||
| 759 | d := newTestDaemon(t, nil, stubClock{}) | 762 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 760 | d.ApplyConfig(domain.Config{Floating: cfg}) | 763 | d.ApplyConfig(domain.Config{Floating: cfg}) | ||
| 761 | 764 | ||||
| 762 | - | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false }) | 765 | + | sess := &session{tabs: []*tab{tb}} |
| 766 | + | failed, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, func() bool { return false }) | |||
| 763 | 767 | ||||
| 764 | require.False(t, ok) | 768 | require.False(t, ok) | ||
| 765 | require.Empty(t, failed) | 769 | require.Empty(t, failed) | ||
| @@ -784,11 +788,11 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) | |||||
| 784 | lease := rc.attachmentLease(ac) | 788 | lease := rc.attachmentLease(ac) | ||
| 785 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | 789 | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease) | ||
| 786 | require.NotZero(t, epoch) | 790 | require.NotZero(t, epoch) | ||
| 787 | - | require.True(t, d.runResizeTransaction(sess, ac, lease, epoch)) | 791 | + | require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, epoch)) |
| 788 | first := popupPTY.requested() | 792 | first := popupPTY.requested() | ||
| 789 | require.Len(t, first, 1) | 793 | require.Len(t, first, 1) | ||
| 790 | 794 | ||||
| 791 | - | d.retryResizeMembers(sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}}) | 795 | + | sess.geometry.retryResizeMembers(d, sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}}) |
| 792 | retried := popupPTY.requested() | 796 | retried := popupPTY.requested() | ||
| 793 | require.Len(t, retried, 2) | 797 | require.Len(t, retried, 2) | ||
| 794 | require.Equal(t, first[0], retried[1], "retry must use the current validated floating slot geometry") | 798 | require.Equal(t, first[0], retried[1], "retry must use the current validated floating slot geometry") | ||
| @@ -802,7 +806,7 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) | |||||
| 802 | tb.mu.Lock() | 806 | tb.mu.Lock() | ||
| 803 | tb.floating = floatingSlot{state: floatingVisible, pane: replacement, generation: 8} | 807 | tb.floating = floatingSlot{state: floatingVisible, pane: replacement, generation: 8} | ||
| 804 | tb.mu.Unlock() | 808 | tb.mu.Unlock() | ||
| 805 | - | d.retryResizeMembers(sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}}) | 809 | + | sess.geometry.retryResizeMembers(d, sess, ac, lease, epoch, []resizeMember{{session: sess, tab: tb, pane: popup, isFloating: true, floatingGeneration: 7}}) |
| 806 | require.Empty(t, replacementPTY.requested(), "a replaced floating slot must not inherit an old retry") | 810 | require.Empty(t, replacementPTY.requested(), "a replaced floating slot must not inherit an old retry") | ||
| 807 | } | 811 | } | ||
| 808 | 812 | ||||