1package daemon
2
3import (
4 "context"
5
6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/layout"
9)
10
11type resizeOwnerPostEffect uint8
12
13const (
14 resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota
15 resizeOwnerPostRetrySchedule
16 resizeOwnerPostRenderInvalidation
17 resizeOwnerPostCommitPublication
18)
19
20// resizeMember is an unpublished layout decision. Prepare only reads guarded
21// state; apply performs the external PTY call; commit is the sole publisher.
22type resizeMember struct {
23 session *session
24 tab *tab
25 pane *pane
26 owner paneEffectLease
27 rect domain.Rect
28 floating floatingGeometry
29 isFloating bool
30 // floatingGeneration identifies the exact popup slot accepted by a failed
31 // resize. Floating panes are intentionally outside tb.panes, so retries
32 // must validate this generation and pointer rather than use tiled lookup.
33 floatingGeneration uint64
34 retry bool
35 ok bool
36 err error
37 screenResized bool
38}
39
40type preparedTabLayout struct {
41 tab *tab
42 generation uint64
43 size domain.Size
44 previousSize domain.Size
45 members []resizeMember
46}
47
48func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout {
49 return prepareTabLayoutForSizeLocked(sess, tb, tb.size)
50}
51
52func prepareTabLayoutForSizeLocked(sess *session, tb *tab, size domain.Size) preparedTabLayout {
53 plan := preparedTabLayout{tab: tb, generation: tb.layoutGeneration, size: size, previousSize: tb.size}
54 if tb.tree == nil || tb.tree.Root == nil || !size.Valid() {
55 return plan
56 }
57 area := domain.Rect{Width: size.Cols, Height: size.Rows}
58 placements, ok := layout.Solve(tb.tree.Root, area)
59 if !ok && tb.tree.Root.Kind == layout.Leaf {
60 placements = []layout.Placement{{ID: tb.tree.Root.Leaf, Content: area}}
61 ok = true
62 }
63 if !ok {
64 return plan
65 }
66 for _, placement := range placements {
67 if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 {
68 continue
69 }
70 if p := tb.panes[placement.ID]; p != nil {
71 p.mu.Lock()
72 owner := p.effectLeaseLocked()
73 p.mu.Unlock()
74 plan.members = append(plan.members, resizeMember{session: sess, tab: tb, pane: p, owner: owner, rect: placement.Content})
75 }
76 }
77 return plan
78}
79
80// applyPreparedTabMembers performs only the external PTY phase. A successful
81// resize leaves the parser gate closed until the complete plan is validated.
82func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) {
83 for i := range plan.members {
84 member := &plan.members[i]
85 p := member.pane
86 p.resizeMu.Lock()
87 p.mu.Lock()
88 if !resizeMemberOwnerCurrent(member) {
89 member.retry = p.owner.Load() != nil
90 p.resizeRetry = member.retry
91 p.mu.Unlock()
92 p.resizeMu.Unlock()
93 continue
94 }
95 old := p.rect
96 pty := p.pty
97 // A rejected earlier attempt deliberately leaves its parser gate open.
98 // Reapply even if the committed rectangle already matches: the PTY may
99 // still be at that rejected attempt's external size. An accepted failed
100 // attempt records resizeRetry for the same reason after its gate closes.
101 needsApply := p.resizeApplying || p.resizeRetry || old.Width != member.rect.Width || old.Height != member.rect.Height
102 if needsApply && pty != nil {
103 p.resizeApplying = true
104 }
105 p.mu.Unlock()
106 if !needsApply || pty == nil {
107 member.ok = true
108 } else if err := pty.Resize(rectSize(member.rect)); err != nil {
109 d.log.Warn("pty resize failed", "err", err)
110 p.mu.Lock()
111 current := resizeMemberOwnerCurrent(member)
112 p.resizeRetry = true
113 p.mu.Unlock()
114 d.replayResizePending(member.session, member.tab, p, false, member.rect)
115 member.retry = true
116 if current {
117 member.err = err
118 }
119 } else {
120 p.mu.Lock()
121 current := resizeMemberOwnerCurrent(member)
122 if !current && p.owner.Load() != nil {
123 p.resizeRetry = true
124 member.retry = true
125 }
126 p.mu.Unlock()
127 member.ok = current
128 member.screenResized = true // records that this member owns an open gate
129 }
130 p.resizeMu.Unlock()
131 }
132}
133
134func resizeMemberOwnerCurrent(member *resizeMember) bool {
135 if member == nil || member.pane == nil {
136 return false
137 }
138 if member.owner.owner == nil {
139 return true
140 }
141 owner := member.pane.owner.Load()
142 return owner == member.owner.owner && owner.session == member.session && owner.tab == member.tab
143}
144
145func validatePreparedTabLayoutLocked(tb *tab, plan *preparedTabLayout) bool {
146 if tb.layoutGeneration != plan.generation || tb.size != plan.previousSize {
147 return false
148 }
149 for i := range plan.members {
150 member := &plan.members[i]
151 if tb.panes[member.pane.id] != member.pane || !resizeMemberOwnerCurrent(member) {
152 return false
153 }
154 }
155 return true
156}
157
158func resizeMembersOwnerCurrent(members []resizeMember) bool {
159 for i := range members {
160 if !resizeMemberOwnerCurrent(&members[i]) {
161 return false
162 }
163 }
164 return true
165}
166
167func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {
168 for i := range plan.members {
169 member := &plan.members[i]
170 member.pane.mu.Lock()
171 current := resizeMemberOwnerCurrent(member)
172 if !current {
173 member.pane.resizeRetry = member.pane.owner.Load() != nil
174 member.retry = member.pane.resizeRetry
175 }
176 member.pane.mu.Unlock()
177 if !current {
178 return false
179 }
180 }
181 for i := range plan.members {
182 member := &plan.members[i]
183 member.pane.mu.Lock()
184 member.pane.rect = member.rect
185 if member.ok {
186 member.pane.resizeRetry = false
187 member.pane.screen.Resize(member.rect.Width, member.rect.Height)
188 }
189 member.pane.mu.Unlock()
190 }
191 return true
192}
193
194// cancelStalePreparedGates releases gates for members which disappeared from
195// the next solved layout (for example a stack member collapsed by a focus
196// change). Members that remain are deliberately left gated for the retry.
197func (d *Daemon) cancelStalePreparedGates(sess *session, tb *tab, plan *preparedTabLayout) {
198 tb.mu.Lock()
199 latest := prepareTabLayoutLocked(sess, tb)
200 tb.mu.Unlock()
201 current := make(map[*pane]struct{}, len(latest.members))
202 for i := range latest.members {
203 current[latest.members[i].pane] = struct{}{}
204 }
205 for i := range plan.members {
206 member := &plan.members[i]
207 if !member.screenResized {
208 continue
209 }
210 if _, ok := current[member.pane]; ok {
211 continue
212 }
213 member.pane.resizeMu.Lock()
214 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
215 member.pane.resizeMu.Unlock()
216 }
217}
218
219func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool) {
220 if !accepted {
221 // cancelStalePreparedGates owns stale-plan cancellation. Keeping this
222 // path empty ensures a removed member's pending bytes are replayed once,
223 // after the fresh layout determines it cannot survive the retry.
224 return
225 }
226 for i := range plan.members {
227 member := &plan.members[i]
228 if !member.screenResized {
229 continue
230 }
231 member.pane.resizeMu.Lock()
232 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
233 member.pane.resizeMu.Unlock()
234 }
235}
236
237// applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns
238// one per-tab single-writer loop, but never holds tab or pane state locks while
239// invoking PTY.Resize.
240func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) {
241 return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...)
242}
243
244func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) {
245 if tb == nil {
246 return nil, false
247 }
248 tb.layoutApplyMu.Lock()
249 defer tb.layoutApplyMu.Unlock()
250 for {
251 if tb.ctx != nil && tb.ctx.Err() != nil {
252 return nil, false
253 }
254 if len(current) != 0 && !current[0]() {
255 return nil, false
256 }
257 tb.mu.Lock()
258 plan := prepareTabLayoutLocked(sess, tb)
259 tb.mu.Unlock()
260 d.applyPreparedTabMembers(&plan)
261
262 tb.mu.Lock()
263 accepted := validatePreparedTabLayoutLocked(tb, &plan)
264 if len(current) != 0 && !current[0]() {
265 accepted = false
266 }
267 if accepted {
268 accepted = commitPreparedTabLayoutLocked(&plan)
269 }
270 tb.mu.Unlock()
271 d.finishPreparedTabMembers(&plan, accepted)
272 if !accepted {
273 d.cancelStalePreparedGates(sess, tb, &plan)
274 if len(current) != 0 && !current[0]() {
275 for i := range plan.members {
276 member := &plan.members[i]
277 if !member.screenResized {
278 continue
279 }
280 member.pane.resizeMu.Lock()
281 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
282 member.pane.resizeMu.Unlock()
283 }
284 return nil, false
285 }
286 continue
287 }
288 failed := make([]resizeMember, 0)
289 for _, member := range plan.members {
290 if !member.ok {
291 failed = append(failed, member)
292 }
293 }
294 if reportFailure && len(failed) != 0 && resizeMembersOwnerCurrent(failed) {
295 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
296 "pane resize failed; retrying in background", failed[len(failed)-1].err)
297 }
298 return failed, true
299 }
300}
301
302func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool {
303 failed, ok := d.applyTabLayoutTransaction(sess, tb)
304 if !ok {
305 return false
306 }
307 markSnapshotDirty(sess)
308 if len(failed) != 0 {
309 d.scheduleAcceptedTabLayoutRetry(sess, tb)
310 }
311 return true
312}
313
314const maxAcceptedTabLayoutRetries = 3
315
316// scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
317// worker is deduplicated, derives cancellation from the tab lifecycle, and
318// suppresses repeat degradation notices after the accepted initial failure.
319func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
320 if d.clock == nil || tb == nil || tb.ctx == nil {
321 return
322 }
323 tb.layoutRetryMu.Lock()
324 if tb.layoutRetryRunning {
325 tb.layoutRetryMu.Unlock()
326 return
327 }
328 ctx, cancel := context.WithCancel(tb.ctx)
329 tb.layoutRetryRunning = true
330 tb.layoutRetryCancel = cancel
331 tb.layoutRetryMu.Unlock()
332
333 go func() {
334 defer func() {
335 cancel()
336 tb.layoutRetryMu.Lock()
337 tb.layoutRetryRunning = false
338 tb.layoutRetryCancel = nil
339 tb.layoutRetryMu.Unlock()
340 }()
341 for range maxAcceptedTabLayoutRetries {
342 timer := d.clock.NewTimer(minOutputRenderDeadline)
343 if timer == nil {
344 return
345 }
346 timerC := timer.C()
347 if timerC == nil {
348 timer.Stop()
349 return
350 }
351 select {
352 case <-ctx.Done():
353 timer.Stop()
354 return
355 case <-timerC:
356 }
357 if ctx.Err() != nil {
358 return
359 }
360 failed, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { return ctx.Err() == nil })
361 if !ok || len(failed) == 0 {
362 return
363 }
364 markSnapshotDirty(sess)
365 for _, ac := range sess.snapshotAttachments() {
366 d.invalidateRender(sess, ac, true, "transactional_resize.go")
367 }
368 }
369 }()
370}
371
372// replayResizePending completes a pane's apply epoch. It keeps the gate set
373// while each buffered batch goes through the normal VT path, so reads arriving
374// during replay join the same ordered stream. Failure deliberately omits the
375// Screen.Resize, parsing every byte at the old dimensions.
376func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bool, size domain.Rect) {
377 first := true
378 for {
379 p.mu.Lock()
380 if first && resized {
381 p.screen.Resize(size.Width, size.Height)
382 }
383 first = false
384 data := append([]byte(nil), p.resizePending...)
385 p.resizePending = p.resizePending[:0]
386 if len(data) == 0 {
387 p.resizeApplying = false
388 p.mu.Unlock()
389 return
390 }
391 p.mu.Unlock()
392 if sess == nil {
393 p.mu.Lock()
394 p.screen.Write(data)
395 p.refreshTerminalTitleLocked()
396 p.mu.Unlock()
397 continue
398 }
399 d.processPTYData(sess, tb, p, data, false)
400 }
401}
402
403// applyVisibleFloatingLayout retains floating panes' existing independent
404// lifecycle while routing their external resize through the same lock-free gate
405// used for retryable PTY work. Floating state is not part of a tiled layout
406// generation, so its slot generation and exact pane identity validate its
407// publication instead.
408func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) {
409 return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil)
410}
411
412// applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the
413// exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes,
414// so accepting a replacement here would silently retry unrelated geometry.
415func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) {
416 if tb == nil {
417 return nil, true
418 }
419 tb.mu.Lock()
420 if tb.floating.state != floatingVisible || tb.floating.pane == nil ||
421 (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) {
422 tb.mu.Unlock()
423 return nil, true
424 }
425 p := tb.floating.pane
426 generation := tb.floating.generation
427 size := tb.size
428 geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())
429 tb.mu.Unlock()
430 if !geometry.committable() {
431 return nil, true
432 }
433 if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
434 // A drawer can validly reserve only its separator row. Publish that
435 // presentation without inventing a physical PTY size; the last usable
436 // rectangle and screen remain committed until a later resize has content.
437 tb.mu.Lock()
438 currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation &&
439 tb.floating.pane == p && tb.size == size
440 if current != nil && !current() {
441 currentSlot = false
442 }
443 if currentSlot {
444 p.mu.Lock()
445 p.popupGeometry = geometry
446 p.mu.Unlock()
447 tb.mu.Unlock()
448 return nil, true
449 }
450 tb.mu.Unlock()
451 if current != nil && !current() {
452 return nil, false
453 }
454 return nil, true
455 }
456
457 // This keeps successful PTY resizes gated until the
458 // floating slot and tab size are revalidated. A newer client resize may
459 // otherwise publish this obsolete popup geometry after its PTY call returns.
460 p.mu.Lock()
461 owner := p.effectLeaseLocked()
462 p.mu.Unlock()
463 plan := preparedTabLayout{members: []resizeMember{{
464 session: sess, tab: tb, pane: p, owner: owner, rect: geometry.ptyRect(), floating: geometry, isFloating: true, floatingGeneration: generation,
465 }}}
466 d.applyPreparedTabMembers(&plan)
467
468 // The PTY may have accepted an intermediate size, but a hidden, replaced,
469 // relaunched, or resized slot must never receive this attempt's geometry.
470 tb.mu.Lock()
471 currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size && resizeMemberOwnerCurrent(&plan.members[0])
472 if current != nil && !current() {
473 currentSlot = false
474 }
475 if currentSlot && plan.members[0].ok {
476 p.mu.Lock()
477 currentSlot = resizeMemberOwnerCurrent(&plan.members[0])
478 if currentSlot {
479 p.rect = plan.members[0].rect
480 p.popupGeometry = geometry
481 p.resizeRetry = false
482 p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height)
483 }
484 p.mu.Unlock()
485 }
486 tb.mu.Unlock()
487 if !currentSlot {
488 // This attempt has no tiled transaction loop to retain the gate for, so
489 // discard its buffered bytes at the old screen size before returning.
490 for i := range plan.members {
491 member := &plan.members[i]
492 if !member.screenResized {
493 continue
494 }
495 member.pane.resizeMu.Lock()
496 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
497 member.pane.resizeMu.Unlock()
498 }
499 if current != nil && !current() {
500 return nil, false
501 }
502 return nil, true
503 }
504 if plan.members[0].ok {
505 d.finishPreparedTabMembers(&plan, true)
506 return nil, true
507 }
508 return plan.members, true
509}
510
511// releasePreparedSessionGates abandons a session attempt which cannot be
512// retried here (for example because a newer coordinator epoch won). Unlike a
513// stale per-tab retry, no succeeding attempt is owned by this call, so every
514// successful external resize must reopen its parser gate at the old screen
515// size.
516func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) {
517 for _, plan := range plans {
518 if plan == nil {
519 continue
520 }
521 for i := range plan.members {
522 member := &plan.members[i]
523 if !member.screenResized {
524 continue
525 }
526 member.pane.resizeMu.Lock()
527 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
528 member.pane.resizeMu.Unlock()
529 }
530 }
531}
532
533// applySessionLayout keeps client resize publication as a two-phase session
534// transaction: all PTYs are applied and plans validated first, then the
535// coordinator admits the epoch before any tab size, rectangle, VT screen,
536// snapshot dirtiness, or resize telemetry becomes visible.
537func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) {
538 if sess == nil {
539 return nil, false
540 }
541 sess.layoutApplyMu.Lock()
542 defer sess.layoutApplyMu.Unlock()
543
544 target := contentSize(size)
545 for {
546 if sess.ctx != nil && sess.ctx.Err() != nil {
547 return nil, false
548 }
549 if current != nil && !current() {
550 return nil, false
551 }
552 sess.mu.Lock()
553 tabs := append([]*tab(nil), sess.tabs...)
554 sess.mu.Unlock()
555 plans := make([]*preparedTabLayout, 0, len(tabs))
556 for _, tb := range tabs {
557 tb.mu.Lock()
558 plan := prepareTabLayoutForSizeLocked(sess, tb, target)
559 tb.mu.Unlock()
560 plans = append(plans, &plan)
561 }
562 for _, plan := range plans {
563 d.applyPreparedTabMembers(plan)
564 }
565
566 // Hold every tab lock across final validation, epoch admission, and all
567 // publication. This makes the session commit indivisible to layout
568 // mutators while still keeping PTY.Resize outside every state lock.
569 for _, tb := range tabs {
570 tb.mu.Lock()
571 }
572 valid := true
573 for _, plan := range plans {
574 if !validatePreparedTabLayoutLocked(plan.tab, plan) {
575 valid = false
576 break
577 }
578 }
579 // This is the final external-apply validation boundary. Tests use the
580 // seam to install a newer epoch here; that epoch must reject admission
581 // before this attempt publishes any session geometry.
582 if valid && d.beforeSessionResizePublication != nil {
583 d.beforeSessionResizePublication()
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()
589 if valid && sess.ctx != nil && sess.ctx.Err() != nil {
590 valid = false
591 }
592 if valid && current != nil && !current() {
593 valid = false
594 }
595 if valid && admit != nil && !admit() {
596 valid = false
597 }
598 if valid {
599 for _, plan := range plans {
600 if !commitPreparedTabLayoutLocked(plan) {
601 valid = false
602 break
603 }
604 plan.tab.size = plan.size
605 }
606 }
607 sess.geometryMu.Unlock()
608 for i := len(tabs) - 1; i >= 0; i-- {
609 tabs[i].mu.Unlock()
610 }
611 if !valid {
612 d.releasePreparedSessionGates(plans)
613 // Headless requests have no attachment epoch. A live tab mutation
614 // still invalidates their plan, but must trigger a fresh prepare rather
615 // than being mistaken for a canceled request.
616 if current != nil && !current() {
617 return nil, false
618 }
619 // A layout mutation invalidated the plans while this epoch remains
620 // current. Reapply the fresh session geometry before admitting it.
621 continue
622 }
623
624 failed := make([]resizeMember, 0)
625 for _, plan := range plans {
626 d.finishPreparedTabMembers(plan, true)
627 for _, member := range plan.members {
628 if !member.ok {
629 failed = append(failed, member)
630 }
631 }
632 }
633 for _, tb := range tabs {
634 floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tb, current)
635 if !ok {
636 return nil, false
637 }
638 failed = append(failed, floatingFailed...)
639 }
640 failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)
641 if current != nil && !current() {
642 return nil, false
643 }
644 if len(failed) != 0 && failedCurrent {
645 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
646 "pane resize failed; retrying in background", failed[len(failed)-1].err)
647 }
648 if len(failed) != 0 && !failedCurrent {
649 return nil, false
650 }
651 if current != nil {
652 d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostSnapshotDirty)
653 if !current() {
654 return nil, false
655 }
656 }
657 markSnapshotDirty(sess)
658 d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
659 return failed, true
660 }
661}
662
663func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
664 rc := sess.renderCoordinator()
665 if rc == nil {
666 return false
667 }
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 }
677 connectionGeneration := ac.connectionGeneration.Load()
678 connection := ac.transportSnapshot()
679 current := func() bool {
680 return ac.connectionGeneration.Load() == connectionGeneration &&
681 ac.transportSnapshotCurrent(connection) &&
682 sess.geometryClaimTokenCurrent(ac, geometryClaim) &&
683 rc.resizeCurrentForLease(epoch, ac, lease, false)
684 }
685 if !current() {
686 return false
687 }
688 d.exitCopyMode(ac)
689 failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
690 return rc.resizeCurrentForLease(epoch, ac, lease, true)
691 })
692 if !ok {
693 return false
694 }
695 // Revalidate while holding the attachment output boundary. A lease can be
696 // retired after the layout commit but before this attachment-local size is
697 // published; stale generations must not publish any remaining effects.
698 d.observeBeforeResizeOwnerPostEffect(resizeOwnerPostCommitPublication)
699 ac.sendMu.Lock()
700 if !current() {
701 ac.sendMu.Unlock()
702 return false
703 }
704 ac.setSize(snap.size)
705 ac.sendMu.Unlock()
706 if !current() {
707 return false
708 }
709 if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
710 rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
711 }
712 if !current() {
713 return false
714 }
715 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
716 // A successful transaction publishes exactly one full S2 frame. The
717 // coordinator is the only emission route and stale epochs never reach it.
718 if !current() || !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
719 return false
720 }
721 // The resize debounce has already elapsed. Consume this sticky reset now;
722 // fire preserves ACK and synchronized-output gates rather than scheduling a
723 // second urgent deadline.
724 if !current() {
725 return false
726 }
727 rc.fireCurrent(false)
728 return true
729}
730
731// retryResizeMembers retries failed committed members through a freshly
732// prepared tab transaction. The captured members identify retry candidates
733// only: their rectangles must never cross this delayed boundary, because any
734// later layout mutation may have changed their geometry or removed them.
735func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
736 rc := sess.renderCoordinator()
737 if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
738 return
739 }
740
741 // Retain tab order for deterministic external PTY ordering while collapsing
742 // several failed members from the same tab into one canonical transaction.
743 tabs := make([]*tab, 0, len(members))
744 seen := make(map[*tab]struct{}, len(members))
745 for _, member := range members {
746 if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) {
747 continue
748 }
749 if _, ok := seen[member.tab]; ok {
750 continue
751 }
752 seen[member.tab] = struct{}{}
753 tabs = append(tabs, member.tab)
754 }
755
756 failed := make([]resizeMember, 0, len(members))
757 succeeded := false
758 for _, tb := range tabs {
759 if !rc.retryCurrentForLease(epoch, ac, lease) {
760 return
761 }
762 // A later accepted layout transaction clears resizeRetry. In that case
763 // this delayed callback has no remaining work and, importantly, must not
764 // replay the obsolete rectangle it captured before the mutation.
765 retryPending := false
766 tb.mu.Lock()
767 for _, member := range members {
768 if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
769 continue
770 }
771 member.pane.mu.Lock()
772 retryPending = retryPending || member.pane.resizeRetry
773 member.pane.mu.Unlock()
774 }
775 tb.mu.Unlock()
776 if !retryPending {
777 continue
778 }
779
780 // applyTabLayoutTransaction captures the current generation, pane
781 // pointers, and solved rectangles, and validates them again before any
782 // VT/rectangle publication. It also preserves the resize gate and
783 // degradation notice behavior for another failed external attempt.
784 freshFailed, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {
785 return rc.retryCurrentForLease(epoch, ac, lease)
786 })
787 if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
788 return
789 }
790 failed = append(failed, freshFailed...)
791 // A retry success is a formerly failed, still-current target whose
792 // canonical apply cleared its retry bit. A collapsed or removed target
793 // is neither a retry completion nor a reason to publish a reset.
794 tb.mu.Lock()
795 for _, member := range members {
796 if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
797 continue
798 }
799 member.pane.mu.Lock()
800 succeeded = succeeded || !member.pane.resizeRetry
801 member.pane.mu.Unlock()
802 }
803 tb.mu.Unlock()
804 }
805 for _, member := range members {
806 if !member.isFloating || member.tab == nil || member.pane == nil {
807 continue
808 }
809 if !rc.retryCurrentForLease(epoch, ac, lease) {
810 return
811 }
812 // Floating panes are outside tb.panes. Validate the exact accepted slot
813 // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
814 // the same pointer/generation check around external PTY.Resize.
815 member.tab.mu.Lock()
816 currentSlot := member.tab.floating.state == floatingVisible &&
817 member.tab.floating.generation == member.floatingGeneration &&
818 member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
819 retryPending := false
820 if currentSlot {
821 member.pane.mu.Lock()
822 retryPending = member.pane.resizeRetry
823 member.pane.mu.Unlock()
824 }
825 member.tab.mu.Unlock()
826 if !currentSlot || !retryPending {
827 continue
828 }
829 freshFailed, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
830 return rc.retryCurrentForLease(epoch, ac, lease)
831 }, &member)
832 if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
833 return
834 }
835 failed = append(failed, freshFailed...)
836 member.tab.mu.Lock()
837 stillCurrent := member.tab.floating.state == floatingVisible &&
838 member.tab.floating.generation == member.floatingGeneration &&
839 member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
840 member.tab.mu.Unlock()
841 if stillCurrent && len(freshFailed) == 0 {
842 member.pane.mu.Lock()
843 succeeded = succeeded || !member.pane.resizeRetry
844 member.pane.mu.Unlock()
845 }
846 }
847 if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
848 rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
849 }
850 if succeeded {
851 // Retry completion changes VT state after the original layout commit.
852 // Keep a named session's eventual snapshot generation aligned with it.
853 markSnapshotDirty(sess)
854 rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
855 }
856}
857
858// requestTransactionalResize reports reset invalidation completion for immediate
859// attached requests, and coordinator schedule acceptance for async requests.
860// Headless requests have no reset invalidation and report geometry completion.
861func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
862 if ac == nil {
863 return d.requestTransactionalResizeForLease(sess, nil, nil, size, immediate)
864 }
865 rc := d.attachCoordinator(sess, nil, ac, true)
866 return d.requestTransactionalResizeForLease(sess, ac, rc.attachmentLease(ac), size, immediate)
867}
868
869// requestTransactionalResizeForLease carries the connection's captured lease
870// through the full resize transaction and every delayed retry callback.
871func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedClient, lease *attachmentLease, size domain.Size, immediate bool) bool {
872 valid := sess != nil && size.Valid()
873 d.observeRuntime(ports.RuntimeResizeRequested, 0, valid)
874 if !valid {
875 return false
876 }
877 if ac == nil {
878 // Headless geometry has no coordinator/transport to coalesce, but keeps
879 // the same prepare/apply/commit ordering.
880 _, ok := d.applySessionLayout(sess, size, nil, nil)
881 return ok
882 }
883 rc := sess.renderCoordinator()
884 if rc == nil || !rc.leaseCurrent(lease, true) {
885 return false
886 }
887 geometryClaim, claimed := sess.claimGeometryOwnerForSize(ac, size)
888 if !claimed {
889 return false
890 }
891 if immediate {
892 epoch := rc.recordResizeRequestForLeaseWithClaim(size, ac, lease, geometryClaim)
893 if epoch == 0 {
894 sess.releaseGeometryClaim(ac, geometryClaim)
895 return false
896 }
897 return d.runResizeTransaction(sess, ac, lease, epoch)
898 }
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
904}