1package daemon
2
3import (
4 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/ports"
6)
7
8// requestResize reports reset invalidation completion for immediate
9// attached requests, and coordinator schedule acceptance for async requests.
10// Headless requests have no reset invalidation and report geometry completion.
11func (g *sharedPTYGeometry) requestResize(d *Daemon, sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
12 if ac == nil {
13 return g.requestResizeForLease(d, sess, nil, nil, size, immediate)
14 }
15 rc := d.attachCoordinator(sess, nil, ac, true)
16 return g.requestResizeForLease(d, sess, ac, rc.attachmentLease(ac), size, immediate)
17}
18
19// requestResizeForLease carries the Connection's captured lease
20// through the full resize transaction and every delayed retry callback.
21func (g *sharedPTYGeometry) requestResizeForLease(d *Daemon, sess *session, ac *attachedClient, lease *attachmentLease, size domain.Size, immediate bool) bool {
22 valid := sess != nil && size.Valid()
23 d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)
24 if !valid {
25 return false
26 }
27 if ac == nil {
28 // Headless geometry has no coordinator/transport to coalesce, but keeps
29 // the same prepare/apply/commit ordering.
30 _, ok := g.applySessionLayout(d, sess, size, nil, nil)
31 return ok
32 }
33 rc := sess.renderCoordinator()
34 if rc == nil || !rc.leaseCurrent(lease, true) {
35 return false
36 }
37 geometryClaim, claimed := g.claimSize(sess, ac, size)
38 if !claimed {
39 return false
40 }
41 resize := attachmentResize{d: d, sess: sess, ac: ac, lease: lease}
42 if immediate {
43 resize.epoch = rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim)
44 if resize.epoch == 0 {
45 g.release(sess, geometryClaim)
46 return false
47 }
48 return resize.run()
49 }
50 epoch := rc.scheduleResizeForLeaseWithClaim(size, ac, lease, geometryClaim, func(epoch uint64) {
51 accepted := resize
52 accepted.epoch = epoch
53 accepted.run()
54 })
55 if epoch == 0 {
56 g.release(sess, geometryClaim)
57 }
58 return epoch != 0
59}
60
61// attachmentResize is one accepted resize of an attachment's session
62// geometry. It owns the complete lifecycle after the coordinator admits an
63// epoch: the session layout transaction (applySessionLayout), attachment size
64// publication, bounded background retries for members whose PTY resize
65// failed, and the single reset frame. The coordinator keeps scheduling and
66// epoch fencing; this type keeps what an accepted epoch does.
67type attachmentResize struct {
68 d *Daemon
69 sess *session
70 ac *attachedClient
71 lease *attachmentLease
72 epoch uint64
73}
74
75func (r attachmentResize) retryCurrent(rc *renderCoordinator) bool {
76 return rc.retryCurrentForLease(r.epoch, r.ac, r.lease)
77}
78
79func (r attachmentResize) scheduleRetry(rc *renderCoordinator, failed []resizeMember) {
80 rc.scheduleResizeRetryForLease(r.epoch, r.ac, r.lease, func() { r.retry(failed) })
81}
82
83// run is the accepted attachment resize: claim shared geometry, apply the
84// session layout transaction, publish the attachment size, schedule retries
85// for failed members, and emit exactly one reset frame. Every step is fenced
86// by the attachment effect, the geometry claim, and the resize epoch.
87func (r attachmentResize) run() bool {
88 d, sess, ac, lease, epoch := r.d, r.sess, r.ac, r.lease, r.epoch
89 g := &sess.geometry
90 rc := sess.renderCoordinator()
91 if rc == nil {
92 return false
93 }
94 snap := rc.resizeSnapshot()
95 geometryClaim := snap.geometryClaim
96 if geometryClaim == nil {
97 var claimed bool
98 geometryClaim, claimed = g.claimSize(sess, ac, snap.size)
99 if !claimed {
100 return false
101 }
102 }
103 effect, admitted := beginAttachmentLeaseEffect(sess, ac, lease)
104 if !admitted {
105 return false
106 }
107 defer effect.End()
108 current := func() bool {
109 return effect.current() && sess.geometry.current(geometryClaim) &&
110 rc.resizeCurrentForLease(epoch, ac, lease, false)
111 }
112 if !current() {
113 return false
114 }
115 d.exitCopyMode(ac)
116 failed, ok := g.applySessionLayout(d, sess, snap.size, current, func() bool {
117 return rc.resizeCurrentForLease(epoch, ac, lease, true)
118 })
119 if !ok {
120 return false
121 }
122 // Revalidate while holding the attachment output boundary. A lease can be
123 // retired after the layout commit but before this attachment-local size is
124 // published; stale generations must not publish any remaining effects.
125 d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
126 ac.sendMu.Lock()
127 if !current() {
128 ac.sendMu.Unlock()
129 return false
130 }
131 ac.setSize(snap.size)
132 ac.sendMu.Unlock()
133 if !current() {
134 return false
135 }
136 if len(failed) != 0 && rc.resizeRetryAvailableForLease(r.epoch, r.ac, r.lease) {
137 r.scheduleRetry(rc, failed)
138 }
139 if !current() {
140 return false
141 }
142 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
143 // A successful transaction publishes exactly one full S2 frame. The
144 // coordinator is the only emission route and stale epochs never reach it.
145 if !current() || !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "attachment_resize.go"}) {
146 return false
147 }
148 // The resize debounce has already elapsed. Consume this sticky reset now;
149 // fire preserves ACK and synchronized-output gates rather than scheduling a
150 // second urgent deadline.
151 if !current() {
152 return false
153 }
154 rc.fireCurrent(false)
155 return true
156}
157
158// retryResizeMembers retries failed committed members through a freshly
159// prepared tab transaction. The captured members identify retry candidates
160// only: their rectangles must never cross this delayed boundary, because any
161// later layout mutation may have changed their geometry or removed them.
162func (r attachmentResize) retry(members []resizeMember) {
163 d, sess := r.d, r.sess
164 g := &sess.geometry
165 rc := sess.renderCoordinator()
166 if rc == nil || !r.retryCurrent(rc) {
167 return
168 }
169
170 // Retain tab order for deterministic external PTY ordering while collapsing
171 // several failed members from the same tab into one canonical transaction.
172 tabs := make([]*tab, 0, len(members))
173 seen := make(map[*tab]struct{}, len(members))
174 for _, member := range members {
175 if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) {
176 continue
177 }
178 if _, ok := seen[member.tab]; ok {
179 continue
180 }
181 seen[member.tab] = struct{}{}
182 tabs = append(tabs, member.tab)
183 }
184
185 failed := make([]resizeMember, 0, len(members))
186 succeeded := false
187 for _, tb := range tabs {
188 if !r.retryCurrent(rc) {
189 return
190 }
191 // A later accepted layout transaction clears resizeRetry. In that case
192 // this delayed callback has no remaining work and, importantly, must not
193 // replay the obsolete rectangle it captured before the mutation.
194 retryPending := false
195 tb.mu.Lock()
196 for _, member := range members {
197 if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
198 continue
199 }
200 member.pane.mu.Lock()
201 retryPending = retryPending || member.pane.resizeRetry
202 member.pane.mu.Unlock()
203 }
204 tb.mu.Unlock()
205 if !retryPending {
206 continue
207 }
208
209 // applyTabLayoutTransaction captures the current generation, pane
210 // pointers, and solved rectangles, and validates them again before any
211 // VT/rectangle publication. It also preserves the resize gate and
212 // degradation notice behavior for another failed external attempt.
213 freshFailed, ok := g.applyTabLayoutTransaction(d, sess, tb, func() bool {
214 return r.retryCurrent(rc)
215 })
216 if !ok || !r.retryCurrent(rc) {
217 return
218 }
219 failed = append(failed, freshFailed...)
220 // A retry success is a formerly failed, still-current target whose
221 // canonical apply cleared its retry bit. A collapsed or removed target
222 // is neither a retry completion nor a reason to publish a reset.
223 tb.mu.Lock()
224 for _, member := range members {
225 if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
226 continue
227 }
228 member.pane.mu.Lock()
229 succeeded = succeeded || !member.pane.resizeRetry
230 member.pane.mu.Unlock()
231 }
232 tb.mu.Unlock()
233 }
234 for _, member := range members {
235 if !member.isFloating || member.tab == nil || member.pane == nil {
236 continue
237 }
238 if !r.retryCurrent(rc) {
239 return
240 }
241 // Floating panes are outside tb.panes. Validate the exact accepted slot
242 // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
243 // the same pointer/generation check around external PTY.Resize.
244 member.tab.mu.Lock()
245 currentSlot := member.tab.floating.state == floatingVisible &&
246 member.tab.floating.generation == member.floatingGeneration &&
247 member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
248 retryPending := false
249 if currentSlot {
250 member.pane.mu.Lock()
251 retryPending = member.pane.resizeRetry
252 member.pane.mu.Unlock()
253 }
254 member.tab.mu.Unlock()
255 if !currentSlot || !retryPending {
256 continue
257 }
258 freshFailed, ok := g.applyVisibleFloatingLayoutForMember(d, sess, member.tab, func() bool {
259 return r.retryCurrent(rc)
260 }, &member)
261 if !ok || !r.retryCurrent(rc) {
262 return
263 }
264 failed = append(failed, freshFailed...)
265 member.tab.mu.Lock()
266 stillCurrent := member.tab.floating.state == floatingVisible &&
267 member.tab.floating.generation == member.floatingGeneration &&
268 member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
269 member.tab.mu.Unlock()
270 if stillCurrent && len(freshFailed) == 0 {
271 member.pane.mu.Lock()
272 succeeded = succeeded || !member.pane.resizeRetry
273 member.pane.mu.Unlock()
274 }
275 }
276 if len(failed) != 0 && rc.resizeRetryAvailableForLease(r.epoch, r.ac, r.lease) {
277 r.scheduleRetry(rc, failed)
278 }
279 if succeeded {
280 // Retry completion changes VT state after the original layout commit.
281 // Keep a named session's eventual snapshot generation aligned with it.
282 markSnapshotDirty(sess)
283 rc.invalidateForLease(r.ac, r.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "attachment_resize.go"})
284 }
285}