detached ae173e8

fix(daemon): make latest attachment resize authoritative

commit
ae173e84fd33f28ea406ab7602c4ba775a5a07f1
author
brice · · 2026-08-04 15:53 UTC
committer
brice · · 2026-08-04 17:25 UTC
parent
183b9e4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
30 files · +653 -76
M AGENTS.md +1 -1
@@ -77,5 +77,5 @@ Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/
7777 - Each connection has a 15-second handshake budget from connect through the initial committed publication.
7878 - Local and remote attach use the same `Hello`/`Welcome` session protocol, then pump `Input`/`Resize` and `Output` frames over a byte-only Transport.
7979 - Remote attach opens a direct connection to the selected daemon over UDP by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.
80-- A session owns shared PTYs, VT state, tabs, panes, fixed PTY content size, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle.
80+- A session owns shared PTYs, VT state, tabs, panes, PTY content geometry selected by the latest valid attachment claim, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle; when the latest claimant detaches, the most recently claimed remaining attachment becomes authoritative.
8181 - Command requests have a 10-second result deadline and are tracked per connection.
@@ -77,5 +77,5 @@ Wire payload types/codecs live in `internal/ports/frame.go` and `internal/ports/
77 - Each connection has a 15-second handshake budget from connect through the initial committed publication.77 - Each connection has a 15-second handshake budget from connect through the initial committed publication.
78 - Local and remote attach use the same `Hello`/`Welcome` session protocol, then pump `Input`/`Resize` and `Output` frames over a byte-only Transport.78 - Local and remote attach use the same `Hello`/`Welcome` session protocol, then pump `Input`/`Resize` and `Output` frames over a byte-only Transport.
79 - Remote attach opens a direct connection to the selected daemon over UDP by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.79 - Remote attach opens a direct connection to the selected daemon over UDP by default; `VEV_REMOTE_TRANSPORT=stdio` explicitly selects an SSH-only carriage.
80-- A session owns shared PTYs, VT state, tabs, panes, fixed PTY content size, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle.80+- A session owns shared PTYs, VT state, tabs, panes, PTY content geometry selected by the latest valid attachment claim, and ordered mutations. Each attachment owns its window/view, copy and overlay state, rendering/output state, and reconnect lifecycle; when the latest claimant detaches, the most recently claimed remaining attachment becomes authoritative.
81 - Command requests have a 10-second result deadline and are tracked per connection.81 - Command requests have a 10-second result deadline and are tracked per connection.
M README.md +1 -1
@@ -51,7 +51,7 @@ vev kill <name> kill a session (--all kills everything)
5151
5252 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.
5353
54-A session may have multiple attachments. The session owns the PTYs, VT state, tabs, panes, fixed PTY content size, and ordered mutations. Each attachment owns its window, view, copy mode, overlays, rendering/output state, and reconnect lifecycle, so attachments can focus different tabs or panes without changing one another's view. The first valid attachment establishes the session's PTY content size; later window resizes affect only that attachment.
54+A session may have multiple attachments. The session owns the PTYs, VT state, tabs, panes, shared PTY content geometry, and ordered mutations. Each attachment owns its window, view, copy mode, overlays, rendering/output state, and reconnect lifecycle, so attachments can focus different tabs or panes without changing one another's view. The latest valid, non-superseded attachment attach, resume, or resize claim controls shared PTY geometry; detaching that attachment falls back to the most recently claimed remaining valid attachment while preserving every attachment's own outer window.
5555
5656 ## Keys
5757
@@ -51,7 +51,7 @@ vev kill <name> kill a session (--all kills everything)
51 51
52 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.52 The daemon starts on first use and exits with the last session. Ephemeral sessions are numbered, survive detach, and disappear with the daemon. Named sessions persist across daemon restarts and come back with their layout, recovered terminal transcript, and allowlisted processes.
53 53
54-A session may have multiple attachments. The session owns the PTYs, VT state, tabs, panes, fixed PTY content size, and ordered mutations. Each attachment owns its window, view, copy mode, overlays, rendering/output state, and reconnect lifecycle, so attachments can focus different tabs or panes without changing one another's view. The first valid attachment establishes the session's PTY content size; later window resizes affect only that attachment.54+A session may have multiple attachments. The session owns the PTYs, VT state, tabs, panes, shared PTY content geometry, and ordered mutations. Each attachment owns its window, view, copy mode, overlays, rendering/output state, and reconnect lifecycle, so attachments can focus different tabs or panes without changing one another's view. The latest valid, non-superseded attachment attach, resume, or resize claim controls shared PTY geometry; detaching that attachment falls back to the most recently claimed remaining valid attachment while preserving every attachment's own outer window.
55 55
56 ## Keys56 ## Keys
57 57
M docs/remote-resilience.md +7 -4
@@ -7,10 +7,11 @@ SSH-only carriage explicitly when UDP is unavailable. vev must be installed on
77 the remote host.
88
99 The session remains shared across all attachments. It owns PTYs, VT state, tabs,
10-panes, a fixed PTY content size, and ordered mutations. Each attachment keeps
10+panes, shared PTY content geometry, and ordered mutations. Each attachment keeps
1111 its own window size, selected tab and pane, copy mode, overlays, render/output
1212 acknowledgements, and reconnect state. Reconnecting one attachment resumes that
13-attachment without replacing or changing its peers.
13+attachment without replacing its peers or changing their attachment-local
14+windows/views; its latest claim may still update shared PTY geometry.
1415
1516 ## Connection limits
1617
@@ -20,8 +21,10 @@ attachment without replacing or changing its peers.
2021 - Each command request has a 10-second deadline for its result. A timeout
2122 abandons only that request; it does not complete a later request with a stale
2223 result.
23-- The first valid attachment establishes the session's PTY content size. A
24- later attachment can resize its own window without resizing shared PTYs.
24+- Shared PTY content geometry follows the latest valid, non-superseded attach,
25+ resume, or resize claim. A later attachment can therefore replace the current
26+ shared geometry; if the winning attachment detaches, the most recently
27+ claimed remaining valid attachment becomes authoritative.
2528
2629 ## UDP operation
2730
@@ -7,10 +7,11 @@ SSH-only carriage explicitly when UDP is unavailable. vev must be installed on
7 the remote host.7 the remote host.
8 8
9 The session remains shared across all attachments. It owns PTYs, VT state, tabs,9 The session remains shared across all attachments. It owns PTYs, VT state, tabs,
10-panes, a fixed PTY content size, and ordered mutations. Each attachment keeps10+panes, shared PTY content geometry, and ordered mutations. Each attachment keeps
11 its own window size, selected tab and pane, copy mode, overlays, render/output11 its own window size, selected tab and pane, copy mode, overlays, render/output
12 acknowledgements, and reconnect state. Reconnecting one attachment resumes that12 acknowledgements, and reconnect state. Reconnecting one attachment resumes that
13-attachment without replacing or changing its peers.13+attachment without replacing its peers or changing their attachment-local
14+windows/views; its latest claim may still update shared PTY geometry.
14 15
15 ## Connection limits16 ## Connection limits
16 17
@@ -20,8 +21,10 @@ attachment without replacing or changing its peers.
20 - Each command request has a 10-second deadline for its result. A timeout21 - Each command request has a 10-second deadline for its result. A timeout
21 abandons only that request; it does not complete a later request with a stale22 abandons only that request; it does not complete a later request with a stale
22 result.23 result.
23-- The first valid attachment establishes the session's PTY content size. A24+- Shared PTY content geometry follows the latest valid, non-superseded attach,
24- later attachment can resize its own window without resizing shared PTYs.25+ resume, or resize claim. A later attachment can therefore replace the current
26+ shared geometry; if the winning attachment detaches, the most recently
27+ claimed remaining valid attachment becomes authoritative.
25 28
26 ## UDP operation29 ## UDP operation
27 30
M docs/terminal.md +6 -3
@@ -16,9 +16,12 @@ vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode,
1616
1717 ## Session content size
1818
19-The first valid attachment establishes the session's fixed PTY content size. Each
20-attachment may have a different outer window and viewport; resizing it changes
21-only its attachment-local presentation and does not resize shared PTYs.
19+The session's PTY content size follows the latest valid attach, resume, or
20+resize claim. Invalid or stale claims do not change shared PTY geometry. Each
21+attachment may have a different outer window and viewport; the winning claim
22+changes shared PTYs while preserving the other attachments' own presentations.
23+If the winning attachment detaches, the most recently claimed remaining valid
24+attachment becomes authoritative.
2225
2326 ## Recovery
2427
@@ -16,9 +16,12 @@ vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode,
16 16
17 ## Session content size17 ## Session content size
18 18
19-The first valid attachment establishes the session's fixed PTY content size. Each19+The session's PTY content size follows the latest valid attach, resume, or
20-attachment may have a different outer window and viewport; resizing it changes20+resize claim. Invalid or stale claims do not change shared PTY geometry. Each
21-only its attachment-local presentation and does not resize shared PTYs.21+attachment may have a different outer window and viewport; the winning claim
22+changes shared PTYs while preserving the other attachments' own presentations.
23+If the winning attachment detaches, the most recently claimed remaining valid
24+attachment becomes authoritative.
22 25
23 ## Recovery26 ## Recovery
24 27
M internal/app/final_acceptance_test.go +3 -2
@@ -110,8 +110,9 @@ func TestAcceptanceTwoLocalAttachmentsKeepViewsOverTransports(t *testing.T) {
110110 awaitText(t, secondPump, size, "[SCROLL]")
111111 assertNoTextAfterInput(t, firstPump, size, "[SCROLL]")
112112
113- // A resize and an output reset are scoped to one attachment output stream;
114- // the shared tab's PTY/content geometry and the peer stream remain intact.
113+ // The resize keeps its attachment-local output stream while recalculating
114+ // shared PTY/content geometry from the latest valid attachment claim; the
115+ // peer stream remains independent.
115116 resizePayload, err := ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}})
116117 require.NoError(t, err)
117118 require.NoError(t, first.Send(ports.Frame{Type: ports.MsgResize, Payload: resizePayload}))
@@ -110,8 +110,9 @@ func TestAcceptanceTwoLocalAttachmentsKeepViewsOverTransports(t *testing.T) {
110 awaitText(t, secondPump, size, "[SCROLL]")110 awaitText(t, secondPump, size, "[SCROLL]")
111 assertNoTextAfterInput(t, firstPump, size, "[SCROLL]")111 assertNoTextAfterInput(t, firstPump, size, "[SCROLL]")
112 112
113- // A resize and an output reset are scoped to one attachment output stream;113+ // The resize keeps its attachment-local output stream while recalculating
114- // the shared tab's PTY/content geometry and the peer stream remain intact.114+ // shared PTY/content geometry from the latest valid attachment claim; the
115+ // peer stream remains independent.
115 resizePayload, err := ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}})116 resizePayload, err := ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}})
116 require.NoError(t, err)117 require.NoError(t, err)
117 require.NoError(t, first.Send(ports.Frame{Type: ports.MsgResize, Payload: resizePayload}))118 require.NoError(t, first.Send(ports.Frame{Type: ports.MsgResize, Payload: resizePayload}))
M internal/app/remote_final_acceptance_test.go +6 -0
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/vev/internal/adapters/sshstdio"
1515 "github.com/bnema/vev/internal/domain"
1616 "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1718 "github.com/bnema/vev/internal/usecase/client"
1819 "github.com/stretchr/testify/require"
1920 )
@@ -114,9 +115,14 @@ func (*acceptanceRemoteTerminal) Flush() error { return nil
114115 func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
115116 outputs := make(chan string, 4)
116117 factory := &acceptanceRemoteFactory{outputs: outputs}
118+ hostStore := portsmocks.NewMockRemoteHostStore(t)
119+ for _, host := range []string{"direct.example", "picker.example", "picked.example"} {
120+ hostStore.EXPECT().Remember(host).Return(nil).Once()
121+ }
117122 localCalls := 0
118123 terminals := make([]*acceptanceRemoteTerminal, 0, 3)
119124 deps := runAttachDeps{
125+ hostStore: hostStore,
120126 localDialer: func() ports.Dialer {
121127 localCalls++
122128 return acceptanceRemoteDialer{}
@@ -14,6 +14,7 @@ import (
14 "github.com/bnema/vev/internal/adapters/sshstdio"14 "github.com/bnema/vev/internal/adapters/sshstdio"
15 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 "github.com/bnema/vev/internal/usecase/client"18 "github.com/bnema/vev/internal/usecase/client"
18 "github.com/stretchr/testify/require"19 "github.com/stretchr/testify/require"
19 )20 )
@@ -114,9 +115,14 @@ func (*acceptanceRemoteTerminal) Flush() error { return nil
114 func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {115 func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
115 outputs := make(chan string, 4)116 outputs := make(chan string, 4)
116 factory := &acceptanceRemoteFactory{outputs: outputs}117 factory := &acceptanceRemoteFactory{outputs: outputs}
118+ hostStore := portsmocks.NewMockRemoteHostStore(t)
119+ for _, host := range []string{"direct.example", "picker.example", "picked.example"} {
120+ hostStore.EXPECT().Remember(host).Return(nil).Once()
121+ }
117 localCalls := 0122 localCalls := 0
118 terminals := make([]*acceptanceRemoteTerminal, 0, 3)123 terminals := make([]*acceptanceRemoteTerminal, 0, 3)
119 deps := runAttachDeps{124 deps := runAttachDeps{
125+ hostStore: hostStore,
120 localDialer: func() ports.Dialer {126 localDialer: func() ports.Dialer {
121 localCalls++127 localCalls++
122 return acceptanceRemoteDialer{}128 return acceptanceRemoteDialer{}
M internal/usecase/daemon/attachment_registry.go +101 -0
@@ -108,9 +108,66 @@ func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool {
108108 c.nextAttachmentID++
109109 c.attachments[ac] = struct{}{}
110110 c.attachmentOrder[ac] = c.nextAttachmentID
111+ if c.claimGeometryOwnerLocked(ac, ac.sizeSnapshot()) == 0 {
112+ c.refreshGeometryOwnerLocked()
113+ }
111114 return true
112115 }
113116
117+// latestValidGeometryOwnerLocked selects the most recent valid attachment,
118+// excluding one claimant when a rejected request is being released. Caller
119+// holds the owning session's mu.
120+func (c *sessionCore) latestValidGeometryOwnerLocked(exclude *attachedClient) *attachedClient {
121+ var owner *attachedClient
122+ var ownerClaim, ownerOrder uint64
123+ for candidate := range c.attachments {
124+ if candidate == nil || candidate == exclude || !candidate.sizeSnapshot().Valid() || !candidate.geometryClaimSizeSnapshot().Valid() {
125+ continue
126+ }
127+ claim := candidate.geometryClaim.Load()
128+ order := c.attachmentOrder[candidate]
129+ if owner == nil || claim > ownerClaim || (claim == ownerClaim && order > ownerOrder) {
130+ owner = candidate
131+ ownerClaim = claim
132+ ownerOrder = order
133+ }
134+ }
135+ return owner
136+}
137+
138+// refreshGeometryOwnerLocked repairs a removed or invalid owner without
139+// inventing a newer claim. Caller holds the owning session's mu.
140+func (c *sessionCore) refreshGeometryOwnerLocked() {
141+ if c == nil {
142+ return
143+ }
144+ owner := c.geometryOwner.Load()
145+ if owner != nil {
146+ if _, ok := c.attachments[owner]; ok && owner.sizeSnapshot().Valid() && owner.geometryClaimSizeSnapshot().Valid() {
147+ return
148+ }
149+ }
150+ c.geometryMu.Lock()
151+ c.geometryOwner.Store(c.latestValidGeometryOwnerLocked(nil))
152+ c.geometryMu.Unlock()
153+}
154+
155+// claimGeometryOwnerLocked records the latest attachment claim. Caller holds
156+// the owning session's mu; the atomic publication lets resize transactions
157+// validate the claim while tab locks are held.
158+func (c *sessionCore) claimGeometryOwnerLocked(ac *attachedClient, size domain.Size) uint64 {
159+ if c == nil || ac == nil || !size.Valid() {
160+ return 0
161+ }
162+ c.geometryMu.Lock()
163+ defer c.geometryMu.Unlock()
164+ c.geometryClaimSeq++
165+ claim := c.geometryClaimSeq
166+ ac.publishGeometryClaim(size, claim)
167+ c.geometryOwner.Store(ac)
168+ return claim
169+}
170+
114171 func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
115172 if entry == nil || entry.core() == nil {
116173 return false
@@ -132,6 +189,49 @@ func (s *session) registerAttachment(ac *attachedClient) bool {
132189 return s.registerAttachmentLocked(ac)
133190 }
134191
192+// claimGeometryOwner records an explicit resize claim after the attachment
193+// has already published its new local size. It is also used by callers that
194+// bypass the wire resize path.
195+func (s *session) claimGeometryOwner(ac *attachedClient) (uint64, bool) {
196+ if ac == nil {
197+ return 0, false
198+ }
199+ return s.claimGeometryOwnerForSize(ac, ac.sizeSnapshot())
200+}
201+
202+// claimGeometryOwnerForSize records a requested shared geometry independently
203+// of the attachment-local publication. Transactional resize commits use this
204+// before the PTY phase so a stale request cannot publish after a newer claim.
205+func (s *session) claimGeometryOwnerForSize(ac *attachedClient, size domain.Size) (uint64, bool) {
206+ if s == nil || ac == nil || !size.Valid() {
207+ return 0, false
208+ }
209+ s.mu.Lock()
210+ defer s.mu.Unlock()
211+ if _, ok := s.attachments[ac]; !ok {
212+ return 0, false
213+ }
214+ return s.claimGeometryOwnerLocked(ac, size), true
215+}
216+
217+// releaseGeometryClaim drops a claim that never reached the coordinator's
218+// resize request. It never creates a new claim; an older valid peer may become
219+// the source again, or the session retains its headless geometry.
220+func (s *session) releaseGeometryClaim(ac *attachedClient, claim uint64) {
221+ if s == nil || ac == nil || claim == 0 {
222+ return
223+ }
224+ s.mu.Lock()
225+ defer s.mu.Unlock()
226+ if s.geometryOwner.Load() != ac || ac.geometryClaim.Load() != claim {
227+ return
228+ }
229+ ac.clearGeometryClaim()
230+ s.geometryMu.Lock()
231+ s.geometryOwner.Store(s.latestValidGeometryOwnerLocked(ac))
232+ s.geometryMu.Unlock()
233+}
234+
135235 // unregisterAttachmentLocked removes only ac and leaves every other
136236 // attachment's view intact. Caller holds s.mu.
137237 func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool {
@@ -150,6 +250,7 @@ func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
150250 }
151251 delete(c.attachments, ac)
152252 delete(c.attachmentOrder, ac)
253+ c.refreshGeometryOwnerLocked()
153254 return true
154255 }
155256
@@ -108,9 +108,66 @@ func (c *sessionCore) registerAttachmentLocked(ac *attachedClient) bool {
108 c.nextAttachmentID++108 c.nextAttachmentID++
109 c.attachments[ac] = struct{}{}109 c.attachments[ac] = struct{}{}
110 c.attachmentOrder[ac] = c.nextAttachmentID110 c.attachmentOrder[ac] = c.nextAttachmentID
111+ if c.claimGeometryOwnerLocked(ac, ac.sizeSnapshot()) == 0 {
112+ c.refreshGeometryOwnerLocked()
113+ }
111 return true114 return true
112 }115 }
113 116
117+// latestValidGeometryOwnerLocked selects the most recent valid attachment,
118+// excluding one claimant when a rejected request is being released. Caller
119+// holds the owning session's mu.
120+func (c *sessionCore) latestValidGeometryOwnerLocked(exclude *attachedClient) *attachedClient {
121+ var owner *attachedClient
122+ var ownerClaim, ownerOrder uint64
123+ for candidate := range c.attachments {
124+ if candidate == nil || candidate == exclude || !candidate.sizeSnapshot().Valid() || !candidate.geometryClaimSizeSnapshot().Valid() {
125+ continue
126+ }
127+ claim := candidate.geometryClaim.Load()
128+ order := c.attachmentOrder[candidate]
129+ if owner == nil || claim > ownerClaim || (claim == ownerClaim && order > ownerOrder) {
130+ owner = candidate
131+ ownerClaim = claim
132+ ownerOrder = order
133+ }
134+ }
135+ return owner
136+}
137+
138+// refreshGeometryOwnerLocked repairs a removed or invalid owner without
139+// inventing a newer claim. Caller holds the owning session's mu.
140+func (c *sessionCore) refreshGeometryOwnerLocked() {
141+ if c == nil {
142+ return
143+ }
144+ owner := c.geometryOwner.Load()
145+ if owner != nil {
146+ if _, ok := c.attachments[owner]; ok && owner.sizeSnapshot().Valid() && owner.geometryClaimSizeSnapshot().Valid() {
147+ return
148+ }
149+ }
150+ c.geometryMu.Lock()
151+ c.geometryOwner.Store(c.latestValidGeometryOwnerLocked(nil))
152+ c.geometryMu.Unlock()
153+}
154+
155+// claimGeometryOwnerLocked records the latest attachment claim. Caller holds
156+// the owning session's mu; the atomic publication lets resize transactions
157+// validate the claim while tab locks are held.
158+func (c *sessionCore) claimGeometryOwnerLocked(ac *attachedClient, size domain.Size) uint64 {
159+ if c == nil || ac == nil || !size.Valid() {
160+ return 0
161+ }
162+ c.geometryMu.Lock()
163+ defer c.geometryMu.Unlock()
164+ c.geometryClaimSeq++
165+ claim := c.geometryClaimSeq
166+ ac.publishGeometryClaim(size, claim)
167+ c.geometryOwner.Store(ac)
168+ return claim
169+}
170+
114 func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool {171 func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool {
115 if entry == nil || entry.core() == nil {172 if entry == nil || entry.core() == nil {
116 return false173 return false
@@ -132,6 +189,49 @@ func (s *session) registerAttachment(ac *attachedClient) bool {
132 return s.registerAttachmentLocked(ac)189 return s.registerAttachmentLocked(ac)
133 }190 }
134 191
192+// claimGeometryOwner records an explicit resize claim after the attachment
193+// has already published its new local size. It is also used by callers that
194+// bypass the wire resize path.
195+func (s *session) claimGeometryOwner(ac *attachedClient) (uint64, bool) {
196+ if ac == nil {
197+ return 0, false
198+ }
199+ return s.claimGeometryOwnerForSize(ac, ac.sizeSnapshot())
200+}
201+
202+// claimGeometryOwnerForSize records a requested shared geometry independently
203+// of the attachment-local publication. Transactional resize commits use this
204+// before the PTY phase so a stale request cannot publish after a newer claim.
205+func (s *session) claimGeometryOwnerForSize(ac *attachedClient, size domain.Size) (uint64, bool) {
206+ if s == nil || ac == nil || !size.Valid() {
207+ return 0, false
208+ }
209+ s.mu.Lock()
210+ defer s.mu.Unlock()
211+ if _, ok := s.attachments[ac]; !ok {
212+ return 0, false
213+ }
214+ return s.claimGeometryOwnerLocked(ac, size), true
215+}
216+
217+// releaseGeometryClaim drops a claim that never reached the coordinator's
218+// resize request. It never creates a new claim; an older valid peer may become
219+// the source again, or the session retains its headless geometry.
220+func (s *session) releaseGeometryClaim(ac *attachedClient, claim uint64) {
221+ if s == nil || ac == nil || claim == 0 {
222+ return
223+ }
224+ s.mu.Lock()
225+ defer s.mu.Unlock()
226+ if s.geometryOwner.Load() != ac || ac.geometryClaim.Load() != claim {
227+ return
228+ }
229+ ac.clearGeometryClaim()
230+ s.geometryMu.Lock()
231+ s.geometryOwner.Store(s.latestValidGeometryOwnerLocked(ac))
232+ s.geometryMu.Unlock()
233+}
234+
135 // unregisterAttachmentLocked removes only ac and leaves every other235 // unregisterAttachmentLocked removes only ac and leaves every other
136 // attachment's view intact. Caller holds s.mu.236 // attachment's view intact. Caller holds s.mu.
137 func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool {237 func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool {
@@ -150,6 +250,7 @@ func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool {
150 }250 }
151 delete(c.attachments, ac)251 delete(c.attachments, ac)
152 delete(c.attachmentOrder, ac)252 delete(c.attachmentOrder, ac)
253+ c.refreshGeometryOwnerLocked()
153 return true254 return true
154 }255 }
155 256
M internal/usecase/daemon/attachment_render_test.go +148 -5
@@ -97,7 +97,7 @@ func TestStaleCapacityReadinessRequeuesWithoutAnotherInvalidation(t *testing.T)
9797 require.Equal(t, int32(2), probes.Load(), "retry must not require another external wake")
9898 }
9999
100-func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
100+func TestAttachmentResizeKeepsPeerWindowAndExpandsSharedContent(t *testing.T) {
101101 pty, releasePTY := newBlockingPTY(t)
102102 defer releasePTY()
103103 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
@@ -113,11 +113,9 @@ func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
113113 Size: domain.Size{Cols: 100, Rows: 40}, ClientID: [16]byte{2},
114114 }, secondTransport)
115115 require.NoError(t, err)
116+ require.True(t, sess.repairAttachmentView(second))
116117
117118 tb := sess.tabs[0]
118- tb.mu.Lock()
119- contentBefore := tb.size
120- tb.mu.Unlock()
121119 firstSize := first.size
122120 secondRevision := second.viewSnapshot().revision
123121 secondEpoch := second.output.currentEpoch()
@@ -134,7 +132,7 @@ func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
134132 ticket.End()
135133
136134 tb.mu.Lock()
137- require.Equal(t, contentBefore, tb.size)
135+ require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size)
138136 tb.mu.Unlock()
139137 require.Equal(t, firstSize, first.size)
140138 require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size)
@@ -254,3 +252,148 @@ func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) {
254252 }
255253 close(firstTransport.release)
256254 }
255+
256+func TestAttachmentResizeUsesLatestClaimedSessionGeometry(t *testing.T) {
257+ pty, releasePTY := newBlockingPTY(t)
258+ defer releasePTY()
259+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
260+ firstTransport, _ := newCapturingTransport(t)
261+ sess, first, err := d.route(ports.Hello{
262+ Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work",
263+ Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1},
264+ }, firstTransport)
265+ require.NoError(t, err)
266+ secondTransport, _ := newCapturingTransport(t)
267+ _, second, err := d.route(ports.Hello{
268+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work",
269+ Size: domain.Size{Cols: 100, Rows: 40}, ClientID: [16]byte{2},
270+ }, secondTransport)
271+ require.NoError(t, err)
272+ d.firstPaint(sess, second)
273+
274+ tb := sess.tabs[0]
275+ tb.mu.Lock()
276+ require.Equal(t, domain.Size{Cols: 100, Rows: 38}, tb.size, "the newest attachment claim must win")
277+ tb.mu.Unlock()
278+
279+ rc := sess.renderCoordinator()
280+ firstLease := rc.attachmentLease(first)
281+ secondLease := rc.attachmentLease(second)
282+ require.True(t, rc.markAttachmentReady(firstLease))
283+ require.True(t, rc.markAttachmentReady(secondLease))
284+ resize := func(ac *attachedClient, tr ports.Transport, size domain.Size) {
285+ token := sess.attachmentToken(ac, tr)
286+ d.handleAttachmentClientFrame(token, ports.Frame{
287+ Type: ports.MsgResize,
288+ Payload: mustMarshalResize(ports.Resize{Size: size}),
289+ })
290+ }
291+ resize(second, secondTransport, domain.Size{Cols: 120, Rows: 50})
292+
293+ tb.mu.Lock()
294+ require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size)
295+ tb.mu.Unlock()
296+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size)
297+ require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size)
298+
299+ // A same-size resize is still a claim, so the smaller peer can take
300+ // authority without changing its local window state first.
301+ resize(second, secondTransport, domain.Size{Cols: 90, Rows: 30})
302+ resize(first, firstTransport, domain.Size{Cols: 80, Rows: 24})
303+ tb.mu.Lock()
304+ require.Equal(t, domain.Size{Cols: 80, Rows: 22}, tb.size)
305+ tb.mu.Unlock()
306+
307+ // The latest resize wins even when it is smaller than a live peer.
308+ resize(first, firstTransport, domain.Size{Cols: 70, Rows: 20})
309+ tb.mu.Lock()
310+ require.Equal(t, domain.Size{Cols: 70, Rows: 18}, tb.size)
311+ tb.mu.Unlock()
312+ require.Equal(t, domain.Size{Cols: 70, Rows: 20}, first.size)
313+ require.Equal(t, domain.Size{Cols: 90, Rows: 30}, second.size)
314+
315+ // Detaching the latest claimant falls back to the most recent remaining
316+ // attachment claim rather than reverting to the historical maximum.
317+ d.clientGone(sess, first, first.transport(), true)
318+ tb.mu.Lock()
319+ require.Equal(t, domain.Size{Cols: 90, Rows: 28}, tb.size)
320+ tb.mu.Unlock()
321+}
322+
323+func TestStaleAttachmentGeometryClaimCannotCommit(t *testing.T) {
324+ d, sess, first, _ := newManualSessionWithPTYs(t, newQuietPTY())
325+ second := &attachedClient{output: newOutputStateStream()}
326+ second.setSize(domain.Size{Cols: 90, Rows: 30})
327+ second.setSession(sess)
328+ require.True(t, sess.registerAttachment(second))
329+
330+ first.setSize(domain.Size{Cols: 120, Rows: 40})
331+ var superseded bool
332+ d.beforeSessionResizePublication = func() {
333+ if superseded {
334+ return
335+ }
336+ superseded = true
337+ sess.claimGeometryOwner(second)
338+ }
339+ require.False(t, d.recalculateSessionGeometry(sess, first), "a newer attachment claim must reject the stale layout commit")
340+
341+ tb := sess.tabs[0]
342+ tb.mu.Lock()
343+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size)
344+ tb.mu.Unlock()
345+
346+ d.beforeSessionResizePublication = nil
347+ require.True(t, d.recalculateSessionGeometry(sess, nil))
348+ tb.mu.Lock()
349+ require.Equal(t, domain.Size{Cols: 90, Rows: 28}, tb.size)
350+ tb.mu.Unlock()
351+}
352+
353+func TestAttachmentMoveReconcilesSourceGeometryAfterOwnerRemoval(t *testing.T) {
354+ d, source, moved, _ := newManualSessionWithPTYs(t, newQuietPTY())
355+ peer := &attachedClient{output: newOutputStateStream()}
356+ peer.setSize(domain.Size{Cols: 80, Rows: 24})
357+ peer.setSession(source)
358+ require.True(t, source.registerAttachment(peer))
359+
360+ moved.setSize(domain.Size{Cols: 120, Rows: 40})
361+ _, claimed := source.claimGeometryOwner(moved)
362+ require.True(t, claimed)
363+ require.True(t, d.recalculateSessionGeometry(source, nil))
364+
365+ target := &session{
366+ sessionCore: sessionCore{id: "target-geometry", name: "target-geometry"},
367+ ctx: source.ctx,
368+ cancel: func() {},
369+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
370+ }
371+ d.mu.Lock()
372+ d.sessions[target.id] = target
373+ d.mu.Unlock()
374+
375+ result, err := d.transitionAttachment(attachmentTransitionRequest{
376+ source: source,
377+ target: target,
378+ next: moved,
379+ expectedTransport: moved.transportSnapshot(),
380+ ready: true,
381+ })
382+ require.NoError(t, err)
383+ d.deferAttachmentTransitionCleanups(result)
384+ d.firstPaint(target, moved)
385+
386+ target.mu.Lock()
387+ targetTab := target.tabs[0]
388+ target.mu.Unlock()
389+ targetTab.mu.Lock()
390+ require.Equal(t, domain.Size{Cols: 120, Rows: 38}, targetTab.size, "target geometry must use the moved attachment claim")
391+ targetTab.mu.Unlock()
392+
393+ tb := source.tabs[0]
394+ tb.mu.Lock()
395+ require.Equal(t, domain.Size{Cols: 80, Rows: 22}, tb.size, "source geometry must fall back after a moved owner leaves")
396+ tb.mu.Unlock()
397+ require.Same(t, source, peer.currentSession())
398+ require.Same(t, target, moved.currentSession())
399+}
@@ -97,7 +97,7 @@ func TestStaleCapacityReadinessRequeuesWithoutAnotherInvalidation(t *testing.T)
97 require.Equal(t, int32(2), probes.Load(), "retry must not require another external wake")97 require.Equal(t, int32(2), probes.Load(), "retry must not require another external wake")
98 }98 }
99 99
100-func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {100+func TestAttachmentResizeKeepsPeerWindowAndExpandsSharedContent(t *testing.T) {
101 pty, releasePTY := newBlockingPTY(t)101 pty, releasePTY := newBlockingPTY(t)
102 defer releasePTY()102 defer releasePTY()
103 d := newTestDaemon(t, newFactory(t, pty), stubClock{})103 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
@@ -113,11 +113,9 @@ func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
113 Size: domain.Size{Cols: 100, Rows: 40}, ClientID: [16]byte{2},113 Size: domain.Size{Cols: 100, Rows: 40}, ClientID: [16]byte{2},
114 }, secondTransport)114 }, secondTransport)
115 require.NoError(t, err)115 require.NoError(t, err)
116+ require.True(t, sess.repairAttachmentView(second))
116 117
117 tb := sess.tabs[0]118 tb := sess.tabs[0]
118- tb.mu.Lock()
119- contentBefore := tb.size
120- tb.mu.Unlock()
121 firstSize := first.size119 firstSize := first.size
122 secondRevision := second.viewSnapshot().revision120 secondRevision := second.viewSnapshot().revision
123 secondEpoch := second.output.currentEpoch()121 secondEpoch := second.output.currentEpoch()
@@ -134,7 +132,7 @@ func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
134 ticket.End()132 ticket.End()
135 133
136 tb.mu.Lock()134 tb.mu.Lock()
137- require.Equal(t, contentBefore, tb.size)135+ require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size)
138 tb.mu.Unlock()136 tb.mu.Unlock()
139 require.Equal(t, firstSize, first.size)137 require.Equal(t, firstSize, first.size)
140 require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size)138 require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size)
@@ -254,3 +252,148 @@ func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) {
254 }252 }
255 close(firstTransport.release)253 close(firstTransport.release)
256 }254 }
255+
256+func TestAttachmentResizeUsesLatestClaimedSessionGeometry(t *testing.T) {
257+ pty, releasePTY := newBlockingPTY(t)
258+ defer releasePTY()
259+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
260+ firstTransport, _ := newCapturingTransport(t)
261+ sess, first, err := d.route(ports.Hello{
262+ Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work",
263+ Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1},
264+ }, firstTransport)
265+ require.NoError(t, err)
266+ secondTransport, _ := newCapturingTransport(t)
267+ _, second, err := d.route(ports.Hello{
268+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work",
269+ Size: domain.Size{Cols: 100, Rows: 40}, ClientID: [16]byte{2},
270+ }, secondTransport)
271+ require.NoError(t, err)
272+ d.firstPaint(sess, second)
273+
274+ tb := sess.tabs[0]
275+ tb.mu.Lock()
276+ require.Equal(t, domain.Size{Cols: 100, Rows: 38}, tb.size, "the newest attachment claim must win")
277+ tb.mu.Unlock()
278+
279+ rc := sess.renderCoordinator()
280+ firstLease := rc.attachmentLease(first)
281+ secondLease := rc.attachmentLease(second)
282+ require.True(t, rc.markAttachmentReady(firstLease))
283+ require.True(t, rc.markAttachmentReady(secondLease))
284+ resize := func(ac *attachedClient, tr ports.Transport, size domain.Size) {
285+ token := sess.attachmentToken(ac, tr)
286+ d.handleAttachmentClientFrame(token, ports.Frame{
287+ Type: ports.MsgResize,
288+ Payload: mustMarshalResize(ports.Resize{Size: size}),
289+ })
290+ }
291+ resize(second, secondTransport, domain.Size{Cols: 120, Rows: 50})
292+
293+ tb.mu.Lock()
294+ require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size)
295+ tb.mu.Unlock()
296+ require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size)
297+ require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size)
298+
299+ // A same-size resize is still a claim, so the smaller peer can take
300+ // authority without changing its local window state first.
301+ resize(second, secondTransport, domain.Size{Cols: 90, Rows: 30})
302+ resize(first, firstTransport, domain.Size{Cols: 80, Rows: 24})
303+ tb.mu.Lock()
304+ require.Equal(t, domain.Size{Cols: 80, Rows: 22}, tb.size)
305+ tb.mu.Unlock()
306+
307+ // The latest resize wins even when it is smaller than a live peer.
308+ resize(first, firstTransport, domain.Size{Cols: 70, Rows: 20})
309+ tb.mu.Lock()
310+ require.Equal(t, domain.Size{Cols: 70, Rows: 18}, tb.size)
311+ tb.mu.Unlock()
312+ require.Equal(t, domain.Size{Cols: 70, Rows: 20}, first.size)
313+ require.Equal(t, domain.Size{Cols: 90, Rows: 30}, second.size)
314+
315+ // Detaching the latest claimant falls back to the most recent remaining
316+ // attachment claim rather than reverting to the historical maximum.
317+ d.clientGone(sess, first, first.transport(), true)
318+ tb.mu.Lock()
319+ require.Equal(t, domain.Size{Cols: 90, Rows: 28}, tb.size)
320+ tb.mu.Unlock()
321+}
322+
323+func TestStaleAttachmentGeometryClaimCannotCommit(t *testing.T) {
324+ d, sess, first, _ := newManualSessionWithPTYs(t, newQuietPTY())
325+ second := &attachedClient{output: newOutputStateStream()}
326+ second.setSize(domain.Size{Cols: 90, Rows: 30})
327+ second.setSession(sess)
328+ require.True(t, sess.registerAttachment(second))
329+
330+ first.setSize(domain.Size{Cols: 120, Rows: 40})
331+ var superseded bool
332+ d.beforeSessionResizePublication = func() {
333+ if superseded {
334+ return
335+ }
336+ superseded = true
337+ sess.claimGeometryOwner(second)
338+ }
339+ require.False(t, d.recalculateSessionGeometry(sess, first), "a newer attachment claim must reject the stale layout commit")
340+
341+ tb := sess.tabs[0]
342+ tb.mu.Lock()
343+ require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size)
344+ tb.mu.Unlock()
345+
346+ d.beforeSessionResizePublication = nil
347+ require.True(t, d.recalculateSessionGeometry(sess, nil))
348+ tb.mu.Lock()
349+ require.Equal(t, domain.Size{Cols: 90, Rows: 28}, tb.size)
350+ tb.mu.Unlock()
351+}
352+
353+func TestAttachmentMoveReconcilesSourceGeometryAfterOwnerRemoval(t *testing.T) {
354+ d, source, moved, _ := newManualSessionWithPTYs(t, newQuietPTY())
355+ peer := &attachedClient{output: newOutputStateStream()}
356+ peer.setSize(domain.Size{Cols: 80, Rows: 24})
357+ peer.setSession(source)
358+ require.True(t, source.registerAttachment(peer))
359+
360+ moved.setSize(domain.Size{Cols: 120, Rows: 40})
361+ _, claimed := source.claimGeometryOwner(moved)
362+ require.True(t, claimed)
363+ require.True(t, d.recalculateSessionGeometry(source, nil))
364+
365+ target := &session{
366+ sessionCore: sessionCore{id: "target-geometry", name: "target-geometry"},
367+ ctx: source.ctx,
368+ cancel: func() {},
369+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
370+ }
371+ d.mu.Lock()
372+ d.sessions[target.id] = target
373+ d.mu.Unlock()
374+
375+ result, err := d.transitionAttachment(attachmentTransitionRequest{
376+ source: source,
377+ target: target,
378+ next: moved,
379+ expectedTransport: moved.transportSnapshot(),
380+ ready: true,
381+ })
382+ require.NoError(t, err)
383+ d.deferAttachmentTransitionCleanups(result)
384+ d.firstPaint(target, moved)
385+
386+ target.mu.Lock()
387+ targetTab := target.tabs[0]
388+ target.mu.Unlock()
389+ targetTab.mu.Lock()
390+ require.Equal(t, domain.Size{Cols: 120, Rows: 38}, targetTab.size, "target geometry must use the moved attachment claim")
391+ targetTab.mu.Unlock()
392+
393+ tb := source.tabs[0]
394+ tb.mu.Lock()
395+ require.Equal(t, domain.Size{Cols: 80, Rows: 22}, tb.size, "source geometry must fall back after a moved owner leaves")
396+ tb.mu.Unlock()
397+ require.Same(t, source, peer.currentSession())
398+ require.Same(t, target, moved.currentSession())
399+}
M internal/usecase/daemon/attachment_transition.go +14 -3
@@ -70,8 +70,9 @@ func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source
7070 }
7171
7272 type attachmentTransitionResult struct {
73- published attachmentConnectionToken
74- cleanups []renderLifecycleCleanup
73+ published attachmentConnectionToken
74+ cleanups []renderLifecycleCleanup
75+ sourceGeometrySession *session
7576 }
7677
7778 type attachmentTransitionParticipants struct {
@@ -146,7 +147,14 @@ func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (a
146147 return attachmentTransitionResult{}, err
147148 }
148149 }
149- return d.transitionAttachmentLocked(req)
150+ result, err := d.transitionAttachmentLocked(req)
151+ if err != nil {
152+ return result, err
153+ }
154+ if req.source != nil && req.target != nil && req.source != req.target {
155+ result.sourceGeometrySession = req.source
156+ }
157+ return result, nil
150158 }
151159
152160 func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
@@ -168,6 +176,9 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
168176 if err != nil {
169177 return result, err
170178 }
179+ if result.sourceGeometrySession != nil {
180+ d.recalculateSessionGeometryAndInvalidate(result.sourceGeometrySession, nil, "attachment_transition.go")
181+ }
171182 return result, nil
172183 }
173184
@@ -70,8 +70,9 @@ func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source
70 }70 }
71 71
72 type attachmentTransitionResult struct {72 type attachmentTransitionResult struct {
73- published attachmentConnectionToken73+ published attachmentConnectionToken
74- cleanups []renderLifecycleCleanup74+ cleanups []renderLifecycleCleanup
75+ sourceGeometrySession *session
75 }76 }
76 77
77 type attachmentTransitionParticipants struct {78 type attachmentTransitionParticipants struct {
@@ -146,7 +147,14 @@ func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (a
146 return attachmentTransitionResult{}, err147 return attachmentTransitionResult{}, err
147 }148 }
148 }149 }
149- return d.transitionAttachmentLocked(req)150+ result, err := d.transitionAttachmentLocked(req)
151+ if err != nil {
152+ return result, err
153+ }
154+ if req.source != nil && req.target != nil && req.source != req.target {
155+ result.sourceGeometrySession = req.source
156+ }
157+ return result, nil
150 }158 }
151 159
152 func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) {160 func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) {
@@ -168,6 +176,9 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme
168 if err != nil {176 if err != nil {
169 return result, err177 return result, err
170 }178 }
179+ if result.sourceGeometrySession != nil {
180+ d.recalculateSessionGeometryAndInvalidate(result.sourceGeometrySession, nil, "attachment_transition.go")
181+ }
171 return result, nil182 return result, nil
172 }183 }
173 184
M internal/usecase/daemon/attachment_transition_publish.go +6 -1
@@ -190,7 +190,12 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
190190 if publication.source != req.target {
191191 unregisterAttachmentSessionLocked(publication.source, req.next)
192192 }
193- registerAttachmentSessionLocked(req.target, req.next)
193+ registered := registerAttachmentSessionLocked(req.target, req.next)
194+ if !registered && publication.source == req.target && !req.preserveAttachment {
195+ // A same-session rebind keeps membership but still represents a fresh
196+ // attachment claim with the replacement transport's current size.
197+ req.target.claimGeometryOwnerLocked(req.next, req.next.sizeSnapshot())
198+ }
194199 publication.nextGeneration = req.next.connectionGeneration.Add(1)
195200 req.next.setSession(req.target)
196201 }
@@ -190,7 +190,12 @@ func publishAttachmentOwnershipLocked(publication *attachmentPublication) {
190 if publication.source != req.target {190 if publication.source != req.target {
191 unregisterAttachmentSessionLocked(publication.source, req.next)191 unregisterAttachmentSessionLocked(publication.source, req.next)
192 }192 }
193- registerAttachmentSessionLocked(req.target, req.next)193+ registered := registerAttachmentSessionLocked(req.target, req.next)
194+ if !registered && publication.source == req.target && !req.preserveAttachment {
195+ // A same-session rebind keeps membership but still represents a fresh
196+ // attachment claim with the replacement transport's current size.
197+ req.target.claimGeometryOwnerLocked(req.next, req.next.sizeSnapshot())
198+ }
194 publication.nextGeneration = req.next.connectionGeneration.Add(1)199 publication.nextGeneration = req.next.connectionGeneration.Add(1)
195 req.next.setSession(req.target)200 req.next.setSession(req.target)
196 }201 }
M internal/usecase/daemon/client.go +108 -26
@@ -66,7 +66,16 @@ type attachedClient struct {
6666 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
6767 // snapshots cannot leak when an attachment switches tabs or sessions.
6868 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
69- size domain.Size
69+ // sizeMu lets shared-geometry snapshots read attachment windows without
70+ // waiting behind a blocked transport send.
71+ sizeMu sync.RWMutex
72+ size domain.Size
73+ geometryClaimSize domain.Size // protected by sizeMu; shared geometry claim snapshot
74+ geometryClaimInitialized bool // protected by sizeMu; distinguishes legacy fixtures
75+ // geometryClaim is an attachment-local monotonic claim sequence. The
76+ // owning session publishes it whenever this attachment is attached,
77+ // resumed, or explicitly resized.
78+ geometryClaim atomic.Uint64
7079 keys *keys.Router
7180 // view is attachment-local navigation state. It is never inferred from a
7281 // session-wide active tab, so multiple attachments can observe different
@@ -141,6 +150,62 @@ func (ac *attachedClient) initOverlays() {
141150 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
142151 }
143152
153+func (ac *attachedClient) sizeSnapshot() domain.Size {
154+ if ac == nil {
155+ return domain.Size{}
156+ }
157+ ac.sizeMu.RLock()
158+ defer ac.sizeMu.RUnlock()
159+ return ac.size
160+}
161+
162+func (ac *attachedClient) setSize(size domain.Size) {
163+ if ac == nil {
164+ return
165+ }
166+ ac.sizeMu.Lock()
167+ ac.size = size
168+ ac.sizeMu.Unlock()
169+}
170+
171+func (ac *attachedClient) geometryClaimSizeSnapshot() domain.Size {
172+ if ac == nil {
173+ return domain.Size{}
174+ }
175+ ac.sizeMu.RLock()
176+ claimSize, localSize := ac.geometryClaimSize, ac.size
177+ initialized := ac.geometryClaimInitialized
178+ ac.sizeMu.RUnlock()
179+ if !initialized {
180+ return localSize
181+ }
182+ return claimSize
183+}
184+
185+// publishGeometryClaim makes the claim size and sequence visible as one
186+// attachment-local publication under sizeMu.
187+func (ac *attachedClient) publishGeometryClaim(size domain.Size, claim uint64) {
188+ if ac == nil {
189+ return
190+ }
191+ ac.sizeMu.Lock()
192+ ac.geometryClaimSize = size
193+ ac.geometryClaimInitialized = true
194+ ac.geometryClaim.Store(claim)
195+ ac.sizeMu.Unlock()
196+}
197+
198+func (ac *attachedClient) clearGeometryClaim() {
199+ if ac == nil {
200+ return
201+ }
202+ ac.sizeMu.Lock()
203+ ac.geometryClaimSize = domain.Size{}
204+ ac.geometryClaimInitialized = true
205+ ac.geometryClaim.Store(0)
206+ ac.sizeMu.Unlock()
207+}
208+
144209 func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() }
145210
146211 // currentSession narrows the attachment to its owning local session.
@@ -692,18 +757,18 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
692757 if token.sess == nil {
693758 return false
694759 }
695- d.firstPaintWithLease(token.sess, token.ac, token.ac.size, token.lease)
760+ d.firstPaintWithLease(token.sess, token.ac, token.lease, true)
696761 return true
697762 }
698763
699764 // firstPaint guarantees the freshly attached client sees the full screen. The
700-// session content geometry is fixed by the first valid Hello; an attachment's
701-// window is rendered without resizing shared tabs or PTYs.
702-func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
703- d.firstPaintWithLease(sess, ac, clientSize, nil)
765+// shared session geometry is reconciled to the latest attachment claim before
766+// each attachment gets its own outer-window paint.
767+func (d *Daemon) firstPaint(sess *session, ac *attachedClient) {
768+ d.firstPaintWithLease(sess, ac, nil, false)
704769 }
705770
706-func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, _ domain.Size, lease *attachmentLease) {
771+func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, lease *attachmentLease, asyncGeometry bool) {
707772 // Global notices raised while nothing was attached surface on this client.
708773 // Drained before the early return below so a session without an active tab
709774 // cannot swallow the queue.
@@ -712,9 +777,17 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, _ domain
712777 if tb == nil {
713778 return
714779 }
780+ if asyncGeometry {
781+ // PTY.Resize is an external, non-cancelable operation. Do not hold the
782+ // handshake on it; the first frame can use retained geometry and the
783+ // shared invalidation will repaint every attachment after the claim wins.
784+ d.recalculateSessionGeometryAndInvalidateAsync(sess, "client.go")
785+ } else {
786+ d.recalculateSessionGeometryAndInvalidate(sess, nil, "client.go")
787+ }
715788 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
716- // Activation still performs attachment-local warmup, but it must not turn a
717- // new window into a shared session resize.
789+ // Floating activation may perform its own pane-local warmup after the shared
790+ // session geometry has been reconciled above.
718791 d.ensureFloatingWarm(sess, tb)
719792 if lease == nil {
720793 d.invalidateRenderNow(sess, ac, true, "client.go")
@@ -773,10 +846,10 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
773846 d.requestTransactionalResize(sess, ac, sz, false)
774847 }
775848
776-// resizeAttachmentForLease changes only the sender's window/view. Session tab
777-// geometry and PTY sizes are deliberately outside this mutation boundary.
778-// Callers hold the attachment effect admission; output state is rebased before
779-// the targeted redraw so every subsequent frame starts a new epoch.
849+// resizeAttachmentForLease updates the sender's window/view, then makes that
850+// attachment the latest shared tab/PTY geometry claimant. Callers hold the
851+// attachment effect admission; output state is rebased before any redraw so
852+// every subsequent frame starts a new epoch.
780853 func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool {
781854 if token.ac == nil || !size.Valid() || !token.attachmentEffectCurrent() {
782855 return false
@@ -787,26 +860,35 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
787860 ac.sendMu.Unlock()
788861 return false
789862 }
790- if ac.size == size {
791- ac.sendMu.Unlock()
792- return false
863+ sameSize := ac.sizeSnapshot() == size
864+ if !sameSize {
865+ ac.setSize(size)
866+ view := ac.viewSnapshot()
867+ view.windowRows = size.Rows
868+ view.windowSet = true
869+ view.windowTop = 0
870+ view.revision++
871+ ac.publishView(view)
872+ ac.rebaseOutput()
873+ ac.pipelineCache = composeCacheInput{}
874+ ac.pipelineScratch = composeCacheInput{}
793875 }
794- ac.size = size
795- view := ac.viewSnapshot()
796- view.windowRows = size.Rows
797- view.windowSet = true
798- view.windowTop = 0
799- view.revision++
800- ac.publishView(view)
801- ac.rebaseOutput()
802- ac.pipelineCache = composeCacheInput{}
803- ac.pipelineScratch = composeCacheInput{}
804876 ac.sendMu.Unlock()
805877
806878 sess := token.sess
807879 if sess == nil {
808880 return false
809881 }
882+ // Shared PTY/layout geometry follows the latest accepted attachment claim.
883+ // When this resize changes canonical geometry, the shared invalidation fans
884+ // out a fresh frame to every attachment; otherwise only this attachment
885+ // needs a viewport redraw.
886+ if d.recalculateSessionGeometryAndInvalidate(sess, ac, "client.go") {
887+ return true
888+ }
889+ if sameSize {
890+ return false
891+ }
810892 rc := sess.renderCoordinator()
811893 if rc == nil {
812894 return false
@@ -66,7 +66,16 @@ type attachedClient struct {
66 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so66 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
67 // snapshots cannot leak when an attachment switches tabs or sessions.67 // snapshots cannot leak when an attachment switches tabs or sessions.
68 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held68 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
69- size domain.Size69+ // sizeMu lets shared-geometry snapshots read attachment windows without
70+ // waiting behind a blocked transport send.
71+ sizeMu sync.RWMutex
72+ size domain.Size
73+ geometryClaimSize domain.Size // protected by sizeMu; shared geometry claim snapshot
74+ geometryClaimInitialized bool // protected by sizeMu; distinguishes legacy fixtures
75+ // geometryClaim is an attachment-local monotonic claim sequence. The
76+ // owning session publishes it whenever this attachment is attached,
77+ // resumed, or explicitly resized.
78+ geometryClaim atomic.Uint64
70 keys *keys.Router79 keys *keys.Router
71 // view is attachment-local navigation state. It is never inferred from a80 // view is attachment-local navigation state. It is never inferred from a
72 // session-wide active tab, so multiple attachments can observe different81 // session-wide active tab, so multiple attachments can observe different
@@ -141,6 +150,62 @@ func (ac *attachedClient) initOverlays() {
141 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })150 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
142 }151 }
143 152
153+func (ac *attachedClient) sizeSnapshot() domain.Size {
154+ if ac == nil {
155+ return domain.Size{}
156+ }
157+ ac.sizeMu.RLock()
158+ defer ac.sizeMu.RUnlock()
159+ return ac.size
160+}
161+
162+func (ac *attachedClient) setSize(size domain.Size) {
163+ if ac == nil {
164+ return
165+ }
166+ ac.sizeMu.Lock()
167+ ac.size = size
168+ ac.sizeMu.Unlock()
169+}
170+
171+func (ac *attachedClient) geometryClaimSizeSnapshot() domain.Size {
172+ if ac == nil {
173+ return domain.Size{}
174+ }
175+ ac.sizeMu.RLock()
176+ claimSize, localSize := ac.geometryClaimSize, ac.size
177+ initialized := ac.geometryClaimInitialized
178+ ac.sizeMu.RUnlock()
179+ if !initialized {
180+ return localSize
181+ }
182+ return claimSize
183+}
184+
185+// publishGeometryClaim makes the claim size and sequence visible as one
186+// attachment-local publication under sizeMu.
187+func (ac *attachedClient) publishGeometryClaim(size domain.Size, claim uint64) {
188+ if ac == nil {
189+ return
190+ }
191+ ac.sizeMu.Lock()
192+ ac.geometryClaimSize = size
193+ ac.geometryClaimInitialized = true
194+ ac.geometryClaim.Store(claim)
195+ ac.sizeMu.Unlock()
196+}
197+
198+func (ac *attachedClient) clearGeometryClaim() {
199+ if ac == nil {
200+ return
201+ }
202+ ac.sizeMu.Lock()
203+ ac.geometryClaimSize = domain.Size{}
204+ ac.geometryClaimInitialized = true
205+ ac.geometryClaim.Store(0)
206+ ac.sizeMu.Unlock()
207+}
208+
144 func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() }209 func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() }
145 210
146 // currentSession narrows the attachment to its owning local session.211 // currentSession narrows the attachment to its owning local session.
@@ -692,18 +757,18 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
692 if token.sess == nil {757 if token.sess == nil {
693 return false758 return false
694 }759 }
695- d.firstPaintWithLease(token.sess, token.ac, token.ac.size, token.lease)760+ d.firstPaintWithLease(token.sess, token.ac, token.lease, true)
696 return true761 return true
697 }762 }
698 763
699 // firstPaint guarantees the freshly attached client sees the full screen. The764 // firstPaint guarantees the freshly attached client sees the full screen. The
700-// session content geometry is fixed by the first valid Hello; an attachment's765+// shared session geometry is reconciled to the latest attachment claim before
701-// window is rendered without resizing shared tabs or PTYs.766+// each attachment gets its own outer-window paint.
702-func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {767+func (d *Daemon) firstPaint(sess *session, ac *attachedClient) {
703- d.firstPaintWithLease(sess, ac, clientSize, nil)768+ d.firstPaintWithLease(sess, ac, nil, false)
704 }769 }
705 770
706-func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, _ domain.Size, lease *attachmentLease) {771+func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, lease *attachmentLease, asyncGeometry bool) {
707 // Global notices raised while nothing was attached surface on this client.772 // Global notices raised while nothing was attached surface on this client.
708 // Drained before the early return below so a session without an active tab773 // Drained before the early return below so a session without an active tab
709 // cannot swallow the queue.774 // cannot swallow the queue.
@@ -712,9 +777,17 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, _ domain
712 if tb == nil {777 if tb == nil {
713 return778 return
714 }779 }
780+ if asyncGeometry {
781+ // PTY.Resize is an external, non-cancelable operation. Do not hold the
782+ // handshake on it; the first frame can use retained geometry and the
783+ // shared invalidation will repaint every attachment after the claim wins.
784+ d.recalculateSessionGeometryAndInvalidateAsync(sess, "client.go")
785+ } else {
786+ d.recalculateSessionGeometryAndInvalidate(sess, nil, "client.go")
787+ }
715 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)788 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
716- // Activation still performs attachment-local warmup, but it must not turn a789+ // Floating activation may perform its own pane-local warmup after the shared
717- // new window into a shared session resize.790+ // session geometry has been reconciled above.
718 d.ensureFloatingWarm(sess, tb)791 d.ensureFloatingWarm(sess, tb)
719 if lease == nil {792 if lease == nil {
720 d.invalidateRenderNow(sess, ac, true, "client.go")793 d.invalidateRenderNow(sess, ac, true, "client.go")
@@ -773,10 +846,10 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
773 d.requestTransactionalResize(sess, ac, sz, false)846 d.requestTransactionalResize(sess, ac, sz, false)
774 }847 }
775 848
776-// resizeAttachmentForLease changes only the sender's window/view. Session tab849+// resizeAttachmentForLease updates the sender's window/view, then makes that
777-// geometry and PTY sizes are deliberately outside this mutation boundary.850+// attachment the latest shared tab/PTY geometry claimant. Callers hold the
778-// Callers hold the attachment effect admission; output state is rebased before851+// attachment effect admission; output state is rebased before any redraw so
779-// the targeted redraw so every subsequent frame starts a new epoch.852+// every subsequent frame starts a new epoch.
780 func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool {853 func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool {
781 if token.ac == nil || !size.Valid() || !token.attachmentEffectCurrent() {854 if token.ac == nil || !size.Valid() || !token.attachmentEffectCurrent() {
782 return false855 return false
@@ -787,26 +860,35 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
787 ac.sendMu.Unlock()860 ac.sendMu.Unlock()
788 return false861 return false
789 }862 }
790- if ac.size == size {863+ sameSize := ac.sizeSnapshot() == size
791- ac.sendMu.Unlock()864+ if !sameSize {
792- return false865+ ac.setSize(size)
866+ view := ac.viewSnapshot()
867+ view.windowRows = size.Rows
868+ view.windowSet = true
869+ view.windowTop = 0
870+ view.revision++
871+ ac.publishView(view)
872+ ac.rebaseOutput()
873+ ac.pipelineCache = composeCacheInput{}
874+ ac.pipelineScratch = composeCacheInput{}
793 }875 }
794- ac.size = size
795- view := ac.viewSnapshot()
796- view.windowRows = size.Rows
797- view.windowSet = true
798- view.windowTop = 0
799- view.revision++
800- ac.publishView(view)
801- ac.rebaseOutput()
802- ac.pipelineCache = composeCacheInput{}
803- ac.pipelineScratch = composeCacheInput{}
804 ac.sendMu.Unlock()876 ac.sendMu.Unlock()
805 877
806 sess := token.sess878 sess := token.sess
807 if sess == nil {879 if sess == nil {
808 return false880 return false
809 }881 }
882+ // Shared PTY/layout geometry follows the latest accepted attachment claim.
883+ // When this resize changes canonical geometry, the shared invalidation fans
884+ // out a fresh frame to every attachment; otherwise only this attachment
885+ // needs a viewport redraw.
886+ if d.recalculateSessionGeometryAndInvalidate(sess, ac, "client.go") {
887+ return true
888+ }
889+ if sameSize {
890+ return false
891+ }
810 rc := sess.renderCoordinator()892 rc := sess.renderCoordinator()
811 if rc == nil {893 if rc == nil {
812 return false894 return false
M internal/usecase/daemon/client_transport_errors.go +2 -0
@@ -63,6 +63,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
6363 if rc := sess.renderCoordinator(); rc != nil {
6464 rc.noteDetach(ac)
6565 }
66+ d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")
6667 sess.mu.Lock()
6768 ephemeral := sess.ephemeral
6869 sess.mu.Unlock()
@@ -163,6 +164,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
163164 if rc := sess.renderCoordinator(); rc != nil {
164165 rc.noteDetach(ac)
165166 }
167+ d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")
166168 if d.parkAttachment(sess, ac) {
167169 _ = ac.closeCapturedTransport(failed)
168170 d.log.Warn("parked client after send error", "session", sess.name)
@@ -63,6 +63,7 @@ func (d *Daemon) finishClientGone(sess *session, ac *attachedClient, failed port
63 if rc := sess.renderCoordinator(); rc != nil {63 if rc := sess.renderCoordinator(); rc != nil {
64 rc.noteDetach(ac)64 rc.noteDetach(ac)
65 }65 }
66+ d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")
66 sess.mu.Lock()67 sess.mu.Lock()
67 ephemeral := sess.ephemeral68 ephemeral := sess.ephemeral
68 sess.mu.Unlock()69 sess.mu.Unlock()
@@ -163,6 +164,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed
163 if rc := sess.renderCoordinator(); rc != nil {164 if rc := sess.renderCoordinator(); rc != nil {
164 rc.noteDetach(ac)165 rc.noteDetach(ac)
165 }166 }
167+ d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go")
166 if d.parkAttachment(sess, ac) {168 if d.parkAttachment(sess, ac) {
167 _ = ac.closeCapturedTransport(failed)169 _ = ac.closeCapturedTransport(failed)
168 d.log.Warn("parked client after send error", "session", sess.name)170 d.log.Warn("parked client after send error", "session", sess.name)
M internal/usecase/daemon/final_acceptance_test.go +18 -4
@@ -62,7 +62,7 @@ func TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(t *test
6262 tabs := append([]*tab(nil), sess.tabs...)
6363 sess.mu.Unlock()
6464 require.Len(t, tabs, 3)
65- wantContent := domain.Size{Cols: 80, Rows: 23}
65+ wantContent := domain.Size{Cols: 120, Rows: 38}
6666 for _, tb := range tabs {
6767 tb.mu.Lock()
6868 gotTabSize := tb.size
@@ -155,11 +155,25 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing
155155 require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size)
156156 require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary")
157157 sess.mu.Lock()
158- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, sess.tabs[0].size, "attachment resize mutated shared session content")
158+ tb := sess.tabs[0]
159159 sess.mu.Unlock()
160+ tb.mu.Lock()
161+ sharedSize := tb.size
162+ tb.mu.Unlock()
163+ require.Equal(t, domain.Size{Cols: 120, Rows: 38}, sharedSize, "latest attachment resize must update shared session content")
160164
161165 d.clientGone(sess, first, first.transport(), true)
162- require.NotContains(t, sess.snapshotAttachments(), first)
163- require.Contains(t, sess.snapshotAttachments(), second, "detaching one attachment removed its peer")
166+ attachments := sess.snapshotAttachments()
167+ for _, candidate := range attachments {
168+ require.NotSame(t, first, candidate, "detached attachment remained registered")
169+ }
170+ var peerRegistered bool
171+ for _, candidate := range attachments {
172+ if candidate == second {
173+ peerRegistered = true
174+ break
175+ }
176+ }
177+ require.True(t, peerRegistered, "detaching one attachment removed its peer")
164178 require.Same(t, sess, second.currentSession())
165179 }
@@ -62,7 +62,7 @@ func TestAcceptancePaletteNewTabUsesSessionGeometryAfterAttachmentResize(t *test
62 tabs := append([]*tab(nil), sess.tabs...)62 tabs := append([]*tab(nil), sess.tabs...)
63 sess.mu.Unlock()63 sess.mu.Unlock()
64 require.Len(t, tabs, 3)64 require.Len(t, tabs, 3)
65- wantContent := domain.Size{Cols: 80, Rows: 23}65+ wantContent := domain.Size{Cols: 120, Rows: 38}
66 for _, tb := range tabs {66 for _, tb := range tabs {
67 tb.mu.Lock()67 tb.mu.Lock()
68 gotTabSize := tb.size68 gotTabSize := tb.size
@@ -155,11 +155,25 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing
155 require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size)155 require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size)
156 require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary")156 require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary")
157 sess.mu.Lock()157 sess.mu.Lock()
158- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, sess.tabs[0].size, "attachment resize mutated shared session content")158+ tb := sess.tabs[0]
159 sess.mu.Unlock()159 sess.mu.Unlock()
160+ tb.mu.Lock()
161+ sharedSize := tb.size
162+ tb.mu.Unlock()
163+ require.Equal(t, domain.Size{Cols: 120, Rows: 38}, sharedSize, "latest attachment resize must update shared session content")
160 164
161 d.clientGone(sess, first, first.transport(), true)165 d.clientGone(sess, first, first.transport(), true)
162- require.NotContains(t, sess.snapshotAttachments(), first)166+ attachments := sess.snapshotAttachments()
163- require.Contains(t, sess.snapshotAttachments(), second, "detaching one attachment removed its peer")167+ for _, candidate := range attachments {
168+ require.NotSame(t, first, candidate, "detached attachment remained registered")
169+ }
170+ var peerRegistered bool
171+ for _, candidate := range attachments {
172+ if candidate == second {
173+ peerRegistered = true
174+ break
175+ }
176+ }
177+ require.True(t, peerRegistered, "detaching one attachment removed its peer")
164 require.Same(t, sess, second.currentSession())178 require.Same(t, sess, second.currentSession())
165 }179 }
M internal/usecase/daemon/notice_persist_test.go +1 -1
@@ -43,7 +43,7 @@ func TestStartupClaimRecordsAndToastsPendingNotices(t *testing.T) {
4343 Size: domain.Size{Cols: 80, Rows: 24},
4444 }, tr)
4545 require.NoError(t, err)
46- d.firstPaint(sess, ac, ac.size)
46+ d.firstPaint(sess, ac)
4747
4848 toasts := awaitToastCount(t, ac, 1)
4949 require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code)
@@ -43,7 +43,7 @@ func TestStartupClaimRecordsAndToastsPendingNotices(t *testing.T) {
43 Size: domain.Size{Cols: 80, Rows: 24},43 Size: domain.Size{Cols: 80, Rows: 24},
44 }, tr)44 }, tr)
45 require.NoError(t, err)45 require.NoError(t, err)
46- d.firstPaint(sess, ac, ac.size)46+ d.firstPaint(sess, ac)
47 47
48 toasts := awaitToastCount(t, ac, 1)48 toasts := awaitToastCount(t, ac, 1)
49 require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code)49 require.Equal(t, domain.NoticeSnapshotWrite, toasts[0].Code)
M internal/usecase/daemon/notify_test.go +3 -3
@@ -607,7 +607,7 @@ func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) {
607607 sess.mu.Unlock()
608608 paintDone := make(chan struct{})
609609 go func() {
610- d.firstPaint(sess, ac, domain.Size{})
610+ d.firstPaint(sess, ac)
611611 close(paintDone)
612612 }()
613613
@@ -690,7 +690,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
690690 sess.mu.Lock()
691691 sess.registerAttachmentLocked(ac)
692692 sess.mu.Unlock()
693- d.firstPaint(sess, ac, domain.Size{})
693+ d.firstPaint(sess, ac)
694694
695695 drained := awaitToastCount(t, ac, 1)
696696 require.Equal(t, domain.NoticeSnapshotWrite, drained[0].Code)
@@ -720,7 +720,7 @@ func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {
720720 sess.mu.Lock()
721721 sess.registerAttachmentLocked(ac)
722722 sess.mu.Unlock()
723- d.firstPaint(sess, ac, domain.Size{})
723+ d.firstPaint(sess, ac)
724724
725725 drained := awaitToastCount(t, ac, 1)
726726 require.Equal(t, domain.NoticePersistDisabled, drained[0].Code)
@@ -607,7 +607,7 @@ func TestNotifyGlobalDoesNotStrandNoticeWhenFirstPaintRacesQueue(t *testing.T) {
607 sess.mu.Unlock()607 sess.mu.Unlock()
608 paintDone := make(chan struct{})608 paintDone := make(chan struct{})
609 go func() {609 go func() {
610- d.firstPaint(sess, ac, domain.Size{})610+ d.firstPaint(sess, ac)
611 close(paintDone)611 close(paintDone)
612 }()612 }()
613 613
@@ -690,7 +690,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
690 sess.mu.Lock()690 sess.mu.Lock()
691 sess.registerAttachmentLocked(ac)691 sess.registerAttachmentLocked(ac)
692 sess.mu.Unlock()692 sess.mu.Unlock()
693- d.firstPaint(sess, ac, domain.Size{})693+ d.firstPaint(sess, ac)
694 694
695 drained := awaitToastCount(t, ac, 1)695 drained := awaitToastCount(t, ac, 1)
696 require.Equal(t, domain.NoticeSnapshotWrite, drained[0].Code)696 require.Equal(t, domain.NoticeSnapshotWrite, drained[0].Code)
@@ -720,7 +720,7 @@ func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {
720 sess.mu.Lock()720 sess.mu.Lock()
721 sess.registerAttachmentLocked(ac)721 sess.registerAttachmentLocked(ac)
722 sess.mu.Unlock()722 sess.mu.Unlock()
723- d.firstPaint(sess, ac, domain.Size{})723+ d.firstPaint(sess, ac)
724 724
725 drained := awaitToastCount(t, ac, 1)725 drained := awaitToastCount(t, ac, 1)
726 require.Equal(t, domain.NoticePersistDisabled, drained[0].Code)726 require.Equal(t, domain.NoticePersistDisabled, drained[0].Code)
M internal/usecase/daemon/output_state_stream.go +1 -1
@@ -245,7 +245,7 @@ func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports
245245 _, epoch, viewRevision := s.fenceLocked()
246246 size := domain.Size{Cols: 1, Rows: 1}
247247 if s != nil && s.attachment != nil {
248- size = s.attachment.size
248+ size = s.attachment.sizeSnapshot()
249249 }
250250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
251251 }
@@ -245,7 +245,7 @@ func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports
245 _, epoch, viewRevision := s.fenceLocked()245 _, epoch, viewRevision := s.fenceLocked()
246 size := domain.Size{Cols: 1, Rows: 1}246 size := domain.Size{Cols: 1, Rows: 1}
247 if s != nil && s.attachment != nil {247 if s != nil && s.attachment != nil {
248- size = s.attachment.size248+ size = s.attachment.sizeSnapshot()
249 }249 }
250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)250 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
251 }251 }
M internal/usecase/daemon/picker.go +2 -2
@@ -922,7 +922,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
922922 from.mu.Lock()
923923 cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env)
924924 from.mu.Unlock()
925- created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, current.tabNames)
925+ created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, current.tabNames)
926926 targetSess = created
927927 return created, createErr
928928 },
@@ -951,7 +951,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
951951 env := copyEnvironment(from.env)
952952 from.mu.Unlock()
953953 cwd := d.dirOrHome(stopped.cwd)
954- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)
954+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, stopped.tabNames)
955955 if err != nil {
956956 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
957957 return nil, attachmentTransitionResult{}, false, err
@@ -922,7 +922,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
922 from.mu.Lock()922 from.mu.Lock()
923 cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env)923 cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env)
924 from.mu.Unlock()924 from.mu.Unlock()
925- created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, current.tabNames)925+ created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, current.tabNames)
926 targetSess = created926 targetSess = created
927 return created, createErr927 return created, createErr
928 },928 },
@@ -951,7 +951,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
951 env := copyEnvironment(from.env)951 env := copyEnvironment(from.env)
952 from.mu.Unlock()952 from.mu.Unlock()
953 cwd := d.dirOrHome(stopped.cwd)953 cwd := d.dirOrHome(stopped.cwd)
954- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, env, stopped.tabNames)954+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, stopped.tabNames)
955 if err != nil {955 if err != nil {
956 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)956 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
957 return nil, attachmentTransitionResult{}, false, err957 return nil, attachmentTransitionResult{}, false, err
M internal/usecase/daemon/picker_test.go +3 -3
@@ -913,7 +913,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
913913 require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1")
914914 sourcePane.screen.Write([]byte("SOURCE"))
915915 client := vt.NewScreen(80, 25)
916- d.firstPaint(source, ac, ac.size)
916+ d.firstPaint(source, ac)
917917 sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
918918 ac.ackOutputState(sourceOutput.Epoch, sourceOutput.New)
919919
@@ -956,10 +956,10 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
956956 d.sessions[sess1.id] = sess1
957957 d.sessions[sess2.id] = sess2
958958
959- d.firstPaint(sess1, ac1, ac1.size)
959+ d.firstPaint(sess1, ac1)
960960 awaitFrame(t, sends1, ports.MsgOutput)
961961 d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})
962- d.firstPaint(sess2, ac1, ac1.size)
962+ d.firstPaint(sess2, ac1)
963963 awaitFrame(t, sends1, ports.MsgOutput)
964964 require.Same(t, sess2, ac1.currentSession())
965965 for len(sends1) > 0 {
@@ -913,7 +913,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
913 require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1")913 require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1")
914 sourcePane.screen.Write([]byte("SOURCE"))914 sourcePane.screen.Write([]byte("SOURCE"))
915 client := vt.NewScreen(80, 25)915 client := vt.NewScreen(80, 25)
916- d.firstPaint(source, ac, ac.size)916+ d.firstPaint(source, ac)
917 sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))917 sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
918 ac.ackOutputState(sourceOutput.Epoch, sourceOutput.New)918 ac.ackOutputState(sourceOutput.Epoch, sourceOutput.New)
919 919
@@ -956,10 +956,10 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
956 d.sessions[sess1.id] = sess1956 d.sessions[sess1.id] = sess1
957 d.sessions[sess2.id] = sess2957 d.sessions[sess2.id] = sess2
958 958
959- d.firstPaint(sess1, ac1, ac1.size)959+ d.firstPaint(sess1, ac1)
960 awaitFrame(t, sends1, ports.MsgOutput)960 awaitFrame(t, sends1, ports.MsgOutput)
961 d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})961 d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})
962- d.firstPaint(sess2, ac1, ac1.size)962+ d.firstPaint(sess2, ac1)
963 awaitFrame(t, sends1, ports.MsgOutput)963 awaitFrame(t, sends1, ports.MsgOutput)
964 require.Same(t, sess2, ac1.currentSession())964 require.Same(t, sess2, ac1.currentSession())
965 for len(sends1) > 0 {965 for len(sends1) > 0 {
M internal/usecase/daemon/render_capture.go +1 -1
@@ -275,7 +275,7 @@ func captureLocalRenderState(
275275 view := ac.viewSnapshot()
276276 window := domain.Size{}
277277 if view.windowSet {
278- window = ac.size
278+ window = ac.sizeSnapshot()
279279 }
280280 *state = capturedRenderState{
281281 attachment: ac, lease: lease, view: view, window: window,
@@ -275,7 +275,7 @@ func captureLocalRenderState(
275 view := ac.viewSnapshot()275 view := ac.viewSnapshot()
276 window := domain.Size{}276 window := domain.Size{}
277 if view.windowSet {277 if view.windowSet {
278- window = ac.size278+ window = ac.sizeSnapshot()
279 }279 }
280 *state = capturedRenderState{280 *state = capturedRenderState{
281 attachment: ac, lease: lease, view: view, window: window,281 attachment: ac, lease: lease, view: view, window: window,
M internal/usecase/daemon/render_coordinator_types.go +1 -0
@@ -90,6 +90,7 @@ 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
9394 epoch uint64 // latest requested epoch
9495 committed uint64 // latest published epoch
9596 retryAttempts uint8
@@ -90,6 +90,7 @@ 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 request
93 epoch uint64 // latest requested epoch94 epoch uint64 // latest requested epoch
94 committed uint64 // latest published epoch95 committed uint64 // latest published epoch
95 retryAttempts uint896 retryAttempts uint8
M internal/usecase/daemon/render_resize_deadline.go +11 -2
@@ -10,10 +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.recordResizeRequestForLease(size, source, c.attachmentLease(source))
13+ return c.recordResizeRequestForLeaseWithClaim(size, source, c.attachmentLease(source), 0)
1414 }
1515
1616 func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source *attachedClient, lease *attachmentLease) uint64 {
17+ return c.recordResizeRequestForLeaseWithClaim(size, source, lease, 0)
18+}
19+
20+func (c *renderCoordinator) recordResizeRequestForLeaseWithClaim(size domain.Size, source *attachedClient, lease *attachmentLease, geometryClaim uint64) uint64 {
1721 c.mu.Lock()
1822 if !c.leaseCurrentLocked(lease, false) || lease.attachment != source {
1923 c.mu.Unlock()
@@ -22,6 +26,7 @@ func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source
2226 _, retry := c.retryLane.replaceLocked()
2327 c.resize.epoch++
2428 c.resize.size, c.resize.source, c.resize.lease = size, source, lease
29+ c.resize.geometryClaim = geometryClaim
2530 c.resize.retryAttempts = 0
2631 epoch := c.resize.epoch
2732 c.mu.Unlock()
@@ -30,7 +35,11 @@ func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source
3035 }
3136
3237 func (c *renderCoordinator) scheduleResizeForLease(size domain.Size, source *attachedClient, lease *attachmentLease, run func(uint64)) uint64 {
33- epoch := c.recordResizeRequestForLease(size, source, lease)
38+ return c.scheduleResizeForLeaseWithClaim(size, source, lease, 0, run)
39+}
40+
41+func (c *renderCoordinator) scheduleResizeForLeaseWithClaim(size domain.Size, source *attachedClient, lease *attachmentLease, geometryClaim uint64, run func(uint64)) uint64 {
42+ epoch := c.recordResizeRequestForLeaseWithClaim(size, source, lease, geometryClaim)
3443 if epoch == 0 {
3544 return 0
3645 }
@@ -10,10 +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.recordResizeRequestForLease(size, source, c.attachmentLease(source))13+ return c.recordResizeRequestForLeaseWithClaim(size, source, c.attachmentLease(source), 0)
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)
18+}
19+
20+func (c *renderCoordinator) recordResizeRequestForLeaseWithClaim(size domain.Size, source *attachedClient, lease *attachmentLease, geometryClaim uint64) uint64 {
17 c.mu.Lock()21 c.mu.Lock()
18 if !c.leaseCurrentLocked(lease, false) || lease.attachment != source {22 if !c.leaseCurrentLocked(lease, false) || lease.attachment != source {
19 c.mu.Unlock()23 c.mu.Unlock()
@@ -22,6 +26,7 @@ func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source
22 _, retry := c.retryLane.replaceLocked()26 _, retry := c.retryLane.replaceLocked()
23 c.resize.epoch++27 c.resize.epoch++
24 c.resize.size, c.resize.source, c.resize.lease = size, source, lease28 c.resize.size, c.resize.source, c.resize.lease = size, source, lease
29+ c.resize.geometryClaim = geometryClaim
25 c.resize.retryAttempts = 030 c.resize.retryAttempts = 0
26 epoch := c.resize.epoch31 epoch := c.resize.epoch
27 c.mu.Unlock()32 c.mu.Unlock()
@@ -30,7 +35,11 @@ func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source
30 }35 }
31 36
32 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 {
33- epoch := c.recordResizeRequestForLease(size, source, lease)38+ return c.scheduleResizeForLeaseWithClaim(size, source, lease, 0, run)
39+}
40+
41+func (c *renderCoordinator) scheduleResizeForLeaseWithClaim(size domain.Size, source *attachedClient, lease *attachmentLease, geometryClaim uint64, run func(uint64)) uint64 {
42+ epoch := c.recordResizeRequestForLeaseWithClaim(size, source, lease, geometryClaim)
34 if epoch == 0 {43 if epoch == 0 {
35 return 044 return 0
36 }45 }
M internal/usecase/daemon/render_test.go +1 -1
@@ -81,7 +81,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
8181 floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})
8282 installTestFloating(testAttachmentTab(sess), floating, true)
8383
84- d.firstPaint(sess, ac, tc.clientSize)
84+ d.firstPaint(sess, ac)
8585
8686 frame := awaitFrame(t, sends, ports.MsgOutput)
8787 output, err := ports.UnmarshalOutput(frame.Payload)
@@ -81,7 +81,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
81 floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})81 floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})
82 installTestFloating(testAttachmentTab(sess), floating, true)82 installTestFloating(testAttachmentTab(sess), floating, true)
83 83
84- d.firstPaint(sess, ac, tc.clientSize)84+ d.firstPaint(sess, ac)
85 85
86 frame := awaitFrame(t, sends, ports.MsgOutput)86 frame := awaitFrame(t, sends, ports.MsgOutput)
87 output, err := ports.UnmarshalOutput(frame.Payload)87 output, err := ports.UnmarshalOutput(frame.Payload)
M internal/usecase/daemon/resize_publication.go +1 -1
@@ -96,7 +96,7 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *
9696 return false
9797 }
9898 if ac != nil {
99- ac.size = size
99+ ac.setSize(size)
100100 }
101101 fences.Release()
102102 if ac != nil {
@@ -96,7 +96,7 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *
96 return false96 return false
97 }97 }
98 if ac != nil {98 if ac != nil {
99- ac.size = size99+ ac.setSize(size)
100 }100 }
101 fences.Release()101 fences.Release()
102 if ac != nil {102 if ac != nil {
M internal/usecase/daemon/resume.go +5 -1
@@ -373,6 +373,9 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
373373 captured := ac.transportSnapshot().transport
374374 ac.setSession(nil)
375375 d.mu.Unlock()
376+ if sess != nil {
377+ d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
378+ }
376379 d.watchParkedTimer(token, rearmed)
377380 if captured != nil {
378381 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
@@ -513,6 +516,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
513516 if rc := sess.renderCoordinator(); rc != nil {
514517 rc.noteDetach(ac)
515518 }
519+ d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
516520 d.unregisterPreview(ac)
517521 if !d.parkAttachment(sess, ac) {
518522 // Detach already published; retire the captured link exactly once so
@@ -611,7 +615,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
611615 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
612616 ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding)
613617 ac.replaceTransport(tr)
614- ac.size = sz
618+ ac.setSize(sz)
615619 ac.resumeToken = d.nextResumeTokenLocked()
616620 ac.parked = false
617621 // The resumed session's snapshot is the sole source for future PTY children.
@@ -373,6 +373,9 @@ func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
373 captured := ac.transportSnapshot().transport373 captured := ac.transportSnapshot().transport
374 ac.setSession(nil)374 ac.setSession(nil)
375 d.mu.Unlock()375 d.mu.Unlock()
376+ if sess != nil {
377+ d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
378+ }
376 d.watchParkedTimer(token, rearmed)379 d.watchParkedTimer(token, rearmed)
377 if captured != nil {380 if captured != nil {
378 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))381 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
@@ -513,6 +516,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
513 if rc := sess.renderCoordinator(); rc != nil {516 if rc := sess.renderCoordinator(); rc != nil {
514 rc.noteDetach(ac)517 rc.noteDetach(ac)
515 }518 }
519+ d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
516 d.unregisterPreview(ac)520 d.unregisterPreview(ac)
517 if !d.parkAttachment(sess, ac) {521 if !d.parkAttachment(sess, ac) {
518 // Detach already published; retire the captured link exactly once so522 // Detach already published; retire the captured link exactly once so
@@ -611,7 +615,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
611 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))615 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
612 ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding)616 ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding)
613 ac.replaceTransport(tr)617 ac.replaceTransport(tr)
614- ac.size = sz618+ ac.setSize(sz)
615 ac.resumeToken = d.nextResumeTokenLocked()619 ac.resumeToken = d.nextResumeTokenLocked()
616 ac.parked = false620 ac.parked = false
617 // The resumed session's snapshot is the sole source for future PTY children.621 // The resumed session's snapshot is the sole source for future PTY children.
M internal/usecase/daemon/resume_test.go +36 -4
@@ -294,27 +294,59 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
294294 d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
295295
296296 tr := &closeTrackingTransport{}
297- sess, ac, err := d.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr)
297+ initial := helloResumeCapable(ports.IntentEphemeral, "", 0)
298+ initial.Size = domain.Size{Cols: 120, Rows: 40}
299+ sess, ac, err := d.route(initial, tr)
298300 require.NoError(t, err)
299301 require.True(t, sess.ephemeral)
300302 token := ac.resumeToken
301303 require.NotZero(t, token, "ephemeral sessions receive resume tokens")
302304
305+ peerHello := helloResumeCapable(ports.IntentAttach, sess.name, 0)
306+ peerHello.ClientID = [16]byte{9}
307+ peerHello.Size = domain.Size{Cols: 100, Rows: 30}
308+ peer := &closeTrackingTransport{}
309+ _, peerAC, err := d.route(peerHello, peer)
310+ require.NoError(t, err)
311+ require.Same(t, sess, peerAC.currentSession())
312+ d.firstPaint(sess, peerAC)
313+ tb := sess.tabs[0]
314+ tb.mu.Lock()
315+ beforeParkingSize := tb.size
316+ tb.mu.Unlock()
317+ require.Equal(t, domain.Size{Cols: 100, Rows: 28}, beforeParkingSize, "the later attachment claim should be current before parking")
318+
303319 d.clientGone(sess, ac, ac.transport(), false)
304320 require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive")
305- require.Empty(t, sess.snapshotAttachments())
321+ attachments := sess.snapshotAttachments()
322+ var peerRegistered bool
323+ for _, candidate := range attachments {
324+ require.NotSame(t, ac, candidate, "parked attachment remained registered")
325+ if candidate == peerAC {
326+ peerRegistered = true
327+ }
328+ }
329+ require.True(t, peerRegistered, "the live peer attachment was removed while parking")
306330 d.mu.Lock()
307331 _, parked := d.parked[token]
308332 d.mu.Unlock()
309333 require.True(t, parked, "ephemeral resume-capable link loss is parked")
310334
335+ resumeHello := helloResumeCapable(ports.IntentResume, sess.name, token)
336+ resumeHello.Size = domain.Size{Cols: 80, Rows: 24}
311337 newTr := &closeTrackingTransport{}
312- resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, sess.name, token), newTr)
338+ resumedSess, resumedAC, err := d.route(resumeHello, newTr)
313339 require.NoError(t, err)
314340 require.Same(t, sess, resumedSess)
315341 require.Same(t, ac, resumedAC)
316342 require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token")
317343 require.Contains(t, sess.snapshotAttachments(), resumedAC)
344+ d.firstPaint(sess, resumedAC)
345+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, peerAC.sizeSnapshot())
346+ tb.mu.Lock()
347+ resumedSize := tb.size
348+ tb.mu.Unlock()
349+ require.Equal(t, domain.Size{Cols: 80, Rows: 22}, resumedSize, "resume must make its new size the latest geometry claim")
318350 }
319351
320352 // TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race
@@ -1138,7 +1170,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
11381170 require.Same(t, sess, resumedSess)
11391171 require.Same(t, ac, resumedAC)
11401172 require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
1141- d.firstPaint(resumedSess, resumedAC, resumedAC.size)
1173+ d.firstPaint(resumedSess, resumedAC)
11421174
11431175 sends := newTransport.Sends()
11441176 require.Len(t, sends, 1)
@@ -294,27 +294,59 @@ func TestEphemeralLinkLossParksAndResumes(t *testing.T) {
294 d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})294 d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
295 295
296 tr := &closeTrackingTransport{}296 tr := &closeTrackingTransport{}
297- sess, ac, err := d.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr)297+ initial := helloResumeCapable(ports.IntentEphemeral, "", 0)
298+ initial.Size = domain.Size{Cols: 120, Rows: 40}
299+ sess, ac, err := d.route(initial, tr)
298 require.NoError(t, err)300 require.NoError(t, err)
299 require.True(t, sess.ephemeral)301 require.True(t, sess.ephemeral)
300 token := ac.resumeToken302 token := ac.resumeToken
301 require.NotZero(t, token, "ephemeral sessions receive resume tokens")303 require.NotZero(t, token, "ephemeral sessions receive resume tokens")
302 304
305+ peerHello := helloResumeCapable(ports.IntentAttach, sess.name, 0)
306+ peerHello.ClientID = [16]byte{9}
307+ peerHello.Size = domain.Size{Cols: 100, Rows: 30}
308+ peer := &closeTrackingTransport{}
309+ _, peerAC, err := d.route(peerHello, peer)
310+ require.NoError(t, err)
311+ require.Same(t, sess, peerAC.currentSession())
312+ d.firstPaint(sess, peerAC)
313+ tb := sess.tabs[0]
314+ tb.mu.Lock()
315+ beforeParkingSize := tb.size
316+ tb.mu.Unlock()
317+ require.Equal(t, domain.Size{Cols: 100, Rows: 28}, beforeParkingSize, "the later attachment claim should be current before parking")
318+
303 d.clientGone(sess, ac, ac.transport(), false)319 d.clientGone(sess, ac, ac.transport(), false)
304 require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive")320 require.Equal(t, 1, sessionCount(d), "ephemeral link loss keeps session alive")
305- require.Empty(t, sess.snapshotAttachments())321+ attachments := sess.snapshotAttachments()
322+ var peerRegistered bool
323+ for _, candidate := range attachments {
324+ require.NotSame(t, ac, candidate, "parked attachment remained registered")
325+ if candidate == peerAC {
326+ peerRegistered = true
327+ }
328+ }
329+ require.True(t, peerRegistered, "the live peer attachment was removed while parking")
306 d.mu.Lock()330 d.mu.Lock()
307 _, parked := d.parked[token]331 _, parked := d.parked[token]
308 d.mu.Unlock()332 d.mu.Unlock()
309 require.True(t, parked, "ephemeral resume-capable link loss is parked")333 require.True(t, parked, "ephemeral resume-capable link loss is parked")
310 334
335+ resumeHello := helloResumeCapable(ports.IntentResume, sess.name, token)
336+ resumeHello.Size = domain.Size{Cols: 80, Rows: 24}
311 newTr := &closeTrackingTransport{}337 newTr := &closeTrackingTransport{}
312- resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, sess.name, token), newTr)338+ resumedSess, resumedAC, err := d.route(resumeHello, newTr)
313 require.NoError(t, err)339 require.NoError(t, err)
314 require.Same(t, sess, resumedSess)340 require.Same(t, sess, resumedSess)
315 require.Same(t, ac, resumedAC)341 require.Same(t, ac, resumedAC)
316 require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token")342 require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token")
317 require.Contains(t, sess.snapshotAttachments(), resumedAC)343 require.Contains(t, sess.snapshotAttachments(), resumedAC)
344+ d.firstPaint(sess, resumedAC)
345+ require.Equal(t, domain.Size{Cols: 100, Rows: 30}, peerAC.sizeSnapshot())
346+ tb.mu.Lock()
347+ resumedSize := tb.size
348+ tb.mu.Unlock()
349+ require.Equal(t, domain.Size{Cols: 80, Rows: 22}, resumedSize, "resume must make its new size the latest geometry claim")
318 }350 }
319 351
320 // TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race352 // TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race
@@ -1138,7 +1170,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
1138 require.Same(t, sess, resumedSess)1170 require.Same(t, sess, resumedSess)
1139 require.Same(t, ac, resumedAC)1171 require.Same(t, ac, resumedAC)
1140 require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))1172 require.True(t, resumedSess.renderCoordinator().markAttachmentReady(resumedSess.renderCoordinator().attachmentLease(resumedAC)))
1141- d.firstPaint(resumedSess, resumedAC, resumedAC.size)1173+ d.firstPaint(resumedSess, resumedAC)
1142 1174
1143 sends := newTransport.Sends()1175 sends := newTransport.Sends()
1144 require.Len(t, sends, 1)1176 require.Len(t, sends, 1)
M internal/usecase/daemon/resume_wake_incarnation_test.go +1 -1
@@ -63,7 +63,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
6363 // replacement transport has completed Welcome.
6464 rc.opts.wake = originalWake
6565 require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
66- d.firstPaint(sess, ac, ac.size)
66+ d.firstPaint(sess, ac)
6767 require.NotEmpty(t, newTransport.Sends())
6868 }
6969
@@ -63,7 +63,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
63 // replacement transport has completed Welcome.63 // replacement transport has completed Welcome.
64 rc.opts.wake = originalWake64 rc.opts.wake = originalWake
65 require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))65 require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
66- d.firstPaint(sess, ac, ac.size)66+ d.firstPaint(sess, ac)
67 require.NotEmpty(t, newTransport.Sends())67 require.NotEmpty(t, newTransport.Sends())
68 }68 }
69 69
M internal/usecase/daemon/session.go +2 -2
@@ -398,7 +398,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
398398 if err := domain.ValidateSessionName(name); err != nil {
399399 return err
400400 }
401- sz := ac.size
401+ sz := ac.sizeSnapshot()
402402 d.mu.Lock()
403403 if d.closing {
404404 d.mu.Unlock()
@@ -484,7 +484,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
484484 cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
485485 source.mu.Unlock()
486486 var createErr error
487- created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.size, term, env)
487+ created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.sizeSnapshot(), term, env)
488488 return created, createErr
489489 },
490490 })
@@ -398,7 +398,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
398 if err := domain.ValidateSessionName(name); err != nil {398 if err := domain.ValidateSessionName(name); err != nil {
399 return err399 return err
400 }400 }
401- sz := ac.size401+ sz := ac.sizeSnapshot()
402 d.mu.Lock()402 d.mu.Lock()
403 if d.closing {403 if d.closing {
404 d.mu.Unlock()404 d.mu.Unlock()
@@ -484,7 +484,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
484 cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)484 cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
485 source.mu.Unlock()485 source.mu.Unlock()
486 var createErr error486 var createErr error
487- created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.size, term, env)487+ created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.sizeSnapshot(), term, env)
488 return created, createErr488 return created, createErr
489 },489 },
490 })490 })
M internal/usecase/daemon/session_core.go +11 -0
@@ -10,6 +10,8 @@ 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
14+// path acquires an architectural lock while holding sizeMu.
1315 type sessionCore struct {
1416 mu sync.Mutex
1517
@@ -23,6 +25,15 @@ type sessionCore struct {
2325 attachments map[*attachedClient]struct{}
2426 attachmentOrder map[*attachedClient]uint64
2527 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+ // geometryClaimSeq is mutated while mu and geometryMu are held; its value
35+ // is copied into attachedClient.geometryClaim.
36+ geometryClaimSeq uint64
2637 createdAt int64
2738 incarnation domain.IncarnationID
2839 mruAt atomic.Uint64
@@ -10,6 +10,8 @@ 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; no
14+// path acquires an architectural lock while holding sizeMu.
13 type sessionCore struct {15 type sessionCore struct {
14 mu sync.Mutex16 mu sync.Mutex
15 17
@@ -23,6 +25,15 @@ type sessionCore struct {
23 attachments map[*attachedClient]struct{}25 attachments map[*attachedClient]struct{}
24 attachmentOrder map[*attachedClient]uint6426 attachmentOrder map[*attachedClient]uint64
25 nextAttachmentID uint6427 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+ // geometryClaimSeq is mutated while mu and geometryMu are held; its value
35+ // is copied into attachedClient.geometryClaim.
36+ geometryClaimSeq uint64
26 createdAt int6437 createdAt int64
27 incarnation domain.IncarnationID38 incarnation domain.IncarnationID
28 mruAt atomic.Uint6439 mruAt atomic.Uint64
A internal/usecase/daemon/session_geometry.go +126 -0
@@ -0,0 +1,126 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/domain"
4+
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 {
10+ attachment *attachedClient
11+ size domain.Size
12+ claim uint64
13+}
14+
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
23+ }
24+
25+ s.mu.Lock()
26+ s.refreshGeometryOwnerLocked()
27+ owner := s.geometryOwner.Load()
28+ var size domain.Size
29+ var claim uint64
30+ if owner != nil {
31+ size = owner.geometryClaimSizeSnapshot()
32+ claim = owner.geometryClaim.Load()
33+ }
34+ s.mu.Unlock()
35+
36+ if owner == nil || !size.Valid() {
37+ return sessionGeometrySource{}, false
38+ }
39+ return sessionGeometrySource{attachment: owner, size: size, claim: claim}, true
40+}
41+
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
48+ }
49+ return s.geometryOwner.Load() == attachment && attachment.geometryClaim.Load() == claim
50+}
51+
52+func (s *session) geometryClaimCurrent(source sessionGeometrySource) bool {
53+ return s.geometryClaimTokenCurrent(source.attachment, source.claim) &&
54+ (source.attachment == nil || source.attachment.geometryClaimSizeSnapshot() == source.size)
55+}
56+
57+// sessionGeometryViewport is called with a non-nil session by the daemon
58+// geometry paths; it returns ok == false when no valid claim exists.
59+func sessionGeometryViewport(sess *session) (domain.Size, sessionGeometrySource, bool) {
60+ source, ok := sess.geometrySourceSnapshot()
61+ if !ok {
62+ return domain.Size{}, sessionGeometrySource{}, false
63+ }
64+ content := contentSize(source.size)
65+ return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true
66+}
67+
68+// recalculateSessionGeometry reconciles shared PTY/layout geometry with the
69+// latest live attachment claim. An explicit source records a new claim before
70+// the snapshot; attach and resume paths already claimed ownership when they
71+// entered the attachment registry. If the owner detaches, unregistering the
72+// attachment selects the most recently claimed remaining attachment.
73+func (d *Daemon) recalculateSessionGeometry(sess *session, source *attachedClient) bool {
74+ if sess == nil {
75+ return false
76+ }
77+ if source != nil {
78+ if _, ok := sess.claimGeometryOwner(source); !ok {
79+ return false
80+ }
81+ }
82+ want, owner, ok := sessionGeometryViewport(sess)
83+ if !ok {
84+ return false
85+ }
86+ current := func() bool { return sess.geometryClaimCurrent(owner) }
87+ if contentSize(sess.fullViewportSize()) == contentSize(want) {
88+ return false
89+ }
90+ failed, ok := d.applySessionLayout(sess, want, current, nil)
91+ if ok && len(failed) != 0 {
92+ seen := make(map[*tab]struct{}, len(failed))
93+ for _, member := range failed {
94+ if member.tab == nil {
95+ continue
96+ }
97+ if _, exists := seen[member.tab]; exists {
98+ continue
99+ }
100+ seen[member.tab] = struct{}{}
101+ d.scheduleAcceptedTabLayoutRetry(sess, member.tab)
102+ }
103+ }
104+ return ok
105+}
106+
107+// recalculateSessionGeometryAndInvalidate publishes a shared render wake after
108+// canonical geometry changes. A nil source uses the latest attach/resume/resize
109+// claim already published by the attachment registry. The wake is asynchronous
110+// so a slow peer cannot block a resize or detach.
111+func (d *Daemon) recalculateSessionGeometryAndInvalidate(sess *session, source *attachedClient, producer string) bool {
112+ if !d.recalculateSessionGeometry(sess, source) {
113+ return false
114+ }
115+ d.invalidateRender(sess, nil, true, producer)
116+ return true
117+}
118+
119+func (d *Daemon) recalculateSessionGeometryAndInvalidateAsync(sess *session, producer string) {
120+ if d == nil {
121+ return
122+ }
123+ d.sessWg.Go(func() {
124+ d.recalculateSessionGeometryAndInvalidate(sess, nil, producer)
125+ })
126+}
@@ -0,0 +1,126 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/domain"
4+
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 {
10+ attachment *attachedClient
11+ size domain.Size
12+ claim uint64
13+}
14+
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
23+ }
24+
25+ s.mu.Lock()
26+ s.refreshGeometryOwnerLocked()
27+ owner := s.geometryOwner.Load()
28+ var size domain.Size
29+ var claim uint64
30+ if owner != nil {
31+ size = owner.geometryClaimSizeSnapshot()
32+ claim = owner.geometryClaim.Load()
33+ }
34+ s.mu.Unlock()
35+
36+ if owner == nil || !size.Valid() {
37+ return sessionGeometrySource{}, false
38+ }
39+ return sessionGeometrySource{attachment: owner, size: size, claim: claim}, true
40+}
41+
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
48+ }
49+ return s.geometryOwner.Load() == attachment && attachment.geometryClaim.Load() == claim
50+}
51+
52+func (s *session) geometryClaimCurrent(source sessionGeometrySource) bool {
53+ return s.geometryClaimTokenCurrent(source.attachment, source.claim) &&
54+ (source.attachment == nil || source.attachment.geometryClaimSizeSnapshot() == source.size)
55+}
56+
57+// sessionGeometryViewport is called with a non-nil session by the daemon
58+// geometry paths; it returns ok == false when no valid claim exists.
59+func sessionGeometryViewport(sess *session) (domain.Size, sessionGeometrySource, bool) {
60+ source, ok := sess.geometrySourceSnapshot()
61+ if !ok {
62+ return domain.Size{}, sessionGeometrySource{}, false
63+ }
64+ content := contentSize(source.size)
65+ return domain.Size{Cols: content.Cols, Rows: content.Rows + tabChromeRows}, source, true
66+}
67+
68+// recalculateSessionGeometry reconciles shared PTY/layout geometry with the
69+// latest live attachment claim. An explicit source records a new claim before
70+// the snapshot; attach and resume paths already claimed ownership when they
71+// entered the attachment registry. If the owner detaches, unregistering the
72+// attachment selects the most recently claimed remaining attachment.
73+func (d *Daemon) recalculateSessionGeometry(sess *session, source *attachedClient) bool {
74+ if sess == nil {
75+ return false
76+ }
77+ if source != nil {
78+ if _, ok := sess.claimGeometryOwner(source); !ok {
79+ return false
80+ }
81+ }
82+ want, owner, ok := sessionGeometryViewport(sess)
83+ if !ok {
84+ return false
85+ }
86+ current := func() bool { return sess.geometryClaimCurrent(owner) }
87+ if contentSize(sess.fullViewportSize()) == contentSize(want) {
88+ return false
89+ }
90+ failed, ok := d.applySessionLayout(sess, want, current, nil)
91+ if ok && len(failed) != 0 {
92+ seen := make(map[*tab]struct{}, len(failed))
93+ for _, member := range failed {
94+ if member.tab == nil {
95+ continue
96+ }
97+ if _, exists := seen[member.tab]; exists {
98+ continue
99+ }
100+ seen[member.tab] = struct{}{}
101+ d.scheduleAcceptedTabLayoutRetry(sess, member.tab)
102+ }
103+ }
104+ return ok
105+}
106+
107+// recalculateSessionGeometryAndInvalidate publishes a shared render wake after
108+// canonical geometry changes. A nil source uses the latest attach/resume/resize
109+// claim already published by the attachment registry. The wake is asynchronous
110+// so a slow peer cannot block a resize or detach.
111+func (d *Daemon) recalculateSessionGeometryAndInvalidate(sess *session, source *attachedClient, producer string) bool {
112+ if !d.recalculateSessionGeometry(sess, source) {
113+ return false
114+ }
115+ d.invalidateRender(sess, nil, true, producer)
116+ return true
117+}
118+
119+func (d *Daemon) recalculateSessionGeometryAndInvalidateAsync(sess *session, producer string) {
120+ if d == nil {
121+ return
122+ }
123+ d.sessWg.Go(func() {
124+ d.recalculateSessionGeometryAndInvalidate(sess, nil, producer)
125+ })
126+}
M internal/usecase/daemon/transactional_resize.go +26 -3
@@ -582,6 +582,10 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
582582 if valid && d.beforeSessionResizePublication != nil {
583583 d.beforeSessionResizePublication()
584584 }
585+ // Claim publication and shared geometry commit use one fence. The hook
586+ // above remains outside the fence so tests and lifecycle callbacks can
587+ // publish a newer claim; the final validation then rejects this stale plan.
588+ sess.geometryMu.Lock()
585589 if valid && sess.ctx != nil && sess.ctx.Err() != nil {
586590 valid = false
587591 }
@@ -600,6 +604,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
600604 plan.tab.size = plan.size
601605 }
602606 }
607+ sess.geometryMu.Unlock()
603608 for i := len(tabs) - 1; i >= 0; i-- {
604609 tabs[i].mu.Unlock()
605610 }
@@ -661,11 +666,20 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
661666 return false
662667 }
663668 snap := rc.resizeSnapshot()
669+ geometryClaim := snap.geometryClaim
670+ if geometryClaim == 0 {
671+ var claimed bool
672+ geometryClaim, claimed = sess.claimGeometryOwnerForSize(ac, snap.size)
673+ if !claimed {
674+ return false
675+ }
676+ }
664677 connectionGeneration := ac.connectionGeneration.Load()
665678 connection := ac.transportSnapshot()
666679 current := func() bool {
667680 return ac.connectionGeneration.Load() == connectionGeneration &&
668681 ac.transportSnapshotCurrent(connection) &&
682+ sess.geometryClaimTokenCurrent(ac, geometryClaim) &&
669683 rc.resizeCurrentForLease(epoch, ac, lease, false)
670684 }
671685 if !current() {
@@ -687,7 +701,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
687701 ac.sendMu.Unlock()
688702 return false
689703 }
690- ac.size = snap.size
704+ ac.setSize(snap.size)
691705 ac.sendMu.Unlock()
692706 if !current() {
693707 return false
@@ -870,12 +884,21 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC
870884 if rc == nil || !rc.leaseCurrent(lease, true) {
871885 return false
872886 }
887+ geometryClaim, claimed := sess.claimGeometryOwnerForSize(ac, size)
888+ if !claimed {
889+ return false
890+ }
873891 if immediate {
874- epoch := rc.recordResizeRequestForLease(size, ac, lease)
892+ epoch := rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim)
875893 if epoch == 0 {
894+ sess.releaseGeometryClaim(ac, geometryClaim)
876895 return false
877896 }
878897 return d.runResizeTransaction(sess, ac, lease, epoch)
879898 }
880- return rc.scheduleResizeForLease(size, ac, lease, func(epoch uint64) { d.runResizeTransaction(sess, ac, lease, epoch) }) != 0
899+ epoch := rc.scheduleResizeForLeaseWithClaim(size, ac, lease, geometryClaim, func(epoch uint64) { d.runResizeTransaction(sess, ac, lease, epoch) })
900+ if epoch == 0 {
901+ sess.releaseGeometryClaim(ac, geometryClaim)
902+ }
903+ return epoch != 0
881904 }
@@ -582,6 +582,10 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
582 if valid && d.beforeSessionResizePublication != nil {582 if valid && d.beforeSessionResizePublication != nil {
583 d.beforeSessionResizePublication()583 d.beforeSessionResizePublication()
584 }584 }
585+ // Claim publication and shared geometry commit use one fence. The hook
586+ // above remains outside the fence so tests and lifecycle callbacks can
587+ // publish a newer claim; the final validation then rejects this stale plan.
588+ sess.geometryMu.Lock()
585 if valid && sess.ctx != nil && sess.ctx.Err() != nil {589 if valid && sess.ctx != nil && sess.ctx.Err() != nil {
586 valid = false590 valid = false
587 }591 }
@@ -600,6 +604,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
600 plan.tab.size = plan.size604 plan.tab.size = plan.size
601 }605 }
602 }606 }
607+ sess.geometryMu.Unlock()
603 for i := len(tabs) - 1; i >= 0; i-- {608 for i := len(tabs) - 1; i >= 0; i-- {
604 tabs[i].mu.Unlock()609 tabs[i].mu.Unlock()
605 }610 }
@@ -661,11 +666,20 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
661 return false666 return false
662 }667 }
663 snap := rc.resizeSnapshot()668 snap := rc.resizeSnapshot()
669+ geometryClaim := snap.geometryClaim
670+ if geometryClaim == 0 {
671+ var claimed bool
672+ geometryClaim, claimed = sess.claimGeometryOwnerForSize(ac, snap.size)
673+ if !claimed {
674+ return false
675+ }
676+ }
664 connectionGeneration := ac.connectionGeneration.Load()677 connectionGeneration := ac.connectionGeneration.Load()
665 connection := ac.transportSnapshot()678 connection := ac.transportSnapshot()
666 current := func() bool {679 current := func() bool {
667 return ac.connectionGeneration.Load() == connectionGeneration &&680 return ac.connectionGeneration.Load() == connectionGeneration &&
668 ac.transportSnapshotCurrent(connection) &&681 ac.transportSnapshotCurrent(connection) &&
682+ sess.geometryClaimTokenCurrent(ac, geometryClaim) &&
669 rc.resizeCurrentForLease(epoch, ac, lease, false)683 rc.resizeCurrentForLease(epoch, ac, lease, false)
670 }684 }
671 if !current() {685 if !current() {
@@ -687,7 +701,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
687 ac.sendMu.Unlock()701 ac.sendMu.Unlock()
688 return false702 return false
689 }703 }
690- ac.size = snap.size704+ ac.setSize(snap.size)
691 ac.sendMu.Unlock()705 ac.sendMu.Unlock()
692 if !current() {706 if !current() {
693 return false707 return false
@@ -870,12 +884,21 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC
870 if rc == nil || !rc.leaseCurrent(lease, true) {884 if rc == nil || !rc.leaseCurrent(lease, true) {
871 return false885 return false
872 }886 }
887+ geometryClaim, claimed := sess.claimGeometryOwnerForSize(ac, size)
888+ if !claimed {
889+ return false
890+ }
873 if immediate {891 if immediate {
874- epoch := rc.recordResizeRequestForLease(size, ac, lease)892+ epoch := rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim)
875 if epoch == 0 {893 if epoch == 0 {
894+ sess.releaseGeometryClaim(ac, geometryClaim)
876 return false895 return false
877 }896 }
878 return d.runResizeTransaction(sess, ac, lease, epoch)897 return d.runResizeTransaction(sess, ac, lease, epoch)
879 }898 }
880- return rc.scheduleResizeForLease(size, ac, lease, func(epoch uint64) { d.runResizeTransaction(sess, ac, lease, epoch) }) != 0899+ epoch := rc.scheduleResizeForLeaseWithClaim(size, ac, lease, geometryClaim, func(epoch uint64) { d.runResizeTransaction(sess, ac, lease, epoch) })
900+ if epoch == 0 {
901+ sess.releaseGeometryClaim(ac, geometryClaim)
902+ }
903+ return epoch != 0
881 }904 }

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