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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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/ | |||
| 77 | 77 | - Each connection has a 15-second handshake budget from connect through the initial committed publication. | |
| 78 | 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 | 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 | 81 | - 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) | |||
| 51 | 51 | ||
| 52 | 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 | 56 | ## Keys | |
| 57 | 57 | ||
| @@ -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 | ## Keys | 56 | ## 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 | |||
| 7 | 7 | the remote host. | |
| 8 | 8 | ||
| 9 | 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 keeps | |
| 10 | + | panes, shared PTY content geometry, and ordered mutations. Each attachment keeps | |
| 11 | 11 | its own window size, selected tab and pane, copy mode, overlays, render/output | |
| 12 | 12 | 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 | 16 | ## Connection limits | |
| 16 | 17 | ||
| @@ -20,8 +21,10 @@ attachment without replacing or changing its peers. | |||
| 20 | 21 | - Each command request has a 10-second deadline for its result. A timeout | |
| 21 | 22 | abandons only that request; it does not complete a later request with a stale | |
| 22 | 23 | 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. | |
| 25 | 28 | ||
| 26 | 29 | ## UDP operation | |
| 27 | 30 | ||
| @@ -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 keeps | 10 | + | panes, shared PTY content geometry, and ordered mutations. Each attachment keeps |
| 11 | its own window size, selected tab and pane, copy mode, overlays, render/output | 11 | its own window size, selected tab and pane, copy mode, overlays, render/output | ||
| 12 | acknowledgements, and reconnect state. Reconnecting one attachment resumes that | 12 | 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 limits | 16 | ## 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 timeout | 21 | - 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 stale | 22 | 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. A | 24 | + | - 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 operation | 29 | ## 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, | |||
| 16 | 16 | ||
| 17 | 17 | ## Session content size | |
| 18 | 18 | ||
| 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. | |
| 22 | 25 | ||
| 23 | 26 | ## Recovery | |
| 24 | 27 | ||
| @@ -16,9 +16,12 @@ vev keeps a server-side VT screen per pane: scroll regions (DECOM), insert mode, | |||||
| 16 | 16 | ||||
| 17 | ## Session content size | 17 | ## Session content size | ||
| 18 | 18 | ||||
| 19 | - | The first valid attachment establishes the session's fixed PTY content size. Each | 19 | + | 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 changes | 20 | + | 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 | ## Recovery | 26 | ## Recovery | ||
| 24 | 27 | ||||
M internal/app/final_acceptance_test.go +3 -2
| @@ -110,8 +110,9 @@ func TestAcceptanceTwoLocalAttachmentsKeepViewsOverTransports(t *testing.T) { | |||
| 110 | 110 | awaitText(t, secondPump, size, "[SCROLL]") | |
| 111 | 111 | assertNoTextAfterInput(t, firstPump, size, "[SCROLL]") | |
| 112 | 112 | ||
| 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. | |
| 115 | 116 | resizePayload, err := ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 100, Rows: 30}}) | |
| 116 | 117 | require.NoError(t, err) | |
| 117 | 118 | 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 ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 15 | 15 | "github.com/bnema/vev/internal/domain" | |
| 16 | 16 | "github.com/bnema/vev/internal/ports" | |
| 17 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/client" | |
| 18 | 19 | "github.com/stretchr/testify/require" | |
| 19 | 20 | ) | |
| @@ -114,9 +115,14 @@ func (*acceptanceRemoteTerminal) Flush() error { return nil | |||
| 114 | 115 | func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) { | |
| 115 | 116 | outputs := make(chan string, 4) | |
| 116 | 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 | 122 | localCalls := 0 | |
| 118 | 123 | terminals := make([]*acceptanceRemoteTerminal, 0, 3) | |
| 119 | 124 | deps := runAttachDeps{ | |
| 125 | + | hostStore: hostStore, | |
| 120 | 126 | localDialer: func() ports.Dialer { | |
| 121 | 127 | localCalls++ | |
| 122 | 128 | 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 := 0 | 122 | 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 { | |||
| 108 | 108 | c.nextAttachmentID++ | |
| 109 | 109 | c.attachments[ac] = struct{}{} | |
| 110 | 110 | c.attachmentOrder[ac] = c.nextAttachmentID | |
| 111 | + | if c.claimGeometryOwnerLocked(ac, ac.sizeSnapshot()) == 0 { | |
| 112 | + | c.refreshGeometryOwnerLocked() | |
| 113 | + | } | |
| 111 | 114 | 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 | 171 | func registerAttachmentSessionLocked(entry *session, ac *attachedClient) bool { | |
| 115 | 172 | if entry == nil || entry.core() == nil { | |
| 116 | 173 | return false | |
| @@ -132,6 +189,49 @@ func (s *session) registerAttachment(ac *attachedClient) bool { | |||
| 132 | 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 | 235 | // unregisterAttachmentLocked removes only ac and leaves every other | |
| 136 | 236 | // attachment's view intact. Caller holds s.mu. | |
| 137 | 237 | func (s *session) unregisterAttachmentLocked(ac *attachedClient) bool { | |
| @@ -150,6 +250,7 @@ func (c *sessionCore) unregisterAttachmentLocked(ac *attachedClient) bool { | |||
| 150 | 250 | } | |
| 151 | 251 | delete(c.attachments, ac) | |
| 152 | 252 | delete(c.attachmentOrder, ac) | |
| 253 | + | c.refreshGeometryOwnerLocked() | |
| 153 | 254 | return true | |
| 154 | 255 | } | |
| 155 | 256 | ||
| @@ -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.nextAttachmentID | 110 | c.attachmentOrder[ac] = c.nextAttachmentID | ||
| 111 | + | if c.claimGeometryOwnerLocked(ac, ac.sizeSnapshot()) == 0 { | |||
| 112 | + | c.refreshGeometryOwnerLocked() | |||
| 113 | + | } | |||
| 111 | return true | 114 | 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 false | 173 | 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 other | 235 | // 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 true | 254 | 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) | |||
| 97 | 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 | 101 | pty, releasePTY := newBlockingPTY(t) | |
| 102 | 102 | defer releasePTY() | |
| 103 | 103 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| @@ -113,11 +113,9 @@ func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) { | |||
| 113 | 113 | Size: domain.Size{Cols: 100, Rows: 40}, ClientID: [16]byte{2}, | |
| 114 | 114 | }, secondTransport) | |
| 115 | 115 | require.NoError(t, err) | |
| 116 | + | require.True(t, sess.repairAttachmentView(second)) | |
| 116 | 117 | ||
| 117 | 118 | tb := sess.tabs[0] | |
| 118 | - | tb.mu.Lock() | |
| 119 | - | contentBefore := tb.size | |
| 120 | - | tb.mu.Unlock() | |
| 121 | 119 | firstSize := first.size | |
| 122 | 120 | secondRevision := second.viewSnapshot().revision | |
| 123 | 121 | secondEpoch := second.output.currentEpoch() | |
| @@ -134,7 +132,7 @@ func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) { | |||
| 134 | 132 | ticket.End() | |
| 135 | 133 | ||
| 136 | 134 | tb.mu.Lock() | |
| 137 | - | require.Equal(t, contentBefore, tb.size) | |
| 135 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 48}, tb.size) | |
| 138 | 136 | tb.mu.Unlock() | |
| 139 | 137 | require.Equal(t, firstSize, first.size) | |
| 140 | 138 | require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size) | |
| @@ -254,3 +252,148 @@ func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) { | |||
| 254 | 252 | } | |
| 255 | 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 | + | } | |
| @@ -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.size | 119 | firstSize := first.size | ||
| 122 | secondRevision := second.viewSnapshot().revision | 120 | 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 | |||
| 70 | 70 | } | |
| 71 | 71 | ||
| 72 | 72 | type attachmentTransitionResult struct { | |
| 73 | - | published attachmentConnectionToken | |
| 74 | - | cleanups []renderLifecycleCleanup | |
| 73 | + | published attachmentConnectionToken | |
| 74 | + | cleanups []renderLifecycleCleanup | |
| 75 | + | sourceGeometrySession *session | |
| 75 | 76 | } | |
| 76 | 77 | ||
| 77 | 78 | type attachmentTransitionParticipants struct { | |
| @@ -146,7 +147,14 @@ func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (a | |||
| 146 | 147 | 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 | 160 | func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachmentTransitionResult, error) { | |
| @@ -168,6 +176,9 @@ func (d *Daemon) transitionAttachment(req attachmentTransitionRequest) (attachme | |||
| 168 | 176 | if err != nil { | |
| 169 | 177 | return result, err | |
| 170 | 178 | } | |
| 179 | + | if result.sourceGeometrySession != nil { | |
| 180 | + | d.recalculateSessionGeometryAndInvalidate(result.sourceGeometrySession, nil, "attachment_transition.go") | |
| 181 | + | } | |
| 171 | 182 | return result, nil | |
| 172 | 183 | } | |
| 173 | 184 | ||
| @@ -70,8 +70,9 @@ func transitionSourceTokenCurrentLocked(token attachmentConnectionToken, source | |||||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | type attachmentTransitionResult struct { | 72 | type attachmentTransitionResult struct { | ||
| 73 | - | published attachmentConnectionToken | 73 | + | published attachmentConnectionToken |
| 74 | - | cleanups []renderLifecycleCleanup | 74 | + | 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{}, err | 147 | 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, err | 177 | return result, err | ||
| 170 | } | 178 | } | ||
| 179 | + | if result.sourceGeometrySession != nil { | |||
| 180 | + | d.recalculateSessionGeometryAndInvalidate(result.sourceGeometrySession, nil, "attachment_transition.go") | |||
| 181 | + | } | |||
| 171 | return result, nil | 182 | 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) { | |||
| 190 | 190 | if publication.source != req.target { | |
| 191 | 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 | 199 | publication.nextGeneration = req.next.connectionGeneration.Add(1) | |
| 195 | 200 | req.next.setSession(req.target) | |
| 196 | 201 | } | |
| @@ -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 { | |||
| 66 | 66 | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | |
| 67 | 67 | // snapshots cannot leak when an attachment switches tabs or sessions. | |
| 68 | 68 | 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 | |
| 70 | 79 | keys *keys.Router | |
| 71 | 80 | // view is attachment-local navigation state. It is never inferred from a | |
| 72 | 81 | // session-wide active tab, so multiple attachments can observe different | |
| @@ -141,6 +150,62 @@ func (ac *attachedClient) initOverlays() { | |||
| 141 | 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 | 209 | func (ac *attachedClient) currentAttachmentSession() *session { return ac.sess.Get() } | |
| 145 | 210 | ||
| 146 | 211 | // currentSession narrows the attachment to its owning local session. | |
| @@ -692,18 +757,18 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool { | |||
| 692 | 757 | if token.sess == nil { | |
| 693 | 758 | 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 | 761 | return true | |
| 697 | 762 | } | |
| 698 | 763 | ||
| 699 | 764 | // 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) | |
| 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 | 772 | // Global notices raised while nothing was attached surface on this client. | |
| 708 | 773 | // Drained before the early return below so a session without an active tab | |
| 709 | 774 | // cannot swallow the queue. | |
| @@ -712,9 +777,17 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, _ domain | |||
| 712 | 777 | if tb == nil { | |
| 713 | 778 | 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 | 788 | 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. | |
| 718 | 791 | d.ensureFloatingWarm(sess, tb) | |
| 719 | 792 | if lease == nil { | |
| 720 | 793 | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| @@ -773,10 +846,10 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 773 | 846 | d.requestTransactionalResize(sess, ac, sz, false) | |
| 774 | 847 | } | |
| 775 | 848 | ||
| 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. | |
| 780 | 853 | func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool { | |
| 781 | 854 | if token.ac == nil || !size.Valid() || !token.attachmentEffectCurrent() { | |
| 782 | 855 | return false | |
| @@ -787,26 +860,35 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size | |||
| 787 | 860 | ac.sendMu.Unlock() | |
| 788 | 861 | return false | |
| 789 | 862 | } | |
| 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{} | |
| 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 | 876 | ac.sendMu.Unlock() | |
| 805 | 877 | ||
| 806 | 878 | sess := token.sess | |
| 807 | 879 | if sess == nil { | |
| 808 | 880 | 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 | 892 | rc := sess.renderCoordinator() | |
| 811 | 893 | if rc == nil { | |
| 812 | 894 | return false | |
| @@ -66,7 +66,16 @@ type attachedClient struct { | |||||
| 66 | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | 66 | // 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 held | 68 | 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 | |||
| 70 | keys *keys.Router | 79 | keys *keys.Router | ||
| 71 | // view is attachment-local navigation state. It is never inferred from a | 80 | // view is attachment-local navigation state. It is never inferred from a | ||
| 72 | // session-wide active tab, so multiple attachments can observe different | 81 | // 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 false | 758 | 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 true | 761 | return true | ||
| 697 | } | 762 | } | ||
| 698 | 763 | ||||
| 699 | // firstPaint guarantees the freshly attached client sees the full screen. The | 764 | // 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 | 765 | + | // 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 tab | 773 | // 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 | return | 778 | 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 a | 789 | + | // 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 tab | 849 | + | // 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 before | 851 | + | // 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 false | 855 | return false | ||
| @@ -787,26 +860,35 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size | |||||
| 787 | ac.sendMu.Unlock() | 860 | ac.sendMu.Unlock() | ||
| 788 | return false | 861 | return false | ||
| 789 | } | 862 | } | ||
| 790 | - | if ac.size == size { | 863 | + | sameSize := ac.sizeSnapshot() == size |
| 791 | - | ac.sendMu.Unlock() | 864 | + | if !sameSize { |
| 792 | - | return false | 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{} | |||
| 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.sess | 878 | sess := token.sess | ||
| 807 | if sess == nil { | 879 | if sess == nil { | ||
| 808 | return false | 880 | 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 false | 894 | 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 | |||
| 63 | 63 | if rc := sess.renderCoordinator(); rc != nil { | |
| 64 | 64 | rc.noteDetach(ac) | |
| 65 | 65 | } | |
| 66 | + | d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go") | |
| 66 | 67 | sess.mu.Lock() | |
| 67 | 68 | ephemeral := sess.ephemeral | |
| 68 | 69 | sess.mu.Unlock() | |
| @@ -163,6 +164,7 @@ func (d *Daemon) finishSendErrorDetach(sess *session, ac *attachedClient, failed | |||
| 163 | 164 | if rc := sess.renderCoordinator(); rc != nil { | |
| 164 | 165 | rc.noteDetach(ac) | |
| 165 | 166 | } | |
| 167 | + | d.recalculateSessionGeometryAndInvalidate(sess, nil, "client_transport_errors.go") | |
| 166 | 168 | if d.parkAttachment(sess, ac) { | |
| 167 | 169 | _ = ac.closeCapturedTransport(failed) | |
| 168 | 170 | 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.ephemeral | 68 | 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 | |||
| 62 | 62 | tabs := append([]*tab(nil), sess.tabs...) | |
| 63 | 63 | sess.mu.Unlock() | |
| 64 | 64 | require.Len(t, tabs, 3) | |
| 65 | - | wantContent := domain.Size{Cols: 80, Rows: 23} | |
| 65 | + | wantContent := domain.Size{Cols: 120, Rows: 38} | |
| 66 | 66 | for _, tb := range tabs { | |
| 67 | 67 | tb.mu.Lock() | |
| 68 | 68 | gotTabSize := tb.size | |
| @@ -155,11 +155,25 @@ func TestAcceptanceAttachmentStateIsolationAcrossResetResizeAndDetach(t *testing | |||
| 155 | 155 | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, second.size) | |
| 156 | 156 | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, first.size, "peer resize crossed attachment boundary") | |
| 157 | 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 | 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 | 165 | 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") | |
| 164 | 178 | require.Same(t, sess, second.currentSession()) | |
| 165 | 179 | } | |
| @@ -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.size | 68 | 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) { | |||
| 43 | 43 | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 44 | 44 | }, tr) | |
| 45 | 45 | require.NoError(t, err) | |
| 46 | - | d.firstPaint(sess, ac, ac.size) | |
| 46 | + | d.firstPaint(sess, ac) | |
| 47 | 47 | ||
| 48 | 48 | toasts := awaitToastCount(t, ac, 1) | |
| 49 | 49 | 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) { | |||
| 607 | 607 | sess.mu.Unlock() | |
| 608 | 608 | paintDone := make(chan struct{}) | |
| 609 | 609 | go func() { | |
| 610 | - | d.firstPaint(sess, ac, domain.Size{}) | |
| 610 | + | d.firstPaint(sess, ac) | |
| 611 | 611 | close(paintDone) | |
| 612 | 612 | }() | |
| 613 | 613 | ||
| @@ -690,7 +690,7 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) { | |||
| 690 | 690 | sess.mu.Lock() | |
| 691 | 691 | sess.registerAttachmentLocked(ac) | |
| 692 | 692 | sess.mu.Unlock() | |
| 693 | - | d.firstPaint(sess, ac, domain.Size{}) | |
| 693 | + | d.firstPaint(sess, ac) | |
| 694 | 694 | ||
| 695 | 695 | drained := awaitToastCount(t, ac, 1) | |
| 696 | 696 | require.Equal(t, domain.NoticeSnapshotWrite, drained[0].Code) | |
| @@ -720,7 +720,7 @@ func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) { | |||
| 720 | 720 | sess.mu.Lock() | |
| 721 | 721 | sess.registerAttachmentLocked(ac) | |
| 722 | 722 | sess.mu.Unlock() | |
| 723 | - | d.firstPaint(sess, ac, domain.Size{}) | |
| 723 | + | d.firstPaint(sess, ac) | |
| 724 | 724 | ||
| 725 | 725 | drained := awaitToastCount(t, ac, 1) | |
| 726 | 726 | 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 | |||
| 245 | 245 | _, epoch, viewRevision := s.fenceLocked() | |
| 246 | 246 | size := domain.Size{Cols: 1, Rows: 1} | |
| 247 | 247 | if s != nil && s.attachment != nil { | |
| 248 | - | size = s.attachment.size | |
| 248 | + | size = s.attachment.sizeSnapshot() | |
| 249 | 249 | } | |
| 250 | 250 | return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false) | |
| 251 | 251 | } | |
| @@ -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.size | 248 | + | 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, | |||
| 922 | 922 | from.mu.Lock() | |
| 923 | 923 | cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env) | |
| 924 | 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 | 926 | targetSess = created | |
| 927 | 927 | return created, createErr | |
| 928 | 928 | }, | |
| @@ -951,7 +951,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 951 | 951 | env := copyEnvironment(from.env) | |
| 952 | 952 | from.mu.Unlock() | |
| 953 | 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 | 955 | if err != nil { | |
| 956 | 956 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | |
| 957 | 957 | 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 = created | 926 | targetSess = created | ||
| 927 | return created, createErr | 927 | 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, err | 957 | return nil, attachmentTransitionResult{}, false, err | ||
M internal/usecase/daemon/picker_test.go +3 -3
| @@ -913,7 +913,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||
| 913 | 913 | require.Equal(t, sourcePane.id, targetPane.id, "target deliberately reuses pane-1") | |
| 914 | 914 | sourcePane.screen.Write([]byte("SOURCE")) | |
| 915 | 915 | client := vt.NewScreen(80, 25) | |
| 916 | - | d.firstPaint(source, ac, ac.size) | |
| 916 | + | d.firstPaint(source, ac) | |
| 917 | 917 | sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 918 | 918 | ac.ackOutputState(sourceOutput.Epoch, sourceOutput.New) | |
| 919 | 919 | ||
| @@ -956,10 +956,10 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||
| 956 | 956 | d.sessions[sess1.id] = sess1 | |
| 957 | 957 | d.sessions[sess2.id] = sess2 | |
| 958 | 958 | ||
| 959 | - | d.firstPaint(sess1, ac1, ac1.size) | |
| 959 | + | d.firstPaint(sess1, ac1) | |
| 960 | 960 | awaitFrame(t, sends1, ports.MsgOutput) | |
| 961 | 961 | d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id}) | |
| 962 | - | d.firstPaint(sess2, ac1, ac1.size) | |
| 962 | + | d.firstPaint(sess2, ac1) | |
| 963 | 963 | awaitFrame(t, sends1, ports.MsgOutput) | |
| 964 | 964 | require.Same(t, sess2, ac1.currentSession()) | |
| 965 | 965 | 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] = sess1 | 956 | d.sessions[sess1.id] = sess1 | ||
| 957 | d.sessions[sess2.id] = sess2 | 957 | 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( | |||
| 275 | 275 | view := ac.viewSnapshot() | |
| 276 | 276 | window := domain.Size{} | |
| 277 | 277 | if view.windowSet { | |
| 278 | - | window = ac.size | |
| 278 | + | window = ac.sizeSnapshot() | |
| 279 | 279 | } | |
| 280 | 280 | *state = capturedRenderState{ | |
| 281 | 281 | 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.size | 278 | + | 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 { | |||
| 90 | 90 | // lease binds every delayed resize/retry callback to the exact attachment | |
| 91 | 91 | // incarnation that published this request. | |
| 92 | 92 | lease *attachmentLease | |
| 93 | + | geometryClaim uint64 // session geometry claim captured by this request | |
| 93 | 94 | epoch uint64 // latest requested epoch | |
| 94 | 95 | committed uint64 // latest published epoch | |
| 95 | 96 | retryAttempts uint8 | |
| @@ -90,6 +90,7 @@ type resizeRequestMetadata struct { | |||||
| 90 | // lease binds every delayed resize/retry callback to the exact attachment | 90 | // lease binds every delayed resize/retry callback to the exact attachment | ||
| 91 | // incarnation that published this request. | 91 | // incarnation that published this request. | ||
| 92 | lease *attachmentLease | 92 | lease *attachmentLease | ||
| 93 | + | geometryClaim uint64 // session geometry claim captured by this request | |||
| 93 | epoch uint64 // latest requested epoch | 94 | epoch uint64 // latest requested epoch | ||
| 94 | committed uint64 // latest published epoch | 95 | committed uint64 // latest published epoch | ||
| 95 | retryAttempts uint8 | 96 | retryAttempts uint8 | ||
M internal/usecase/daemon/render_resize_deadline.go +11 -2
| @@ -10,10 +10,14 @@ const maxResizeRetryAttempts = 3 | |||
| 10 | 10 | // Resize deadlines and retries retain coordinator-owned attachment, epoch, and | |
| 11 | 11 | // token validation at every callback effect boundary. | |
| 12 | 12 | func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 { | |
| 13 | - | return c.recordResizeRequestForLease(size, source, c.attachmentLease(source)) | |
| 13 | + | return c.recordResizeRequestForLeaseWithClaim(size, source, c.attachmentLease(source), 0) | |
| 14 | 14 | } | |
| 15 | 15 | ||
| 16 | 16 | func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source *attachedClient, lease *attachmentLease) uint64 { | |
| 17 | + | return c.recordResizeRequestForLeaseWithClaim(size, source, lease, 0) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (c *renderCoordinator) recordResizeRequestForLeaseWithClaim(size domain.Size, source *attachedClient, lease *attachmentLease, geometryClaim uint64) uint64 { | |
| 17 | 21 | c.mu.Lock() | |
| 18 | 22 | if !c.leaseCurrentLocked(lease, false) || lease.attachment != source { | |
| 19 | 23 | c.mu.Unlock() | |
| @@ -22,6 +26,7 @@ func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source | |||
| 22 | 26 | _, retry := c.retryLane.replaceLocked() | |
| 23 | 27 | c.resize.epoch++ | |
| 24 | 28 | c.resize.size, c.resize.source, c.resize.lease = size, source, lease | |
| 29 | + | c.resize.geometryClaim = geometryClaim | |
| 25 | 30 | c.resize.retryAttempts = 0 | |
| 26 | 31 | epoch := c.resize.epoch | |
| 27 | 32 | c.mu.Unlock() | |
| @@ -30,7 +35,11 @@ func (c *renderCoordinator) recordResizeRequestForLease(size domain.Size, source | |||
| 30 | 35 | } | |
| 31 | 36 | ||
| 32 | 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 | 43 | if epoch == 0 { | |
| 35 | 44 | return 0 | |
| 36 | 45 | } | |
| @@ -10,10 +10,14 @@ const maxResizeRetryAttempts = 3 | |||||
| 10 | // Resize deadlines and retries retain coordinator-owned attachment, epoch, and | 10 | // Resize deadlines and retries retain coordinator-owned attachment, epoch, and | ||
| 11 | // token validation at every callback effect boundary. | 11 | // token validation at every callback effect boundary. | ||
| 12 | func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 { | 12 | func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 { | ||
| 13 | - | return c.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, lease | 28 | c.resize.size, c.resize.source, c.resize.lease = size, source, lease | ||
| 29 | + | c.resize.geometryClaim = geometryClaim | |||
| 25 | c.resize.retryAttempts = 0 | 30 | c.resize.retryAttempts = 0 | ||
| 26 | epoch := c.resize.epoch | 31 | 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 0 | 44 | return 0 | ||
| 36 | } | 45 | } | ||
M internal/usecase/daemon/render_test.go +1 -1
| @@ -81,7 +81,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) { | |||
| 81 | 81 | floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8}) | |
| 82 | 82 | installTestFloating(testAttachmentTab(sess), floating, true) | |
| 83 | 83 | ||
| 84 | - | d.firstPaint(sess, ac, tc.clientSize) | |
| 84 | + | d.firstPaint(sess, ac) | |
| 85 | 85 | ||
| 86 | 86 | frame := awaitFrame(t, sends, ports.MsgOutput) | |
| 87 | 87 | 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 * | |||
| 96 | 96 | return false | |
| 97 | 97 | } | |
| 98 | 98 | if ac != nil { | |
| 99 | - | ac.size = size | |
| 99 | + | ac.setSize(size) | |
| 100 | 100 | } | |
| 101 | 101 | fences.Release() | |
| 102 | 102 | if ac != nil { | |
| @@ -96,7 +96,7 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac * | |||||
| 96 | return false | 96 | return false | ||
| 97 | } | 97 | } | ||
| 98 | if ac != nil { | 98 | if ac != nil { | ||
| 99 | - | ac.size = size | 99 | + | 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 { | |||
| 373 | 373 | captured := ac.transportSnapshot().transport | |
| 374 | 374 | ac.setSession(nil) | |
| 375 | 375 | d.mu.Unlock() | |
| 376 | + | if sess != nil { | |
| 377 | + | d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go") | |
| 378 | + | } | |
| 376 | 379 | d.watchParkedTimer(token, rearmed) | |
| 377 | 380 | if captured != nil { | |
| 378 | 381 | _ = ac.closeCapturedTransport(ac.revokeTransport(captured)) | |
| @@ -513,6 +516,7 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||
| 513 | 516 | if rc := sess.renderCoordinator(); rc != nil { | |
| 514 | 517 | rc.noteDetach(ac) | |
| 515 | 518 | } | |
| 519 | + | d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go") | |
| 516 | 520 | d.unregisterPreview(ac) | |
| 517 | 521 | if !d.parkAttachment(sess, ac) { | |
| 518 | 522 | // 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 | 615 | ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight)) | |
| 612 | 616 | ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding) | |
| 613 | 617 | ac.replaceTransport(tr) | |
| 614 | - | ac.size = sz | |
| 618 | + | ac.setSize(sz) | |
| 615 | 619 | ac.resumeToken = d.nextResumeTokenLocked() | |
| 616 | 620 | ac.parked = false | |
| 617 | 621 | // 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().transport | 373 | 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 so | 522 | // 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 = sz | 618 | + | ac.setSize(sz) |
| 615 | ac.resumeToken = d.nextResumeTokenLocked() | 619 | ac.resumeToken = d.nextResumeTokenLocked() | ||
| 616 | ac.parked = false | 620 | 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) { | |||
| 294 | 294 | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |
| 295 | 295 | ||
| 296 | 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 | 300 | require.NoError(t, err) | |
| 299 | 301 | require.True(t, sess.ephemeral) | |
| 300 | 302 | token := ac.resumeToken | |
| 301 | 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 | 319 | d.clientGone(sess, ac, ac.transport(), false) | |
| 304 | 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 | 330 | d.mu.Lock() | |
| 307 | 331 | _, parked := d.parked[token] | |
| 308 | 332 | d.mu.Unlock() | |
| 309 | 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 | 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 | 339 | require.NoError(t, err) | |
| 314 | 340 | require.Same(t, sess, resumedSess) | |
| 315 | 341 | require.Same(t, ac, resumedAC) | |
| 316 | 342 | require.NotEqual(t, token, resumedAC.resumeToken, "resume rotates token") | |
| 317 | 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 | 352 | // TestResumeParkedTokenReplacedDuringWaitFailsClosed covers the lifecycle race | |
| @@ -1138,7 +1170,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||
| 1138 | 1170 | require.Same(t, sess, resumedSess) | |
| 1139 | 1171 | require.Same(t, ac, resumedAC) | |
| 1140 | 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 | 1175 | sends := newTransport.Sends() | |
| 1144 | 1176 | 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.resumeToken | 302 | 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 race | 352 | // 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) { | |||
| 63 | 63 | // replacement transport has completed Welcome. | |
| 64 | 64 | rc.opts.wake = originalWake | |
| 65 | 65 | require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac))) | |
| 66 | - | d.firstPaint(sess, ac, ac.size) | |
| 66 | + | d.firstPaint(sess, ac) | |
| 67 | 67 | require.NotEmpty(t, newTransport.Sends()) | |
| 68 | 68 | } | |
| 69 | 69 | ||
| @@ -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 = originalWake | 64 | 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 | |||
| 398 | 398 | if err := domain.ValidateSessionName(name); err != nil { | |
| 399 | 399 | return err | |
| 400 | 400 | } | |
| 401 | - | sz := ac.size | |
| 401 | + | sz := ac.sizeSnapshot() | |
| 402 | 402 | d.mu.Lock() | |
| 403 | 403 | if d.closing { | |
| 404 | 404 | d.mu.Unlock() | |
| @@ -484,7 +484,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT | |||
| 484 | 484 | cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env) | |
| 485 | 485 | source.mu.Unlock() | |
| 486 | 486 | 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 | 488 | return created, createErr | |
| 489 | 489 | }, | |
| 490 | 490 | }) | |
| @@ -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 err | 399 | return err | ||
| 400 | } | 400 | } | ||
| 401 | - | sz := ac.size | 401 | + | 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 error | 486 | 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, createErr | 488 | return created, createErr | ||
| 489 | }, | 489 | }, | ||
| 490 | }) | 490 | }) | ||
M internal/usecase/daemon/session_core.go +11 -0
| @@ -10,6 +10,8 @@ import ( | |||
| 10 | 10 | // sessionCore is the state shared by every attachment-capable session. It is | |
| 11 | 11 | // embedded as the first field of session so the existing s.mu selector remains | |
| 12 | 12 | // the one lock guarding both attachment membership and local tab state. | |
| 13 | + | // Geometry claim paths use the partial order mu -> geometryMu -> sizeMu; no | |
| 14 | + | // path acquires an architectural lock while holding sizeMu. | |
| 13 | 15 | type sessionCore struct { | |
| 14 | 16 | mu sync.Mutex | |
| 15 | 17 | ||
| @@ -23,6 +25,15 @@ type sessionCore struct { | |||
| 23 | 25 | attachments map[*attachedClient]struct{} | |
| 24 | 26 | attachmentOrder map[*attachedClient]uint64 | |
| 25 | 27 | nextAttachmentID uint64 | |
| 28 | + | // geometryOwner is the attachment whose latest attach/resume/resize claim | |
| 29 | + | // controls shared PTY/layout geometry. The pointer is published atomically | |
| 30 | + | // so resize transactions can reject stale claims without taking session.mu. | |
| 31 | + | // geometryMu serializes claim publication with the final shared-layout commit. | |
| 32 | + | geometryMu sync.Mutex | |
| 33 | + | geometryOwner atomic.Pointer[attachedClient] | |
| 34 | + | // geometryClaimSeq is mutated while mu and geometryMu are held; its value | |
| 35 | + | // is copied into attachedClient.geometryClaim. | |
| 36 | + | geometryClaimSeq uint64 | |
| 26 | 37 | createdAt int64 | |
| 27 | 38 | incarnation domain.IncarnationID | |
| 28 | 39 | mruAt atomic.Uint64 | |
| @@ -10,6 +10,8 @@ import ( | |||||
| 10 | // sessionCore is the state shared by every attachment-capable session. It is | 10 | // sessionCore is the state shared by every attachment-capable session. It is | ||
| 11 | // embedded as the first field of session so the existing s.mu selector remains | 11 | // embedded as the first field of session so the existing s.mu selector remains | ||
| 12 | // the one lock guarding both attachment membership and local tab state. | 12 | // the one lock guarding both attachment membership and local tab state. | ||
| 13 | + | // Geometry claim paths use the partial order mu -> geometryMu -> sizeMu; no | |||
| 14 | + | // path acquires an architectural lock while holding sizeMu. | |||
| 13 | type sessionCore struct { | 15 | type sessionCore struct { | ||
| 14 | mu sync.Mutex | 16 | 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]uint64 | 26 | attachmentOrder map[*attachedClient]uint64 | ||
| 25 | nextAttachmentID uint64 | 27 | nextAttachmentID uint64 | ||
| 28 | + | // geometryOwner is the attachment whose latest attach/resume/resize claim | |||
| 29 | + | // controls shared PTY/layout geometry. The pointer is published atomically | |||
| 30 | + | // so resize transactions can reject stale claims without taking session.mu. | |||
| 31 | + | // geometryMu serializes claim publication with the final shared-layout commit. | |||
| 32 | + | geometryMu sync.Mutex | |||
| 33 | + | geometryOwner atomic.Pointer[attachedClient] | |||
| 34 | + | // geometryClaimSeq is mutated while mu and geometryMu are held; its value | |||
| 35 | + | // is copied into attachedClient.geometryClaim. | |||
| 36 | + | geometryClaimSeq uint64 | |||
| 26 | createdAt int64 | 37 | createdAt int64 | ||
| 27 | incarnation domain.IncarnationID | 38 | incarnation domain.IncarnationID | ||
| 28 | mruAt atomic.Uint64 | 39 | 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 | |||
| 582 | 582 | if valid && d.beforeSessionResizePublication != nil { | |
| 583 | 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 | 589 | if valid && sess.ctx != nil && sess.ctx.Err() != nil { | |
| 586 | 590 | valid = false | |
| 587 | 591 | } | |
| @@ -600,6 +604,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 600 | 604 | plan.tab.size = plan.size | |
| 601 | 605 | } | |
| 602 | 606 | } | |
| 607 | + | sess.geometryMu.Unlock() | |
| 603 | 608 | for i := len(tabs) - 1; i >= 0; i-- { | |
| 604 | 609 | tabs[i].mu.Unlock() | |
| 605 | 610 | } | |
| @@ -661,11 +666,20 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||
| 661 | 666 | return false | |
| 662 | 667 | } | |
| 663 | 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 | 677 | connectionGeneration := ac.connectionGeneration.Load() | |
| 665 | 678 | connection := ac.transportSnapshot() | |
| 666 | 679 | current := func() bool { | |
| 667 | 680 | return ac.connectionGeneration.Load() == connectionGeneration && | |
| 668 | 681 | ac.transportSnapshotCurrent(connection) && | |
| 682 | + | sess.geometryClaimTokenCurrent(ac, geometryClaim) && | |
| 669 | 683 | rc.resizeCurrentForLease(epoch, ac, lease, false) | |
| 670 | 684 | } | |
| 671 | 685 | if !current() { | |
| @@ -687,7 +701,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||
| 687 | 701 | ac.sendMu.Unlock() | |
| 688 | 702 | return false | |
| 689 | 703 | } | |
| 690 | - | ac.size = snap.size | |
| 704 | + | ac.setSize(snap.size) | |
| 691 | 705 | ac.sendMu.Unlock() | |
| 692 | 706 | if !current() { | |
| 693 | 707 | return false | |
| @@ -870,12 +884,21 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC | |||
| 870 | 884 | if rc == nil || !rc.leaseCurrent(lease, true) { | |
| 871 | 885 | return false | |
| 872 | 886 | } | |
| 887 | + | geometryClaim, claimed := sess.claimGeometryOwnerForSize(ac, size) | |
| 888 | + | if !claimed { | |
| 889 | + | return false | |
| 890 | + | } | |
| 873 | 891 | if immediate { | |
| 874 | - | epoch := rc.recordResizeRequestForLease(size, ac, lease) | |
| 892 | + | epoch := rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim) | |
| 875 | 893 | if epoch == 0 { | |
| 894 | + | sess.releaseGeometryClaim(ac, geometryClaim) | |
| 876 | 895 | return false | |
| 877 | 896 | } | |
| 878 | 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) }) != 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 | |
| 881 | 904 | } | |
| @@ -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 = false | 590 | valid = false | ||
| 587 | } | 591 | } | ||
| @@ -600,6 +604,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 600 | plan.tab.size = plan.size | 604 | 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 false | 666 | 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 false | 702 | return false | ||
| 689 | } | 703 | } | ||
| 690 | - | ac.size = snap.size | 704 | + | ac.setSize(snap.size) |
| 691 | ac.sendMu.Unlock() | 705 | ac.sendMu.Unlock() | ||
| 692 | if !current() { | 706 | if !current() { | ||
| 693 | return false | 707 | 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 false | 885 | 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 false | 895 | 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) }) != 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 | |||
| 881 | } | 904 | } | ||