detached cbfd03f

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 UnverifiedB5690EEEBB952194 The 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 {
101101 c.nextAttachmentID++
102102 c.attachments[ac] = struct{}{}
103103 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)
106106 }
107107 return true
108108 }
109109
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-
164110 func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
165111 if entry == nil || entry.core() == nil {
166112 return false
@@ -182,64 +128,6 @@ func (s *session) registerAttachment(ac *attachedClient) bool {
182128 return s.registerAttachmentLocked(ac)
183129 }
184130
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-
243131 // unregisterAttachmentLocked removes only ac and leaves every other
244132 // attachment's view intact. Caller holds s.mu.
245133 func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool {
@@ -258,7 +146,7 @@ func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
258146 }
259147 delete(c.attachments, ac)
260148 delete(c.attachmentOrder, ac)
261- c.refreshGeometryOwnerLocked()
149+ c.geometry.removeAttachmentLocked(c, ac)
262150 return true
263151 }
264152
@@ -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.nextAttachmentID103 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 true107 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 false112 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 other131 // 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 true150 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) {
334334 return
335335 }
336336 superseded = true
337- sess.claimGeometryOwner(second)
337+ sess.geometry.claimAttachment(sess, second)
338338 }
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")
340340
341341 tb := sess.tabs[0]
342342 tb.mu.Lock()
@@ -344,7 +344,7 @@ func TestStaleAttachmentGeometryClaimCannotCommit(t *testing.T) {
344344 tb.mu.Unlock()
345345
346346 d.beforeSessionResizePublication = nil
347- require.True(t, d.recalculateSessionGeometry(sess, nil))
347+ require.True(t, sess.geometry.reconcile(d, sess, nil))
348348 tb.mu.Lock()
349349 require.Equal(t, domain.Size{Cols: 90, Rows: 28}, tb.size)
350350 tb.mu.Unlock()
@@ -358,9 +358,9 @@ func TestAttachmentMoveReconcilesSourceGeometryAfterOwnerRemoval(t *testing.T) {
358358 require.True(t, source.registerAttachment(peer))
359359
360360 moved.setSize(domain.Size{Cols: 120, Rows: 40})
361- _, claimed := source.claimGeometryOwner(moved)
361+ _, claimed := source.geometry.claimAttachment(source, moved)
362362 require.True(t, claimed)
363- require.True(t, d.recalculateSessionGeometry(source, nil))
363+ require.True(t, source.geometry.reconcile(d, source, nil))
364364
365365 target := &session{
366366 sessionCore: sessionCore{id: "target-geometry", name: "target-geometry"},
@@ -334,9 +334,9 @@ func TestStaleAttachmentGeometryClaimCannotCommit(t *testing.T) {
334 return334 return
335 }335 }
336 superseded = true336 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 = nil346 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
168168 }
169169 }
170170 if result.sourceGeometrySession != nil {
171- d.recalculateSessionGeometryAndInvalidateAsync(result.sourceGeometrySession, "attachment_transition.go")
171+ result.sourceGeometrySession.geometry.reconcileAndInvalidateAsync(d, result.sourceGeometrySession, "attachment_transition.go")
172172 }
173173 return result, nil
174174 }
@@ -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, nil173 return result, nil
174 }174 }
M internal/usecase/daemon/attachment_transition_publish.go +1 -1
@@ -192,7 +192,7 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
192192 if !registered && publication.source == req.target && !req.preserveAttachment {
193193 // A same-session rebind keeps membership but still represents a fresh
194194 // 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())
196196 }
197197 req.next.setSession(req.target)
198198 }
@@ -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 fresh193 // 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 {
6868 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
6969 // sizeMu lets shared-geometry snapshots read attachment windows without
7070 // 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
8175 // view is attachment-local navigation state. It is never inferred from a
8276 // session-wide active tab, so multiple attachments can observe different
8377 // tabs and panes without changing shared session ownership.
@@ -200,47 +194,6 @@ func (ac *attachedClient) setGeometry(geometry domain.Geometry) {
200194 ac.sizeMu.Unlock()
201195 }
202196
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-
244197 // currentSession returns the attachment's owning local session.
245198 func (ac *attachedClient) currentSession() *session {
246199 if ac == nil {
@@ -912,9 +865,9 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, lease *a
912865 // PTY.Resize is an external, non-cancelable operation. Do not hold the
913866 // handshake on it; the first frame can use retained geometry and the
914867 // shared invalidation will repaint every attachment after the claim wins.
915- d.recalculateSessionGeometryAndInvalidateAsync(sess, "client.go")
868+ sess.geometry.reconcileAndInvalidateAsync(d, sess, "client.go")
916869 } else {
917- d.recalculateSessionGeometryAndInvalidate(sess, nil, "client.go")
870+ sess.geometry.reconcileAndInvalidate(d, sess, nil, "client.go")
918871 }
919872 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
920873 // 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
976929 }
977930
978931 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)
980933 }
981934
982935 // resizeAttachmentForLease updates the sender's window/view, then makes that
@@ -1040,7 +993,7 @@ func (d *Daemon) resizeAttachmentGeometryForLease(effect *attachmentEffect, geom
1040993 // When this resize changes canonical geometry, the shared invalidation fans
1041994 // out a fresh frame to every attachment; otherwise only this attachment
1042995 // needs a viewport redraw.
1043- if d.recalculateSessionGeometryAndInvalidate(sess, ac, "client.go") {
996+ if sess.geometry.reconcileAndInvalidate(d, sess, ac, "client.go") {
1044997 return true
1045998 }
1046999 if sameGeometry {
@@ -1157,7 +1110,7 @@ func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domai
11571110 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
11581111 // returned value reports whether the synchronous request was accepted.
11591112 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)
11611114 }
11621115
11631116 // 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 held68 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
69 // sizeMu lets shared-geometry snapshots read attachment windows without69 // 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.RWMutex71+ sizeMu sync.RWMutex
72- size domain.Size // retained for legacy fixtures; live paths publish geometry72+ size domain.Size // retained for legacy fixtures; live paths publish geometry
73- geometry domain.Geometry // controlling-terminal geometry for this attachment73+ geometry domain.Geometry // controlling-terminal geometry for this Attachment
74- geometryClaimGeometry domain.Geometry // protected by sizeMu; shared geometry claim snapshot74+ 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 a75 // view is attachment-local navigation state. It is never inferred from a
82 // session-wide active tab, so multiple attachments can observe different76 // 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 the865 // PTY.Resize is an external, non-cancelable operation. Do not hold the
913 // handshake on it; the first frame can use retained geometry and the866 // 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 shared873 // 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 that935 // 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 fans993 // When this resize changes canonical geometry, the shared invalidation fans
1041 // out a fresh frame to every attachment; otherwise only this attachment994 // 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 true997 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. The1110 // 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 is1116 // 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
7474 if rc := sess.renderCoordinator(); rc != nil {
7575 rc.noteDetach(ac)
7676 }
77- d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")
77+ sess.geometry.reconcileAndInvalidate(d, sess, nil, "client_transport_errors.go")
7878 sess.mu.Lock()
7979 ephemeral := sess.ephemeral
8080 name := sess.name
@@ -182,7 +182,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
182182 if rc := sess.renderCoordinator(); rc != nil {
183183 rc.noteDetach(ac)
184184 }
185- d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")
185+ sess.geometry.reconcileAndInvalidate(d, sess, nil, "client_transport_errors.go")
186186 if d.parkAttachment(sess, ac) {
187187 _ = ac.closeCapturedTransport(failed)
188188 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.ephemeral79 ephemeral := sess.ephemeral
80 name := sess.name80 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 {
483483 cwd, env := e.sess.cwd, copyEnvironment(e.sess.env)
484484 e.sess.mu.Unlock()
485485 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 {
487487 geometry = source.geometry
488488 }
489489 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.geometry487 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
282282 }
283283 tb.mu.Unlock()
284284 if layoutChanged {
285- d.applyTabLayout(sess, tb)
285+ sess.geometry.applyTabLayout(d, sess, tb)
286286 }
287287 if otherPane {
288288 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 {
152152 // and immediately before a post-commit effect is published. Tests use it to
153153 // move a pane through the ordered resize fences at the former TOCTOU window.
154154 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()
159155 // afterConnectionSessionSnapshot is a deterministic test seam after the
160156 // connection loop reads its current session and before it captures that role.
161157 afterConnectionSessionSnapshot func(*session)
@@ -152,10 +152,6 @@ type Daemon struct {
152 // and immediately before a post-commit effect is published. Tests use it to152 // 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 the155 // 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
202202 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
203203 tb.mu.Unlock()
204204 if hasFloating {
205- return d.requestTransactionalResize(sess, nil, size, true)
205+ return sess.geometry.requestResize(d, sess, nil, size, true)
206206 }
207207 return false
208208 }
@@ -216,9 +216,9 @@ func (d *Daemon) activateTabAfterResizeForLease(sess *session, tb *tab, outerRes
216216 tb.mu.Unlock()
217217 if hasFloating {
218218 if lease != nil {
219- return d.requestTransactionalResizeForLease(sess, ac, lease, size, true)
219+ return sess.geometry.requestResizeForLease(d, sess, ac, lease, size, true)
220220 }
221- return d.requestTransactionalResize(sess, ac, size, true)
221+ return sess.geometry.requestResize(d, sess, ac, size, true)
222222 }
223223 return false
224224 }
@@ -260,7 +260,7 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
260260 tb.mu.Lock()
261261 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
262262 tb.mu.Unlock()
263- d.requestTransactionalResize(sess, ac, size, true)
263+ sess.geometry.requestResize(d, sess, ac, size, true)
264264 }
265265 return nil
266266 }
@@ -386,7 +386,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
386386 sessionName: name,
387387 cwd: cwd,
388388 size: size,
389- ptyGeometry: sess.paneGeometry(size),
389+ ptyGeometry: sess.geometry.paneGeometry(size),
390390 geometry: geometry,
391391 paneStableID: paneStableID,
392392 env: childEnvFrom(env, name, tabStableID, paneStableID),
@@ -496,14 +496,14 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
496496 if len(attachments) == 0 {
497497 // A headless resize may commit while Open is warming. Reconcile the
498498 // visible floating geometry even without an attachment callback.
499- d.applyVisibleFloatingLayout(sess, tb, nil)
499+ sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil)
500500 return
501501 }
502502 tb.mu.Lock()
503503 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
504504 tb.mu.Unlock()
505505 for _, ac := range attachments {
506- d.requestTransactionalResize(sess, ac, size, true)
506+ sess.geometry.requestResize(d, sess, ac, size, true)
507507 }
508508 }
509509 }
@@ -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 false207 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 false223 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 nil265 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 the497 // 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 return500 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
2222 return false
2323 }
2424 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)
2627 member := plan.members[0]
2728 if !member.ok {
2829 return false
@@ -200,7 +201,8 @@ func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t
200201 d := newTestDaemon(t, nil, stubClock{})
201202 d.ApplyConfig(domain.Config{Floating: cfg})
202203
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)
204206 require.True(t, ok)
205207 require.Empty(t, failed)
206208 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) {
925927 installTestFloating(tb, p, true)
926928 d := newTestDaemon(t, nil, stubClock{})
927929 d.ApplyConfig(domain.Config{Floating: cfg})
930+ sess := &session{tabs: []*tab{tb}}
928931
929932 done := make(chan bool, 1)
930933 go func() {
931- _, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
934+ _, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil)
932935 done <- ok
933936 }()
934937 <-pty.entered
@@ -947,7 +950,7 @@ func TestFloatingLayoutRejectsStaleResizeBeforePublishing(t *testing.T) {
947950 require.False(t, p.resizeApplying, "a stale floating apply must release its parser gate")
948951 p.mu.Unlock()
949952
950- _, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
953+ _, ok := sess.geometry.applyVisibleFloatingLayout(d, sess, tb, nil)
951954 require.True(t, ok)
952955 require.Equal(t, []domain.Size{rectSize(oldGeometry.Inner), rectSize(newGeometry.Inner)}, pty.sizes())
953956 require.Equal(t, newGeometry.Inner, p.rect)
@@ -22,7 +22,8 @@ func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometr
22 return false22 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 false29 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 <- ok935 done <- ok
933 }()936 }()
934 <-pty.entered937 <-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) {
244244 d.startFloating(sess, tb, true)
245245 openCall := <-opened
246246 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))
248248 allowOpen()
249249 select {
250250 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 := <-opened245 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)
2828 require.True(t, sess.registerAttachment(second))
2929 require.Equal(t, domain.Geometry{
3030 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}))
3232
3333 require.True(t, sess.unregisterAttachment(second))
3434 require.Equal(t, domain.Geometry{
3535 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}))
3737 }
3838
3939 func TestPreparedLayoutPropagatesGeometryToPTYAndScreenQueries(t *testing.T) {
@@ -45,7 +45,8 @@ func TestPreparedLayoutPropagatesGeometryToPTYAndScreenQueries(t *testing.T) {
4545 plan := preparedTabLayout{members: []resizeMember{{pane: pane, rect: domain.Rect{Width: 39, Height: 23}, geometry: geometry}}}
4646 daemon := &Daemon{log: slog.New(slog.NewTextHandler(io.Discard, nil))}
4747
48- daemon.applyPreparedTabMembers(&plan)
48+ geometryModule := &sharedPTYGeometry{}
49+ geometryModule.applyPreparedTabMembers(daemon, &plan)
4950 require.True(t, commitPreparedTabLayoutLocked(&plan))
5051 require.Equal(t, geometry, pty.geometry)
5152 require.Equal(t, vt.Geometry{
@@ -85,27 +86,27 @@ func TestSizeOnlyGeometryClaimsPreservePixelsAndFallback(t *testing.T) {
8586 require.True(t, sess.registerAttachment(first))
8687 require.True(t, sess.registerAttachment(second))
8788
88- _, claimed := sess.claimGeometryOwnerForSize(second, domain.Size{Cols: 80, Rows: 24})
89+ _, claimed := sess.geometry.claimSize(sess, second, domain.Size{Cols: 80, Rows: 24})
8990 require.True(t, claimed)
9091 require.Equal(t, domain.Geometry{
9192 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}))
9394
9495 require.True(t, sess.unregisterAttachment(second))
9596 require.Equal(t, domain.Geometry{
9697 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}))
9899
99100 partial := &attachedClient{}
100101 partial.setGeometry(domain.Geometry{
101102 Size: domain.Size{Cols: 80, Rows: 24}, PixelWidth: 901,
102103 })
103104 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")
105106 require.True(t, sess.unregisterAttachment(partial))
106107 require.Equal(t, domain.Geometry{
107108 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")
109110 }
110111
111112 func TestSizeOnlyLayoutAndFloatingRetainClaimingPixelGeometry(t *testing.T) {
@@ -119,7 +120,7 @@ func TestSizeOnlyLayoutAndFloatingRetainClaimingPixelGeometry(t *testing.T) {
119120 tb := newTab(layoutPTY, domain.Size{Cols: 80, Rows: 22})
120121 sess.tabs = []*tab{tb}
121122 d := newTestDaemon(t, nil, stubClock{})
122- require.True(t, d.applyTabLayout(sess, tb))
123+ require.True(t, sess.geometry.applyTabLayout(d, sess, tb))
123124 require.Equal(t, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 22}, PixelWidth: 800, PixelHeight: 440}, layoutPTY.geometry)
124125
125126 floatingPTY := &recordingGeometryPTY{}
@@ -129,7 +130,7 @@ func TestSizeOnlyLayoutAndFloatingRetainClaimingPixelGeometry(t *testing.T) {
129130 floating := newPane("floating", floatingPTY, rectSize(floatingGeometry.Inner))
130131 floating.popupGeometry = floatingGeometry
131132 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)
133134 require.True(t, ok)
134135 require.Empty(t, failed)
135136 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 = floatingGeometry131 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
243243 sess.setAttachmentPaneLocked(ac, tb, focused)
244244 sess.mu.Unlock()
245245 if changed {
246- d.applyTabLayout(sess, tb)
246+ sess.geometry.applyTabLayout(d, sess, tb)
247247 }
248248 return changed, focused
249249 }
@@ -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, focused248 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
6868 target.session.mu.Unlock()
6969 d.mu.Unlock()
7070
71- if !d.applyTabLayout(target.session, target.tab) {
71+ if !target.session.geometry.applyTabLayout(d, target.session, target.tab) {
7272 target.tab.mu.Lock()
7373 applied := target.tab.tree == candidate
7474 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 == candidate73 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) {
2626 require.Same(t, second, retired[1].ac)
2727 }
2828
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-
10629 func TestMovePaneCleanupUsesCapturedSourceAttachment(t *testing.T) {
10730 movedPTY, releaseMoved := newBlockingPTY(t)
10831 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) {
110110 sourceLayoutTab = p.source.firstTab()
111111 }
112112 if sourceLayoutTab != nil && sourceLayoutTab != p.destinationTab {
113- d.applyTabLayout(p.source, sourceLayoutTab)
113+ p.source.geometry.applyTabLayout(d, p.source, sourceLayoutTab)
114114 }
115115 }
116- d.applyTabLayout(p.destination, p.destinationTab)
116+ p.destination.geometry.applyTabLayout(d, p.destination, p.destinationTab)
117117 if p.sourceMetadataValid && !p.sourceEmpty {
118118 d.markCatalogueDirty(p.sourceMetadata)
119119 }
@@ -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
212212 })
213213 }
214214 layoutDone := make(chan bool, 1)
215- go func() { layoutDone <- d.applyTabLayout(source, moved) }()
215+ go func() { layoutDone <- source.geometry.applyTabLayout(d, source, moved) }()
216216 <-resizeEntered
217217 reserved := make(chan struct{})
218218 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 <-resizeEntered216 <-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
243243 candidate.source.mu.Unlock()
244244 sess.mu.Unlock()
245245 sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
246- d.applyTabLayout(sess, candidate.target)
246+ sess.geometry.applyTabLayout(d, sess, candidate.target)
247247 return true
248248 }
@@ -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 true247 return true
248 }248 }
M internal/usecase/daemon/nil_pty_test.go +1 -1
@@ -28,7 +28,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
2828 first.bumpLayoutGenerationLocked()
2929 first.mu.Unlock()
3030 require.NotPanics(t, func() {
31- d.applyTabLayout(sess, first)
31+ sess.geometry.applyTabLayout(d, sess, first)
3232 })
3333 require.Equal(t, 20, first.focusedPane().screen.Frame.Width)
3434 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(
8181 tabStableID := tb.stableID
8282 generation := tb.layoutGeneration
8383 tb.mu.Unlock()
84- initialGeometry := sess.paneGeometry(rectSize(newRect))
84+ initialGeometry := sess.geometry.paneGeometry(rectSize(newRect))
8585
8686 paneStableID, err := newStableID("p")
8787 if err != nil {
@@ -123,7 +123,7 @@ func (d *Daemon) spawnPaneOpAt(
123123 tb.bumpLayoutGenerationLocked()
124124 tb.mu.Unlock()
125125
126- d.applyTabLayout(sess, tb)
126+ sess.geometry.applyTabLayout(d, sess, tb)
127127 d.startPaneGoroutines(sess, tb, p)
128128 return paneFocusChange{tab: tb, focused: p, layoutChanged: true}, nil
129129 }
@@ -188,7 +188,7 @@ func (d *Daemon) toggleStackAt(sess *session, tb *tab, target *pane) error {
188188 }
189189 tb.mu.Unlock()
190190 if err == nil {
191- d.applyTabLayout(sess, tb)
191+ sess.geometry.applyTabLayout(d, sess, tb)
192192 }
193193 return err
194194 }
@@ -312,7 +312,7 @@ func (d *Daemon) reapTiledPaneLease(lease paneEffectLease) bool {
312312 _ = d.closeTabLocked(sess, tb, true)
313313 return true
314314 }
315- d.applyTabLayout(sess, tb)
315+ sess.geometry.applyTabLayout(d, sess, tb)
316316 for _, ac := range attachments {
317317 if ac.overlays != nil {
318318 ac.overlays.clearCopyModeForPane(p)
@@ -380,7 +380,7 @@ func (d *Daemon) closePaneLockedWithEffect(sess *session, tb *tab, id layout.Pan
380380 sess.mu.Lock()
381381 sess.invalidateViewsLocked()
382382 sess.mu.Unlock()
383- d.applyTabLayout(sess, tb)
383+ sess.geometry.applyTabLayout(d, sess, tb)
384384
385385 for _, viewer := range attachments {
386386 if viewer.overlays != nil {
@@ -545,7 +545,7 @@ func (d *Daemon) focusDirAt(sess *session, tb *tab, target *pane, dir layout.Dir
545545 }
546546 tb.mu.Unlock()
547547 if change.layoutChanged {
548- d.applyTabLayout(sess, tb)
548+ sess.geometry.applyTabLayout(d, sess, tb)
549549 }
550550 if errors.Is(err, layout.ErrNoPane) {
551551 return change, errNoNeighbor
@@ -81,7 +81,7 @@ func (d *Daemon) spawnPaneOpAt(
81 tabStableID := tb.stableID81 tabStableID := tb.stableID
82 generation := tb.layoutGeneration82 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}, nil128 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 err193 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 true313 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, errNoNeighbor551 return change, errNoNeighbor
M internal/usecase/daemon/pane_actions_test.go +13 -11
@@ -84,10 +84,10 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) {
8484 tb.bumpLayoutGenerationLocked()
8585 tb.mu.Unlock()
8686
87- d.applyTabLayout(sess, tb)
87+ sess.geometry.applyTabLayout(d, sess, tb)
8888 // A second accepted failure joins the existing worker rather than adding
8989 // another timer/goroutine.
90- d.scheduleAcceptedTabLayoutRetry(sess, tb)
90+ sess.geometry.scheduleAcceptedTabLayoutRetry(d, sess, tb)
9191 for range maxAcceptedTabLayoutRetries {
9292 var timer *layoutRetryTimer
9393 for timer == nil {
@@ -123,7 +123,7 @@ func TestLayoutRetryIsBoundedDeduplicatedAndCanceled(t *testing.T) {
123123 tb.size = domain.Size{Cols: 100, Rows: 20}
124124 tb.bumpLayoutGenerationLocked()
125125 tb.mu.Unlock()
126- d.applyTabLayout(sess, tb)
126+ sess.geometry.applyTabLayout(d, sess, tb)
127127 var timer *layoutRetryTimer
128128 for timer == nil {
129129 candidate := <-clock.timers
@@ -177,7 +177,7 @@ func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) {
177177 resized.Do(func() { close(secondResize) })
178178 }
179179 }
180- d.applyTabLayout(sess, tb)
180+ sess.geometry.applyTabLayout(d, sess, tb)
181181
182182 var timer *layoutRetryTimer
183183 for timer == nil {
@@ -271,7 +271,7 @@ func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) {
271271
272272 done := make(chan struct{})
273273 go func() {
274- d.applyTabLayout(sess, tb)
274+ sess.geometry.applyTabLayout(d, sess, tb)
275275 close(done)
276276 }()
277277 awaitSignal(t, entered, "PTY resize did not start")
@@ -303,7 +303,7 @@ func TestLayoutApplicationRejectsStalePaneIdentity(t *testing.T) {
303303
304304 done := make(chan struct{})
305305 go func() {
306- d.applyTabLayout(sess, tb)
306+ sess.geometry.applyTabLayout(d, sess, tb)
307307 close(done)
308308 }()
309309 awaitSignal(t, entered, "stale PTY resize did not start")
@@ -351,7 +351,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
351351 tb.mu.Unlock()
352352
353353 done := make(chan struct{})
354- go func() { d.applyTabLayout(sess, tb); close(done) }()
354+ go func() { sess.geometry.applyTabLayout(d, sess, tb); close(done) }()
355355 awaitSignal(t, entered, "old layout apply did not block")
356356 closed := make(chan error, 1)
357357 go func() { closed <- d.closePane(sess, tb, p2.id, nil, false) }()
@@ -393,7 +393,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
393393 tb.mu.Unlock()
394394
395395 done := make(chan struct{})
396- go func() { d.applyTabLayout(sess, tb); close(done) }()
396+ go func() { sess.geometry.applyTabLayout(d, sess, tb); close(done) }()
397397 awaitSignal(t, entered, "old stack apply did not block")
398398 tb.mu.Lock()
399399 require.True(t, focusPlacementLocked(tb, p2.id))
@@ -422,10 +422,12 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) {
422422 p.rect = domain.Rect{Width: 20, Height: 10}
423423
424424 oldDone := make(chan struct{})
425- go func() { d.applyTabLayout(sess, tb); close(oldDone) }()
425+ go func() { sess.geometry.applyTabLayout(d, sess, tb); close(oldDone) }()
426426 awaitSignal(t, entered, "old size apply did not block")
427427 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+ }()
429431 // Session resize now stages the target privately until every external
430432 // apply validates and the transaction is admitted, so neither the size
431433 // nor pane geometry is visible while the old PTY call is blocked.
@@ -591,7 +593,7 @@ func TestApplyLayoutResizesPTYsAndScreens(t *testing.T) {
591593 oldPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
592594 newPTY.EXPECT().Resize(domain.Geometry{Size: domain.Size{Cols: 20, Rows: 10}}).Return(nil).Once()
593595
594- d.applyTabLayout(sess, tb)
596+ sess.geometry.applyTabLayout(d, sess, tb)
595597
596598 require.Equal(t, 20, tb.panes["pane-1"].screen.Frame.Width)
597599 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 adding88 // 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 *layoutRetryTimer92 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 *layoutRetryTimer127 var timer *layoutRetryTimer
128 for timer == nil {128 for timer == nil {
129 candidate := <-clock.timers129 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 *layoutRetryTimer182 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 external431 // Session resize now stages the target privately until every external
430 // apply validates and the transaction is admitted, so neither the size432 // 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
11321132 require.Len(f.t, placements, paneCount)
11331133 tb.bumpLayoutGenerationLocked()
11341134 tb.mu.Unlock()
1135- _, _ = f.d.applyTabLayoutTransaction(f.sess, tb)
1135+ _, _ = f.sess.geometry.applyTabLayoutTransaction(f.d, f.sess, tb)
11361136 tb.mu.Lock()
11371137 panes := tb.panesSnapshot()
11381138 tb.mu.Unlock()
@@ -1272,7 +1272,7 @@ func (f *performanceFixture) retryLatest() {
12721272 members = append(members, plan.members...)
12731273 }
12741274 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)
12761276 failuresAfter, callsAfter := f.pty.metrics()
12771277 f.ptyFailures += failuresAfter - failuresBefore
12781278 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 - failuresBefore1277 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) {
15911591 t.Run("immediate reset invalidation", func(t *testing.T) {
15921592 d, sess, ac, _, _, invs := newResizeFixture(t)
15931593
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))
15951595 inv := awaitInvalidation(t, invs)
15961596 require.True(t, inv.reset)
15971597 })
@@ -1617,7 +1617,7 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
16171617
16181618 result := make(chan bool, 1)
16191619 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)
16211621 }()
16221622 <-resizeStarted
16231623 require.NotZero(t, rc.recordResizeRequest(domain.Size{Cols: 140, Rows: 30}, ac))
@@ -1630,12 +1630,12 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
16301630 t.Run("async schedule and teardown rejection", func(t *testing.T) {
16311631 d, sess, ac, _, clk, _ := newResizeFixture(t)
16321632
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))
16341634 awaitCoordinatorScheduledTimer(t, clk)
16351635
16361636 // A torn-down coordinator cannot accept a stale attachment's schedule.
16371637 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))
16391639 })
16401640 })
16411641 }
@@ -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 <-resizeStarted1622 <-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 {
9090 // lease binds every delayed resize/retry callback to the exact attachment
9191 // incarnation that published this request.
9292 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
9696 retryAttempts uint8
9797 }
9898
@@ -90,9 +90,9 @@ type resizeRequestMetadata struct {
90 // lease binds every delayed resize/retry callback to the exact attachment90 // 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 *attachmentLease92 lease *attachmentLease
93- geometryClaim uint64 // session geometry claim captured by this request93+ geometryClaim *sharedPTYClaim // immutable Session geometry claim captured by this request
94- epoch uint64 // latest requested epoch94+ epoch uint64 // latest requested epoch
95- committed uint64 // latest published epoch95+ committed uint64 // latest published epoch
96 retryAttempts uint896 retryAttempts uint8
97 }97 }
98 98
M internal/usecase/daemon/render_resize_deadline.go +5 -5
@@ -10,14 +10,14 @@ const maxResizeRetryAttempts = 3
1010 // Resize deadlines and retries retain coordinator-owned attachment, epoch, and
1111 // token validation at every callback effect boundary.
1212 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)
1414 }
1515
1616 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)
1818 }
1919
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 {
2121 c.mu.Lock()
2222 if !c.leaseCurrentLocked(lease, false) || lease.attachment != source {
2323 c.mu.Unlock()
@@ -35,10 +35,10 @@ func (c *renderCoordinator) recordResizeRequestForLeaseWithClaim(size domain.Siz
3535 }
3636
3737 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)
3939 }
4040
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 {
4242 epoch := c.recordResizeRequestForLeaseWithClaim(size, source, lease, geometryClaim)
4343 if epoch == 0 {
4444 return 0
@@ -10,14 +10,14 @@ const maxResizeRetryAttempts = 3
10 // Resize deadlines and retries retain coordinator-owned attachment, epoch, and10 // 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 044 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 {
408408 captured := ac.transportSnapshot().transport
409409 d.mu.Unlock()
410410 if sess != nil {
411- d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
411+ sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go")
412412 }
413413 d.watchParkedTimer(token, rearmed)
414414 if captured != nil {
@@ -557,7 +557,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
557557 if rc := sess.renderCoordinator(); rc != nil {
558558 rc.noteDetach(ac)
559559 }
560- d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
560+ sess.geometry.reconcileAndInvalidate(d, sess, nil, "resume.go")
561561 d.unregisterPreview(ac)
562562 if !d.parkAttachment(sess, ac) {
563563 // 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)
618618 // Resume publication normally schedules reconciliation asynchronously;
619619 // complete the claiming terminal's geometry before the resumed
620620 // attachment is allowed to produce its first frame.
621- d.recalculateSessionGeometry(sess, resumed)
621+ sess.geometry.reconcile(d, sess, resumed)
622622 // A resumed unsupported attachment bypasses finishAttachedClient. Check
623623 // the restored scene at this boundary so suppression is explained once.
624624 d.warnUnsupportedGraphics(resumed)
@@ -408,7 +408,7 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
408 captured := ac.transportSnapshot().transport408 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 so563 // 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 resumed619 // 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. Check622 // 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) {
12171217 tb.panes[closed.id] = closed
12181218 tb.bumpLayoutGenerationLocked()
12191219 tb.mu.Unlock()
1220- d.applyTabLayout(sess, tb)
1220+ sess.geometry.applyTabLayout(d, sess, tb)
12211221 survivor.screen.Write([]byte("survivor"))
12221222 closed.screen.Write([]byte("closed"))
12231223 d.paint(sess, ac, true, nil)
@@ -1217,7 +1217,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
1217 tb.panes[closed.id] = closed1217 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
287287 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable"))
288288 }
289289 tbSize := contentSize(sz)
290- initialPaneGeometry := scalePaneGeometry(geometry, tbSize)
290+ initialPaneGeometry := scaleSharedPTYGeometry(geometry, tbSize)
291291 var names []string
292292 var tabRecords []domain.CatalogueTabRecord
293293 if resuming && len(stopped.tabRecords) != 0 {
@@ -653,10 +653,10 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
653653 sess.mu.Unlock()
654654 tbSize := contentSize(sess.fullViewportSize())
655655 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)
658658 } else if ac != nil {
659- claimGeometry = scalePaneGeometry(ac.geometrySnapshot(), tbSize)
659+ claimGeometry = scaleSharedPTYGeometry(ac.geometrySnapshot(), tbSize)
660660 }
661661 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
662662 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 []string291 var names []string
292 var tabRecords []domain.CatalogueTabRecord292 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 (
1010 // sessionCore is the state shared by every attachment-capable session. It is
1111 // embedded as the first field of session so the existing s.mu selector remains
1212 // 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
1414 // path acquires an architectural lock while holding sizeMu.
1515 type sessionCore struct {
1616 mu sync.Mutex
@@ -25,20 +25,14 @@ type sessionCore struct {
2525 attachments map[*attachedClient]struct{}
2626 attachmentOrder map[*attachedClient]uint64
2727 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]
4236 }
4337
4438 func (s *session) core() *sessionCore {
@@ -10,7 +10,7 @@ import (
10 // sessionCore is the state shared by every attachment-capable session. It is10 // 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 remains11 // 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; no13+// 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.Mutex16 mu sync.Mutex
@@ -25,20 +25,14 @@ type sessionCore struct {
25 attachments map[*attachedClient]struct{}25 attachments map[*attachedClient]struct{}
26 attachmentOrder map[*attachedClient]uint6426 attachmentOrder map[*attachedClient]uint64
27 nextAttachmentID uint6427 nextAttachmentID uint64
28- // geometryOwner is the attachment whose latest attach/resume/resize claim28+ // geometry owns immutable Attachment claims, latest-valid selection, and
29- // controls shared PTY/layout geometry. The pointer is published atomically29+ // 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.Mutex32+ createdAt int64
33- geometryOwner atomic.Pointer[attachedClient]33+ incarnation domain.IncarnationID
34- appliedGeometry domain.Geometry // guarded by geometryMu34+ mruAt atomic.Uint64
35- // geometryClaimSeq is mutated while mu and geometryMu are held; its value35+ 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 @@
11 package daemon
22
3-import "github.com/bnema/vev/internal/domain"
3+import (
4+ "sync"
5+ "sync/atomic"
46
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 {
1015 attachment *attachedClient
1116 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)
13129 }
14130
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
23139 }
140+ claim := g.claimLocked(sess.core(), attachment, geometry)
141+ return claim, claim != nil
142+}
24143
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
33157 }
34- s.mu.Unlock()
158+ delete(g.claims, claim.attachment)
159+ g.latest.Store(g.latestValidClaimLocked(sess.core(), claim.attachment))
160+}
35161
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
38175 }
39- return sessionGeometrySource{attachment: owner, geometry: geometry, claim: claim}, true
176+ return claim, true
40177 }
41178
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{}
48188 }
49- return s.geometryOwner.Load() == attachment && attachment.geometryClaim.Load() == claim
189+ g.mu.Lock()
190+ defer g.mu.Unlock()
191+ return g.applied
50192 }
51193
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()
55203 }
56204
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 {
65210 geometry := domain.Geometry{Size: size}
66211 if !full.Valid() || !size.Valid() || !full.PixelsKnown() {
67212 return geometry
@@ -71,62 +216,51 @@ func scalePaneGeometry(full domain.Geometry, size domain.Size) domain.Geometry {
71216 return geometry.NormalizePixels()
72217 }
73218
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}
78222 }
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}
82226 }
83- return scalePaneGeometry(owner.geometryClaimSnapshot(), size)
227+ return scaleSharedPTYGeometry(claim.geometry, size)
84228 }
85229
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)
90233 if !ok {
91- return domain.Size{}, sessionGeometrySource{}, false
234+ return domain.Size{}, nil, false
92235 }
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
95238 }
96239
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 {
103243 if sess == nil {
104244 return false
105245 }
106246 if source != nil {
107- if _, ok := sess.claimGeometryOwner(source); !ok {
247+ if _, ok := g.claimAttachment(sess, source); !ok {
108248 return false
109249 }
110250 }
111- want, owner, ok := sessionGeometryViewport(sess)
251+ want, claim, ok := g.viewport(sess)
112252 if !ok {
113253 return false
114254 }
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
120258 if !pixelGeometryChanged && contentSize(sess.fullViewportSize()) == contentSize(want) {
121259 return false
122260 }
123- failed, ok := d.applySessionLayout(sess, want, current, nil)
261+ failed, ok := g.applySessionLayout(d, sess, want, current, nil)
124262 if ok {
125- sess.geometryMu.Lock()
126- if current() {
127- sess.appliedGeometry = owner.geometry
128- }
129- sess.geometryMu.Unlock()
263+ g.publishAppliedIfCurrent(claim)
130264 }
131265 if ok && len(failed) != 0 {
132266 seen := make(map[*tab]struct{}, len(failed))
@@ -138,29 +272,25 @@ func (d *Daemon) recalculateSessionGeometry(sess *session, source *attachedClien
138272 continue
139273 }
140274 seen[member.tab] = struct{}{}
141- d.scheduleAcceptedTabLayoutRetry(sess, member.tab)
275+ g.scheduleAcceptedTabLayoutRetry(d, sess, member.tab)
142276 }
143277 }
144278 return ok
145279 }
146280
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) {
153283 return false
154284 }
155285 d.invalidateRender(sess, nil, true, producer)
156286 return true
157287 }
158288
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 {
161291 return
162292 }
163293 d.sessWg.Go(func() {
164- d.recalculateSessionGeometryAndInvalidate(sess, nil, producer)
294+ g.reconcileAndInvalidate(d, sess, nil, producer)
165295 })
166296 }
@@ -1,67 +1,212 @@
1 package daemon1 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 zero8+)
7-// only for legacy/headless test fixtures that construct attachment maps9+
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 *attachedClient15 attachment *attachedClient
11 geometry domain.Geometry16 geometry domain.Geometry
12- claim uint6417+ 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. The131+func (g *sharedPTYGeometry) claimGeometry(sess *session, attachment *attachedClient, geometry domain.Geometry) (*sharedPTYClaim, bool) {
16-// attachment registry publishes geometryOwner under the session lock, and this132+ if g == nil || sess == nil || attachment == nil || !geometry.Valid() {
17-// function reads the owner, claim, and claim size under that same lock. Later133+ return nil, false
18-// revalidation at the layout publication fence is lock-free so a stale claim134+ }
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{}, false138+ 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.Geometry147+func (g *sharedPTYGeometry) release(sess *session, claim *sharedPTYClaim) {
29- var claim uint64148+ 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{}, false163+// 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}, true176+ return claim, true
40 }177 }
41 178
42-// geometryClaimTokenCurrent is deliberately lock-free. applySessionLayout invokes179+// current is deliberately one lock-free immutable-token comparison. Session
43-// its current callback while tab locks are held, so taking session.mu here180+// 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 true184+
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() == claim189+ 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 a205+// scaleSharedPTYGeometry is the sole cell/pixel mapping policy. Integer
58-// pane's cell rectangle. Integer division deliberately truncates: the kernel206+// 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. Pixel208+// claim can be published; live paths call paneGeometry below.
61-// dimensions are deliberately scaled with integer truncation, matching the209+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 geometry212 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 geometry225+ 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 daemon230+// 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{}, false234+ 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, true237+ return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, claim, true
95 }238 }
96 239
97-// recalculateSessionGeometry reconciles shared PTY/layout geometry with the240+// reconcile applies the latest live Attachment claim to shared Session
98-// latest live attachment claim. An explicit source records a new claim before241+// PTY/layout geometry. An explicit source records a new claim first.
99-// the snapshot; attach and resume paths already claimed ownership when they242+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 false244 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 false248 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 false253 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.appliedGeometry257+ 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 false259 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 continue272 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 ok278 return ok
145 }279 }
146 280
147-// recalculateSessionGeometryAndInvalidate publishes a shared render wake after281+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/resize282+ 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 false283 return false
154 }284 }
155 d.invalidateRender(sess, nil, true, producer)285 d.invalidateRender(sess, nil, true, producer)
156 return true286 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 return291 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
9090 paneSize := rectSize(placement.Content)
9191 plan.members = append(plan.members, resizeMember{
9292 session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content,
93- geometry: sess.paneGeometry(paneSize),
93+ geometry: sess.geometry.paneGeometry(paneSize),
9494 })
9595 }
9696 }
@@ -99,7 +99,7 @@ func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) pre
9999
100100 // applyPreparedTabMembers performs only the external PTY phase. A successful
101101 // 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) {
103103 for i := range plan.members {
104104 member := &plan.members[i]
105105 if !member.geometry.Valid() {
@@ -219,7 +219,7 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {
219219 // cancelStalePreparedGates releases gates for members which disappeared from
220220 // the next solved layout (for example a stack member collapsed by a focus
221221 // 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) {
223223 tb.mu.Lock()
224224 latest := prepareTabLayoutLocked(sess, tb)
225225 tb.mu.Unlock()
@@ -241,7 +241,7 @@ func (d *Daemon) cancelStalePreparedGates(sess *session, tb *tab, plan *prepared
241241 }
242242 }
243243
244-func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool) {
244+func (g *sharedPTYGeometry) finishPreparedTabMembers(d *Daemon, plan *preparedTabLayout, accepted bool) {
245245 if !accepted {
246246 // cancelStalePreparedGates owns stale-plan cancellation. Keeping this
247247 // path empty ensures a removed member's pending bytes are replayed once,
@@ -262,11 +262,11 @@ func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool
262262 // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns
263263 // one per-tab single-writer loop, but never holds tab or pane state locks while
264264 // 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...)
267267 }
268268
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) {
270270 if tb == nil {
271271 return nil, false
272272 }
@@ -282,7 +282,7 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
282282 tb.mu.Lock()
283283 plan := prepareTabLayoutLocked(sess, tb)
284284 tb.mu.Unlock()
285- d.applyPreparedTabMembers(&plan)
285+ g.applyPreparedTabMembers(d, &plan)
286286
287287 tb.mu.Lock()
288288 accepted := validatePreparedTabLayoutLocked(tb, &plan)
@@ -293,9 +293,9 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
293293 accepted = commitPreparedTabLayoutLocked(&plan)
294294 }
295295 tb.mu.Unlock()
296- d.finishPreparedTabMembers(&plan, accepted)
296+ g.finishPreparedTabMembers(d, &plan, accepted)
297297 if !accepted {
298- d.cancelStalePreparedGates(sess, tb, &plan)
298+ g.cancelStalePreparedGates(d, sess, tb, &plan)
299299 if len(current) != 0 && !current[0]() {
300300 for i := range plan.members {
301301 member := &plan.members[i]
@@ -324,14 +324,14 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
324324 }
325325 }
326326
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)
329329 if !ok {
330330 return false
331331 }
332332 markSnapshotDirty(sess)
333333 if len(failed) != 0 {
334- d.scheduleAcceptedTabLayoutRetry(sess, tb)
334+ g.scheduleAcceptedTabLayoutRetry(d, sess, tb)
335335 }
336336 return true
337337 }
@@ -341,7 +341,7 @@ const maxAcceptedTabLayoutRetries = 3
341341 // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
342342 // worker is deduplicated, derives cancellation from the tab lifecycle, and
343343 // 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) {
345345 if d.clock == nil || tb == nil || tb.ctx == nil {
346346 return
347347 }
@@ -382,7 +382,7 @@ func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
382382 if ctx.Err() != nil {
383383 return
384384 }
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 })
386386 if !ok || len(failed) == 0 {
387387 return
388388 }
@@ -430,14 +430,14 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo
430430 // used for retryable PTY work. Floating state is not part of a tiled layout
431431 // generation, so its slot generation and exact pane identity validate its
432432 // 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)
435435 }
436436
437437 // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the
438438 // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes,
439439 // 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) {
441441 if tb == nil {
442442 return nil, true
443443 }
@@ -488,10 +488,10 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
488488 ptyRect := geometry.ptyRect()
489489 plan := preparedTabLayout{members: []resizeMember{{
490490 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,
492492 isFloating: true, floatingGeneration: generation,
493493 }}}
494- d.applyPreparedTabMembers(&plan)
494+ g.applyPreparedTabMembers(d, &plan)
495495
496496 // The PTY may have accepted an intermediate size, but a hidden, replaced,
497497 // relaunched, or resized slot must never receive this attempt's geometry.
@@ -531,7 +531,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
531531 return nil, true
532532 }
533533 if plan.members[0].ok {
534- d.finishPreparedTabMembers(&plan, true)
534+ g.finishPreparedTabMembers(d, &plan, true)
535535 return nil, true
536536 }
537537 return plan.members, true
@@ -542,7 +542,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
542542 // stale per-tab retry, no succeeding attempt is owned by this call, so every
543543 // successful external resize must reopen its parser gate at the old screen
544544 // size.
545-func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) {
545+func (g *sharedPTYGeometry) releasePreparedSessionGates(d *Daemon, plans []*preparedTabLayout) {
546546 for _, plan := range plans {
547547 if plan == nil {
548548 continue
@@ -563,7 +563,7 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) {
563563 // transaction: all PTYs are applied and plans validated first, then the
564564 // coordinator admits the epoch before any tab size, rectangle, VT screen,
565565 // 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) {
567567 if sess == nil {
568568 return nil, false
569569 }
@@ -589,7 +589,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
589589 plans = append(plans, &plan)
590590 }
591591 for _, plan := range plans {
592- d.applyPreparedTabMembers(plan)
592+ g.applyPreparedTabMembers(d, plan)
593593 }
594594
595595 // 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
614614 // Claim publication and shared geometry commit use one fence. The hook
615615 // above remains outside the fence so tests and lifecycle callbacks can
616616 // publish a newer claim; the final validation then rejects this stale plan.
617- sess.geometryMu.Lock()
617+ g.mu.Lock()
618618 if valid && sess.ctx != nil && sess.ctx.Err() != nil {
619619 valid = false
620620 }
@@ -633,12 +633,12 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
633633 plan.tab.size = plan.size
634634 }
635635 }
636- sess.geometryMu.Unlock()
636+ g.mu.Unlock()
637637 for i := len(tabs) - 1; i >= 0; i-- {
638638 tabs[i].mu.Unlock()
639639 }
640640 if !valid {
641- d.releasePreparedSessionGates(plans)
641+ g.releasePreparedSessionGates(d, plans)
642642 // Headless requests have no attachment epoch. A live tab mutation
643643 // still invalidates their plan, but must trigger a fresh prepare rather
644644 // than being mistaken for a canceled request.
@@ -652,7 +652,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
652652
653653 failed := make([]resizeMember, 0)
654654 for _, plan := range plans {
655- d.finishPreparedTabMembers(plan, true)
655+ g.finishPreparedTabMembers(d, plan, true)
656656 for _, member := range plan.members {
657657 if !member.ok {
658658 failed = append(failed, member)
@@ -660,7 +660,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
660660 }
661661 }
662662 for _, tb := range tabs {
663- floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tb, current)
663+ floatingFailed, ok := g.applyVisibleFloatingLayout(d, sess, tb, current)
664664 if !ok {
665665 return nil, false
666666 }
@@ -689,16 +689,16 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
689689 }
690690 }
691691
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 {
693693 rc := sess.renderCoordinator()
694694 if rc == nil {
695695 return false
696696 }
697697 snap := rc.resizeSnapshot()
698698 geometryClaim := snap.geometryClaim
699- if geometryClaim == 0 {
699+ if geometryClaim == nil {
700700 var claimed bool
701- geometryClaim, claimed = sess.claimGeometryOwnerForSize(ac, snap.size)
701+ geometryClaim, claimed = g.claimSize(sess, ac, snap.size)
702702 if !claimed {
703703 return false
704704 }
@@ -709,14 +709,14 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
709709 }
710710 defer effect.End()
711711 current := func() bool {
712- return effect.current() && sess.geometryClaimTokenCurrent(ac, geometryClaim) &&
712+ return effect.current() && sess.geometry.current(geometryClaim) &&
713713 rc.resizeCurrentForLease(epoch, ac, lease, false)
714714 }
715715 if !current() {
716716 return false
717717 }
718718 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 {
720720 return rc.resizeCurrentForLease(epoch, ac, lease, true)
721721 })
722722 if !ok {
@@ -737,7 +737,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
737737 return false
738738 }
739739 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) })
741741 }
742742 if !current() {
743743 return false
@@ -762,7 +762,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
762762 // prepared tab transaction. The captured members identify retry candidates
763763 // only: their rectangles must never cross this delayed boundary, because any
764764 // 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) {
766766 rc := sess.renderCoordinator()
767767 if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
768768 return
@@ -811,7 +811,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
811811 // pointers, and solved rectangles, and validates them again before any
812812 // VT/rectangle publication. It also preserves the resize gate and
813813 // 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 {
815815 return rc.retryCurrentForLease(epoch, ac, lease)
816816 })
817817 if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
@@ -856,7 +856,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
856856 if !currentSlot || !retryPending {
857857 continue
858858 }
859- freshFailed, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
859+ freshFailed, ok := g.applyVisibleFloatingLayoutForMember(d, sess, member.tab, func() bool {
860860 return rc.retryCurrentForLease(epoch, ac, lease)
861861 }, &member)
862862 if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
@@ -875,7 +875,7 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
875875 }
876876 }
877877 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) })
879879 }
880880 if succeeded {
881881 // Retry completion changes VT state after the original layout commit.
@@ -885,20 +885,20 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *at
885885 }
886886 }
887887
888-// requestTransactionalResize reports reset invalidation completion for immediate
888+// requestResize reports reset invalidation completion for immediate
889889 // attached requests, and coordinator schedule acceptance for async requests.
890890 // 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 {
892892 if ac == nil {
893- return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)
893+ return g.requestResizeForLease(d, sess, nil, nil, size, immediate)
894894 }
895895 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)
897897 }
898898
899-// requestTransactionalResizeForLease carries the connection's captured lease
899+// requestResizeForLease carries the Connection's captured lease
900900 // 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 {
902902 valid := sess != nil && size.Valid()
903903 d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)
904904 if !valid {
@@ -907,28 +907,28 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC
907907 if ac == nil {
908908 // Headless geometry has no coordinator/transport to coalesce, but keeps
909909 // the same prepare/apply/commit ordering.
910- _, ok := d.applySessionLayout(sess, size, nil, nil)
910+ _, ok := g.applySessionLayout(d, sess, size, nil, nil)
911911 return ok
912912 }
913913 rc := sess.renderCoordinator()
914914 if rc == nil || !rc.leaseCurrent(lease, true) {
915915 return false
916916 }
917- geometryClaim, claimed := sess.claimGeometryOwnerForSize(ac, size)
917+ geometryClaim, claimed := g.claimSize(sess, ac, size)
918918 if !claimed {
919919 return false
920920 }
921921 if immediate {
922922 epoch := rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim)
923923 if epoch == 0 {
924- sess.releaseGeometryClaim(ac, geometryClaim)
924+ g.release(sess, geometryClaim)
925925 return false
926926 }
927- return d.runResizeTransaction(sess, ac, lease, epoch)
927+ return g.runResizeTransaction(d, sess, ac, lease, epoch)
928928 }
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) })
930930 if epoch == 0 {
931- sess.releaseGeometryClaim(ac, geometryClaim)
931+ g.release(sess, geometryClaim)
932932 }
933933 return epoch != 0
934934 }
@@ -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 successful100 // 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 from219 // cancelStalePreparedGates releases gates for members which disappeared from
220 // the next solved layout (for example a stack member collapsed by a focus220 // 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 this246 // 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 owns262 // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns
263 // one per-tab single-writer loop, but never holds tab or pane state locks while263 // 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, false271 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 false330 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 true336 return true
337 }337 }
@@ -341,7 +341,7 @@ const maxAcceptedTabLayoutRetries = 3
341 // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The341 // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
342 // worker is deduplicated, derives cancellation from the tab lifecycle, and342 // 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 return346 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 return383 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 return387 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 layout430 // 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 its431 // 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 the437 // 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, true442 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, true531 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, true535 return nil, true
536 }536 }
537 return plan.members, true537 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 every542 // 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 screen543 // 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 continue548 continue
@@ -563,7 +563,7 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) {
563 // transaction: all PTYs are applied and plans validated first, then the563 // 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, false568 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 all595 // 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 hook614 // Claim publication and shared geometry commit use one fence. The hook
615 // above remains outside the fence so tests and lifecycle callbacks can615 // 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 = false619 valid = false
620 }620 }
@@ -633,12 +633,12 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
633 plan.tab.size = plan.size633 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 mutation642 // Headless requests have no attachment epoch. A live tab mutation
643 // still invalidates their plan, but must trigger a fresh prepare rather643 // 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, false665 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 false695 return false
696 }696 }
697 snap := rc.resizeSnapshot()697 snap := rc.resizeSnapshot()
698 geometryClaim := snap.geometryClaim698 geometryClaim := snap.geometryClaim
699- if geometryClaim == 0 {699+ if geometryClaim == nil {
700 var claimed bool700 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 false703 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 false716 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 false737 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 false743 return false
@@ -762,7 +762,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
762 // prepared tab transaction. The captured members identify retry candidates762 // prepared tab transaction. The captured members identify retry candidates
763 // only: their rectangles must never cross this delayed boundary, because any763 // 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 return768 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 any811 // pointers, and solved rectangles, and validates them again before any
812 // VT/rectangle publication. It also preserves the resize gate and812 // 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 continue857 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 immediate888+// 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 lease899+// 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 keeps908 // 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 ok911 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 false915 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 false919 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 false925 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 != 0933 return epoch != 0
934 }934 }
M internal/usecase/daemon/transactional_resize_test.go +25 -21
@@ -210,7 +210,7 @@ func TestApplySessionLayoutStopsRetryWhenSessionCanceled(t *testing.T) {
210210 tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
211211 d.beforeSessionResizePublication = sess.cancel
212212
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)
214214
215215 require.False(t, ok)
216216 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) {
272272 }
273273 d, sess, _, _ := newManualSessionWithPTYs(t, first, second)
274274
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)
276276
277277 require.Equal(t, tc.wantOK, ok)
278278 require.Len(t, failed, tc.wantFailed)
@@ -334,10 +334,10 @@ func TestTransactionalResizeRetryMarksNamedSnapshotDirtyOnlyOnSuccess(t *testing
334334 sess.snapEligible.Store(true)
335335 tb, p := testAttachmentTab(sess), testAttachmentTab(sess).focusedPane()
336336
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))
338338 rc := sess.renderCoordinator()
339339 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}})
341341
342342 sess.snapshotMu.Lock()
343343 generation := sess.snapshotGeneration
@@ -432,7 +432,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
432432 require.NotZero(t, newer)
433433 }
434434
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))
436436 d.beforeSessionResizePublication = nil
437437 require.False(t, sess.snapDirty.Load(), "rejected epoch must not dirty the snapshot")
438438 for _, tb := range sess.tabs {
@@ -442,7 +442,7 @@ func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing
442442 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")
443443 }
444444
445- require.True(t, d.runResizeTransaction(sess, ac, lease, newer))
445+ require.True(t, sess.geometry.runResizeTransaction(d, sess, ac, lease, newer))
446446 for _, tb := range sess.tabs {
447447 require.Equal(t, domain.Size{Cols: 120, Rows: 32}, tb.size)
448448 p := tb.focusedPane()
@@ -487,7 +487,7 @@ func TestTransactionalResizeRechecksLeaseAtAttachmentPublication(t *testing.T) {
487487 <-release
488488 }
489489 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) }()
491491 <-entered
492492 newer := rc.recordResizeRequestForLease(domain.Size{Cols: 120, Rows: 34}, ac, lease)
493493 require.NotZero(t, newer)
@@ -514,11 +514,11 @@ func TestStaleRemovedMemberGateIsCanceledOnceByFreshPlan(t *testing.T) {
514514 tb.bumpLayoutGenerationLocked()
515515 tb.mu.Unlock()
516516
517- d.finishPreparedTabMembers(&plan, false)
517+ sess.geometry.finishPreparedTabMembers(d, &plan, false)
518518 p.mu.Lock()
519519 require.True(t, p.resizeApplying, "stale finalization must leave cancellation to the fresh-plan path")
520520 p.mu.Unlock()
521- d.cancelStalePreparedGates(sess, tb, &plan)
521+ sess.geometry.cancelStalePreparedGates(d, sess, tb, &plan)
522522 p.mu.Lock()
523523 require.False(t, p.resizeApplying)
524524 require.Empty(t, p.resizePending)
@@ -538,7 +538,7 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
538538 })
539539 }
540540
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))
542542 require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 100, Rows: 28}}, pty.requested())
543543 tb.mu.Lock()
544544 require.Equal(t, domain.Size{Cols: 100, Rows: 28}, tb.size)
@@ -558,7 +558,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
558558 rc := d.attachCoordinator(source, nil, ac, true)
559559 lease := rc.attachmentLease(ac)
560560 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))
562562
563563 destination := &session{sessionCore: sessionCore{id: "destination", name: "destination"}, ctx: source.ctx, tabs: []*tab{tb}}
564564 publishPaneOwner(popup, destination, tb, 7)
@@ -572,7 +572,7 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
572572 source.snapshotMu.Unlock()
573573 requested := popupPTY.requested()
574574
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}})
576576
577577 require.Equal(t, requested, popupPTY.requested(), "stale floating retry reached the moved PTY")
578578 popup.mu.Lock()
@@ -598,7 +598,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
598598 t.Run(tc.name, func(t *testing.T) {
599599 d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
600600 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))
602602
603603 tb := testAttachmentTab(sess)
604604 fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg)
@@ -615,7 +615,7 @@ func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
615615 installTestFloating(tb, popup, true)
616616
617617 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) }()
619619 <-entered
620620 popup.mu.Lock()
621621 require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode")
@@ -664,7 +664,8 @@ func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(
664664 }
665665 }
666666
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)
668669
669670 require.True(t, ok)
670671 require.Len(t, failed, 1)
@@ -704,7 +705,8 @@ func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t *
704705 d := newTestDaemon(t, nil, stubClock{})
705706 d.ApplyConfig(domain.Config{Floating: cfg})
706707
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)
708710
709711 require.True(t, ok)
710712 require.Empty(t, failed)
@@ -730,7 +732,8 @@ func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) {
730732 d.ApplyConfig(domain.Config{Floating: cfg})
731733 calls := 0
732734
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 {
734737 calls++
735738 return calls == 1
736739 })
@@ -759,7 +762,8 @@ func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t
759762 d := newTestDaemon(t, nil, stubClock{})
760763 d.ApplyConfig(domain.Config{Floating: cfg})
761764
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 })
763767
764768 require.False(t, ok)
765769 require.Empty(t, failed)
@@ -784,11 +788,11 @@ func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T)
784788 lease := rc.attachmentLease(ac)
785789 epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 80, Rows: 24}, ac, lease)
786790 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))
788792 first := popupPTY.requested()
789793 require.Len(t, first, 1)
790794
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}})
792796 retried := popupPTY.requested()
793797 require.Len(t, retried, 2)
794798 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)
802806 tb.mu.Lock()
803807 tb.floating = floatingSlot{state: floatingVisible, pane: replacement, generation: 8}
804808 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}})
806810 require.Empty(t, replacementPTY.requested(), "a replaced floating slot must not inherit an old retry")
807811 }
808812
@@ -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.cancel211 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.snapshotGeneration343 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 = nil436 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 <-release487 <-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 <-entered491 <-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 <-entered619 <-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 := 0733 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 == 1738 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close