detached 290f538

fix(daemon): retry failed transactional resizes

commit
290f5386bb713cf983bee334ef57bec60b3ff4a2
author
brice · · 2026-07-12 08:39 UTC
parent
29a2879
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +170 -17
M internal/usecase/daemon/floating_render_test.go +4 -4
@@ -348,14 +348,14 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
348348 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42})
349349 require.Empty(t, pty.sizes())
350350
351- // Showing attempts the current size before paint. A failed resize leaves the
352- // old screen and rect untouched, so a later hide/show can retry.
351+ // Showing attempts the current size before paint. A failed resize retains
352+ // the old screen, but commits the new popup rect so capture clips/pads it.
353353 require.NoError(t, d.toggleFloating(sess, nil))
354354 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
355- require.Equal(t, initial, floating.rect)
355+ require.Equal(t, current, floating.rect)
356356 require.Equal(t, initial.Width, floating.screen.Frame.Width)
357357 require.Equal(t, initial.Height, floating.screen.Frame.Height)
358- require.Equal(t, initialGeometry, floating.popupGeometry)
358+ require.Equal(t, currentGeometry, floating.popupGeometry)
359359
360360 require.NoError(t, d.toggleFloating(sess, nil)) // hide
361361 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
@@ -348,14 +348,14 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
348 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42})348 d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42})
349 require.Empty(t, pty.sizes())349 require.Empty(t, pty.sizes())
350 350
351- // Showing attempts the current size before paint. A failed resize leaves the351+ // Showing attempts the current size before paint. A failed resize retains
352- // old screen and rect untouched, so a later hide/show can retry.352+ // the old screen, but commits the new popup rect so capture clips/pads it.
353 require.NoError(t, d.toggleFloating(sess, nil))353 require.NoError(t, d.toggleFloating(sess, nil))
354 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())354 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
355- require.Equal(t, initial, floating.rect)355+ require.Equal(t, current, floating.rect)
356 require.Equal(t, initial.Width, floating.screen.Frame.Width)356 require.Equal(t, initial.Width, floating.screen.Frame.Width)
357 require.Equal(t, initial.Height, floating.screen.Frame.Height)357 require.Equal(t, initial.Height, floating.screen.Frame.Height)
358- require.Equal(t, initialGeometry, floating.popupGeometry)358+ require.Equal(t, currentGeometry, floating.popupGeometry)
359 359
360 require.NoError(t, d.toggleFloating(sess, nil)) // hide360 require.NoError(t, d.toggleFloating(sess, nil)) // hide
361 require.NoError(t, d.toggleFloating(sess, nil)) // retry show361 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
M internal/usecase/daemon/render_coordinator.go +113 -5
@@ -165,7 +165,13 @@ type renderCoordinator struct {
165165 resizeTimer ports.Timer
166166 resizeCancel chan struct{}
167167 resizeGen uint64
168- metrics renderCoordinatorBurstMetrics
168+ // retry is a separate, latest-committed-epoch retry lane. It is cancelled
169+ // with resize requests and attachment lifecycle changes; callbacks also
170+ // validate their epoch before touching a pane.
171+ retryTimer ports.Timer
172+ retryCancel chan struct{}
173+ retryGen uint64
174+ metrics renderCoordinatorBurstMetrics
169175 // generation invalidates callbacks from superseded deadline/watchdog timers.
170176 // Each worker also owns an explicit cancellation channel: fake timers may
171177 // expose a nil or never-firing C channel, so Stop alone cannot release a
@@ -615,7 +621,7 @@ func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
615621 // attachment.
616622 func (c *renderCoordinator) noteDetach(ac *attachedClient) {
617623 c.mu.Lock()
618- var timer, resizeTimer ports.Timer
624+ var timer, resizeTimer, retryTimer ports.Timer
619625 if c.attachment == ac {
620626 c.advanceAttachmentEpochLocked(ac)
621627 c.attachment = nil
@@ -635,10 +641,17 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
635641 c.resizeCancel = nil
636642 }
637643 resizeTimer, c.resizeTimer = c.resizeTimer, nil
644+ if c.retryCancel != nil {
645+ close(c.retryCancel)
646+ c.retryCancel = nil
647+ }
648+ retryTimer, c.retryTimer = c.retryTimer, nil
649+ c.retryGen++
638650 }
639651 c.mu.Unlock()
640652 stopTimer(timer)
641653 stopTimer(resizeTimer)
654+ stopTimer(retryTimer)
642655 }
643656
644657 // noteReplace hands the coordinator from old to replacement; callbacks
@@ -653,7 +666,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
653666 // which predated coordinator ownership. In that case nil has no pending
654667 // identity to invalidate, and the replacement becomes the first bound
655668 // attachment atomically with this lifecycle transition.
656- var timer, resizeTimer ports.Timer
669+ var timer, resizeTimer, retryTimer ports.Timer
657670 if c.attachment == old || c.attachment == nil {
658671 c.advanceAttachmentEpochLocked(old, replacement)
659672 c.attachment = replacement
@@ -676,10 +689,17 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
676689 c.resizeCancel = nil
677690 }
678691 resizeTimer, c.resizeTimer = c.resizeTimer, nil
692+ if c.retryCancel != nil {
693+ close(c.retryCancel)
694+ c.retryCancel = nil
695+ }
696+ retryTimer, c.retryTimer = c.retryTimer, nil
697+ c.retryGen++
679698 }
680699 c.mu.Unlock()
681700 stopTimer(timer)
682701 stopTimer(resizeTimer)
702+ stopTimer(retryTimer)
683703 }
684704
685705 // notePark invalidates pending wakes when the attachment parks for resume.
@@ -701,6 +721,13 @@ func (c *renderCoordinator) noteSessionTeardown() {
701721 }
702722 resizeTimer := c.resizeTimer
703723 c.resizeTimer = nil
724+ if c.retryCancel != nil {
725+ close(c.retryCancel)
726+ c.retryCancel = nil
727+ }
728+ retryTimer := c.retryTimer
729+ c.retryTimer = nil
730+ c.retryGen++
704731 c.ackDeferred = false
705732 c.pendingPreview = false
706733 c.generation++
@@ -710,6 +737,7 @@ func (c *renderCoordinator) noteSessionTeardown() {
710737 c.mu.Unlock()
711738 stopTimer(timer)
712739 stopTimer(resizeTimer)
740+ stopTimer(retryTimer)
713741 stopTimers(timers)
714742 }
715743
@@ -718,14 +746,26 @@ func (c *renderCoordinator) noteSessionTeardown() {
718746 // the strictly monotonically increased epoch, or 0 when the source is stale.
719747 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
720748 c.mu.Lock()
721- defer c.mu.Unlock()
722749 if c.torndown || c.attachment != source {
750+ c.mu.Unlock()
723751 return 0
724752 }
753+ // An intervening request supersedes any failed-pane retry, even if a fake
754+ // timer delivers a stopped callback afterwards.
755+ if c.retryCancel != nil {
756+ close(c.retryCancel)
757+ c.retryCancel = nil
758+ }
759+ retryTimer := c.retryTimer
760+ c.retryTimer = nil
761+ c.retryGen++
725762 c.resize.epoch++
726763 c.resize.size = size
727764 c.resize.source = source
728- return c.resize.epoch
765+ epoch := c.resize.epoch
766+ c.mu.Unlock()
767+ stopTimer(retryTimer)
768+ return epoch
729769 }
730770
731771 // scheduleResize records a latest-wins request and runs apply after the bounded
@@ -804,6 +844,74 @@ func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient,
804844 return true
805845 }
806846
847+// scheduleResizeRetry serializes failed-pane retries through the injected
848+// clock. Only a retry for the most recently committed epoch can run.
849+func (c *renderCoordinator) scheduleResizeRetry(epoch uint64, source *attachedClient, run func()) {
850+ c.mu.Lock()
851+ if c.torndown || c.attachment != source || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch {
852+ c.mu.Unlock()
853+ return
854+ }
855+ old := c.retryTimer
856+ if c.retryCancel != nil {
857+ close(c.retryCancel)
858+ }
859+ c.retryGen++
860+ gen := c.retryGen
861+ cancel := make(chan struct{})
862+ c.retryCancel, c.retryTimer = cancel, nil
863+ clock := c.opts.clock
864+ c.mu.Unlock()
865+ stopTimer(old)
866+ if clock == nil {
867+ run()
868+ return
869+ }
870+ timer := clock.NewTimer(minOutputRenderDeadline)
871+ timerC := timer.C()
872+ if timerC == nil {
873+ // A nil timer channel is the deterministic disabled-clock contract used
874+ // by headless tests; do not spin retries synchronously.
875+ stopTimer(timer)
876+ return
877+ }
878+ c.mu.Lock()
879+ valid := !c.torndown && c.retryGen == gen && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
880+ if valid {
881+ c.retryTimer = timer
882+ }
883+ c.mu.Unlock()
884+ if !valid {
885+ stopTimer(timer)
886+ return
887+ }
888+ go func() {
889+ select {
890+ case <-timerC:
891+ case <-cancel:
892+ return
893+ }
894+ c.mu.Lock()
895+ valid := !c.torndown && c.retryGen == gen && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
896+ if valid {
897+ c.retryTimer = nil
898+ c.retryCancel = nil
899+ }
900+ c.mu.Unlock()
901+ if valid {
902+ run()
903+ }
904+ }()
905+}
906+
907+// retryCurrent is stricter than resizeCurrent: retries may only repair the
908+// newest geometry that has already been published.
909+func (c *renderCoordinator) retryCurrent(epoch uint64, source *attachedClient) bool {
910+ c.mu.Lock()
911+ defer c.mu.Unlock()
912+ return !c.torndown && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
913+}
914+
807915 // resizeSnapshot returns a locked copy of the latest resize metadata.
808916 func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
809917 c.mu.Lock()
@@ -165,7 +165,13 @@ type renderCoordinator struct {
165 resizeTimer ports.Timer165 resizeTimer ports.Timer
166 resizeCancel chan struct{}166 resizeCancel chan struct{}
167 resizeGen uint64167 resizeGen uint64
168- metrics renderCoordinatorBurstMetrics168+ // retry is a separate, latest-committed-epoch retry lane. It is cancelled
169+ // with resize requests and attachment lifecycle changes; callbacks also
170+ // validate their epoch before touching a pane.
171+ retryTimer ports.Timer
172+ retryCancel chan struct{}
173+ retryGen uint64
174+ metrics renderCoordinatorBurstMetrics
169 // generation invalidates callbacks from superseded deadline/watchdog timers.175 // generation invalidates callbacks from superseded deadline/watchdog timers.
170 // Each worker also owns an explicit cancellation channel: fake timers may176 // Each worker also owns an explicit cancellation channel: fake timers may
171 // expose a nil or never-firing C channel, so Stop alone cannot release a177 // expose a nil or never-firing C channel, so Stop alone cannot release a
@@ -615,7 +621,7 @@ func (c *renderCoordinator) wakeCurrent(w renderWake) bool {
615 // attachment.621 // attachment.
616 func (c *renderCoordinator) noteDetach(ac *attachedClient) {622 func (c *renderCoordinator) noteDetach(ac *attachedClient) {
617 c.mu.Lock()623 c.mu.Lock()
618- var timer, resizeTimer ports.Timer624+ var timer, resizeTimer, retryTimer ports.Timer
619 if c.attachment == ac {625 if c.attachment == ac {
620 c.advanceAttachmentEpochLocked(ac)626 c.advanceAttachmentEpochLocked(ac)
621 c.attachment = nil627 c.attachment = nil
@@ -635,10 +641,17 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) {
635 c.resizeCancel = nil641 c.resizeCancel = nil
636 }642 }
637 resizeTimer, c.resizeTimer = c.resizeTimer, nil643 resizeTimer, c.resizeTimer = c.resizeTimer, nil
644+ if c.retryCancel != nil {
645+ close(c.retryCancel)
646+ c.retryCancel = nil
647+ }
648+ retryTimer, c.retryTimer = c.retryTimer, nil
649+ c.retryGen++
638 }650 }
639 c.mu.Unlock()651 c.mu.Unlock()
640 stopTimer(timer)652 stopTimer(timer)
641 stopTimer(resizeTimer)653 stopTimer(resizeTimer)
654+ stopTimer(retryTimer)
642 }655 }
643 656
644 // noteReplace hands the coordinator from old to replacement; callbacks657 // noteReplace hands the coordinator from old to replacement; callbacks
@@ -653,7 +666,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
653 // which predated coordinator ownership. In that case nil has no pending666 // which predated coordinator ownership. In that case nil has no pending
654 // identity to invalidate, and the replacement becomes the first bound667 // identity to invalidate, and the replacement becomes the first bound
655 // attachment atomically with this lifecycle transition.668 // attachment atomically with this lifecycle transition.
656- var timer, resizeTimer ports.Timer669+ var timer, resizeTimer, retryTimer ports.Timer
657 if c.attachment == old || c.attachment == nil {670 if c.attachment == old || c.attachment == nil {
658 c.advanceAttachmentEpochLocked(old, replacement)671 c.advanceAttachmentEpochLocked(old, replacement)
659 c.attachment = replacement672 c.attachment = replacement
@@ -676,10 +689,17 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin
676 c.resizeCancel = nil689 c.resizeCancel = nil
677 }690 }
678 resizeTimer, c.resizeTimer = c.resizeTimer, nil691 resizeTimer, c.resizeTimer = c.resizeTimer, nil
692+ if c.retryCancel != nil {
693+ close(c.retryCancel)
694+ c.retryCancel = nil
695+ }
696+ retryTimer, c.retryTimer = c.retryTimer, nil
697+ c.retryGen++
679 }698 }
680 c.mu.Unlock()699 c.mu.Unlock()
681 stopTimer(timer)700 stopTimer(timer)
682 stopTimer(resizeTimer)701 stopTimer(resizeTimer)
702+ stopTimer(retryTimer)
683 }703 }
684 704
685 // notePark invalidates pending wakes when the attachment parks for resume.705 // notePark invalidates pending wakes when the attachment parks for resume.
@@ -701,6 +721,13 @@ func (c *renderCoordinator) noteSessionTeardown() {
701 }721 }
702 resizeTimer := c.resizeTimer722 resizeTimer := c.resizeTimer
703 c.resizeTimer = nil723 c.resizeTimer = nil
724+ if c.retryCancel != nil {
725+ close(c.retryCancel)
726+ c.retryCancel = nil
727+ }
728+ retryTimer := c.retryTimer
729+ c.retryTimer = nil
730+ c.retryGen++
704 c.ackDeferred = false731 c.ackDeferred = false
705 c.pendingPreview = false732 c.pendingPreview = false
706 c.generation++733 c.generation++
@@ -710,6 +737,7 @@ func (c *renderCoordinator) noteSessionTeardown() {
710 c.mu.Unlock()737 c.mu.Unlock()
711 stopTimer(timer)738 stopTimer(timer)
712 stopTimer(resizeTimer)739 stopTimer(resizeTimer)
740+ stopTimer(retryTimer)
713 stopTimers(timers)741 stopTimers(timers)
714 }742 }
715 743
@@ -718,14 +746,26 @@ func (c *renderCoordinator) noteSessionTeardown() {
718 // the strictly monotonically increased epoch, or 0 when the source is stale.746 // the strictly monotonically increased epoch, or 0 when the source is stale.
719 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {747 func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
720 c.mu.Lock()748 c.mu.Lock()
721- defer c.mu.Unlock()
722 if c.torndown || c.attachment != source {749 if c.torndown || c.attachment != source {
750+ c.mu.Unlock()
723 return 0751 return 0
724 }752 }
753+ // An intervening request supersedes any failed-pane retry, even if a fake
754+ // timer delivers a stopped callback afterwards.
755+ if c.retryCancel != nil {
756+ close(c.retryCancel)
757+ c.retryCancel = nil
758+ }
759+ retryTimer := c.retryTimer
760+ c.retryTimer = nil
761+ c.retryGen++
725 c.resize.epoch++762 c.resize.epoch++
726 c.resize.size = size763 c.resize.size = size
727 c.resize.source = source764 c.resize.source = source
728- return c.resize.epoch765+ epoch := c.resize.epoch
766+ c.mu.Unlock()
767+ stopTimer(retryTimer)
768+ return epoch
729 }769 }
730 770
731 // scheduleResize records a latest-wins request and runs apply after the bounded771 // scheduleResize records a latest-wins request and runs apply after the bounded
@@ -804,6 +844,74 @@ func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient,
804 return true844 return true
805 }845 }
806 846
847+// scheduleResizeRetry serializes failed-pane retries through the injected
848+// clock. Only a retry for the most recently committed epoch can run.
849+func (c *renderCoordinator) scheduleResizeRetry(epoch uint64, source *attachedClient, run func()) {
850+ c.mu.Lock()
851+ if c.torndown || c.attachment != source || c.resize.source != source || c.resize.epoch != epoch || c.resize.committed != epoch {
852+ c.mu.Unlock()
853+ return
854+ }
855+ old := c.retryTimer
856+ if c.retryCancel != nil {
857+ close(c.retryCancel)
858+ }
859+ c.retryGen++
860+ gen := c.retryGen
861+ cancel := make(chan struct{})
862+ c.retryCancel, c.retryTimer = cancel, nil
863+ clock := c.opts.clock
864+ c.mu.Unlock()
865+ stopTimer(old)
866+ if clock == nil {
867+ run()
868+ return
869+ }
870+ timer := clock.NewTimer(minOutputRenderDeadline)
871+ timerC := timer.C()
872+ if timerC == nil {
873+ // A nil timer channel is the deterministic disabled-clock contract used
874+ // by headless tests; do not spin retries synchronously.
875+ stopTimer(timer)
876+ return
877+ }
878+ c.mu.Lock()
879+ valid := !c.torndown && c.retryGen == gen && c.resize.epoch == epoch && c.resize.committed == epoch && c.resize.source == source
880+ if valid {
881+ c.retryTimer = timer
882+ }
883+ c.mu.Unlock()
884+ if !valid {
885+ stopTimer(timer)
886+ return
887+ }
888+ go func() {
889+ select {
890+ case <-timerC:
891+ case <-cancel:
892+ return
893+ }
894+ c.mu.Lock()
895+ valid := !c.torndown && c.retryGen == gen && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
896+ if valid {
897+ c.retryTimer = nil
898+ c.retryCancel = nil
899+ }
900+ c.mu.Unlock()
901+ if valid {
902+ run()
903+ }
904+ }()
905+}
906+
907+// retryCurrent is stricter than resizeCurrent: retries may only repair the
908+// newest geometry that has already been published.
909+func (c *renderCoordinator) retryCurrent(epoch uint64, source *attachedClient) bool {
910+ c.mu.Lock()
911+ defer c.mu.Unlock()
912+ return !c.torndown && c.attachment == source && c.resize.source == source && c.resize.epoch == epoch && c.resize.committed == epoch
913+}
914+
807 // resizeSnapshot returns a locked copy of the latest resize metadata.915 // resizeSnapshot returns a locked copy of the latest resize metadata.
808 func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {916 func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
809 c.mu.Lock()917 c.mu.Lock()
M internal/usecase/daemon/transactional_resize.go +53 -8
@@ -13,6 +13,7 @@ type resizeMember struct {
1313 rect domain.Rect
1414 floating floatingGeometry
1515 isFloating bool
16+ retry bool
1617 ok bool
1718 }
1819
@@ -43,6 +44,8 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
4344 }
4445 }
4546 }
47+ // Hidden retained popups enter this same primitive when shown; only a
48+ // visible popup participates in an unrelated client resize.
4649 if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil {
4750 g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig())
4851 if g.valid() {
@@ -65,8 +68,9 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
6568 m.pane.resizeMu.Lock()
6669 m.pane.mu.Lock()
6770 old, pty := m.pane.rect, m.pane.pty
71+ screenSize := domain.Size{Cols: m.pane.screen.Frame.Width, Rows: m.pane.screen.Frame.Height}
6872 m.pane.mu.Unlock()
69- if old.Width == m.rect.Width && old.Height == m.rect.Height {
73+ if !m.retry && old.Width == m.rect.Width && old.Height == m.rect.Height && screenSize == rectSize(m.rect) {
7074 m.ok = true
7175 } else if pty == nil {
7276 m.ok = true
@@ -90,13 +94,9 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan
9094 tb.mu.Unlock()
9195 }
9296 for _, m := range plan.members {
93- // A failed floating resize must retain the committed popup geometry: it
94- // controls both its frame and mouse/copy hit testing. Normal layout still
95- // publishes its solved rectangle, while retaining its old screen until a
96- // successful PTY resize.
97- if m.isFloating && !m.ok {
98- continue
99- }
97+ // Layout geometry always publishes together. Failed members deliberately
98+ // retain their old VT screen; capture clips that screen to this new rect
99+ // and pads the uncovered cells with normal blanks.
100100 m.pane.mu.Lock()
101101 m.pane.rect = m.rect
102102 if m.isFloating {
@@ -136,12 +136,57 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
136136 return
137137 }
138138 d.commitResize(sess, ac, plan)
139+ failed := make([]resizeMember, 0, len(plan.members))
140+ for _, member := range plan.members {
141+ if !member.ok {
142+ failed = append(failed, member)
143+ }
144+ }
145+ if len(failed) != 0 {
146+ rc.scheduleResizeRetry(epoch, ac, func() { d.retryResizeMembers(sess, ac, epoch, failed) })
147+ }
139148 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
140149 // A successful transaction publishes exactly one full S2 frame. The
141150 // coordinator is the only emission route and stale epochs never reach it.
142151 rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
143152 }
144153
154+// retryResizeMembers retries only members which failed the committed epoch.
155+// It never republishes geometry: an intervening resize owns that publication.
156+func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uint64, members []resizeMember) {
157+ rc := sess.renderCoordinator()
158+ if rc == nil || !rc.retryCurrent(epoch, ac) {
159+ return
160+ }
161+ plan := resizePlan{members: append([]resizeMember(nil), members...)}
162+ for i := range plan.members {
163+ plan.members[i].retry = true
164+ }
165+ if !d.applyResize(&plan, func() bool { return rc.retryCurrent(epoch, ac) }) || !rc.retryCurrent(epoch, ac) {
166+ return
167+ }
168+ failed := make([]resizeMember, 0, len(plan.members))
169+ succeeded := false
170+ for _, member := range plan.members {
171+ if !member.ok {
172+ failed = append(failed, member)
173+ continue
174+ }
175+ member.pane.mu.Lock()
176+ // The rect is the already committed layout target. Resize VT only now,
177+ // then force a reset so renderer shadow cannot retain padded cells.
178+ member.pane.screen.Resize(member.rect.Width, member.rect.Height)
179+ member.pane.mu.Unlock()
180+ succeeded = true
181+ }
182+ if len(failed) != 0 {
183+ rc.scheduleResizeRetry(epoch, ac, func() { d.retryResizeMembers(sess, ac, epoch, failed) })
184+ }
185+ if succeeded {
186+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
187+ }
188+}
189+
145190 func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) {
146191 if sess == nil || !size.Valid() {
147192 return
@@ -13,6 +13,7 @@ type resizeMember struct {
13 rect domain.Rect13 rect domain.Rect
14 floating floatingGeometry14 floating floatingGeometry
15 isFloating bool15 isFloating bool
16+ retry bool
16 ok bool17 ok bool
17 }18 }
18 19
@@ -43,6 +44,8 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
43 }44 }
44 }45 }
45 }46 }
47+ // Hidden retained popups enter this same primitive when shown; only a
48+ // visible popup participates in an unrelated client resize.
46 if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil {49 if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil {
47 g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig())50 g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig())
48 if g.valid() {51 if g.valid() {
@@ -65,8 +68,9 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
65 m.pane.resizeMu.Lock()68 m.pane.resizeMu.Lock()
66 m.pane.mu.Lock()69 m.pane.mu.Lock()
67 old, pty := m.pane.rect, m.pane.pty70 old, pty := m.pane.rect, m.pane.pty
71+ screenSize := domain.Size{Cols: m.pane.screen.Frame.Width, Rows: m.pane.screen.Frame.Height}
68 m.pane.mu.Unlock()72 m.pane.mu.Unlock()
69- if old.Width == m.rect.Width && old.Height == m.rect.Height {73+ if !m.retry && old.Width == m.rect.Width && old.Height == m.rect.Height && screenSize == rectSize(m.rect) {
70 m.ok = true74 m.ok = true
71 } else if pty == nil {75 } else if pty == nil {
72 m.ok = true76 m.ok = true
@@ -90,13 +94,9 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan
90 tb.mu.Unlock()94 tb.mu.Unlock()
91 }95 }
92 for _, m := range plan.members {96 for _, m := range plan.members {
93- // A failed floating resize must retain the committed popup geometry: it97+ // Layout geometry always publishes together. Failed members deliberately
94- // controls both its frame and mouse/copy hit testing. Normal layout still98+ // retain their old VT screen; capture clips that screen to this new rect
95- // publishes its solved rectangle, while retaining its old screen until a99+ // and pads the uncovered cells with normal blanks.
96- // successful PTY resize.
97- if m.isFloating && !m.ok {
98- continue
99- }
100 m.pane.mu.Lock()100 m.pane.mu.Lock()
101 m.pane.rect = m.rect101 m.pane.rect = m.rect
102 if m.isFloating {102 if m.isFloating {
@@ -136,12 +136,57 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
136 return136 return
137 }137 }
138 d.commitResize(sess, ac, plan)138 d.commitResize(sess, ac, plan)
139+ failed := make([]resizeMember, 0, len(plan.members))
140+ for _, member := range plan.members {
141+ if !member.ok {
142+ failed = append(failed, member)
143+ }
144+ }
145+ if len(failed) != 0 {
146+ rc.scheduleResizeRetry(epoch, ac, func() { d.retryResizeMembers(sess, ac, epoch, failed) })
147+ }
139 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)148 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
140 // A successful transaction publishes exactly one full S2 frame. The149 // A successful transaction publishes exactly one full S2 frame. The
141 // coordinator is the only emission route and stale epochs never reach it.150 // coordinator is the only emission route and stale epochs never reach it.
142 rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})151 rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
143 }152 }
144 153
154+// retryResizeMembers retries only members which failed the committed epoch.
155+// It never republishes geometry: an intervening resize owns that publication.
156+func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uint64, members []resizeMember) {
157+ rc := sess.renderCoordinator()
158+ if rc == nil || !rc.retryCurrent(epoch, ac) {
159+ return
160+ }
161+ plan := resizePlan{members: append([]resizeMember(nil), members...)}
162+ for i := range plan.members {
163+ plan.members[i].retry = true
164+ }
165+ if !d.applyResize(&plan, func() bool { return rc.retryCurrent(epoch, ac) }) || !rc.retryCurrent(epoch, ac) {
166+ return
167+ }
168+ failed := make([]resizeMember, 0, len(plan.members))
169+ succeeded := false
170+ for _, member := range plan.members {
171+ if !member.ok {
172+ failed = append(failed, member)
173+ continue
174+ }
175+ member.pane.mu.Lock()
176+ // The rect is the already committed layout target. Resize VT only now,
177+ // then force a reset so renderer shadow cannot retain padded cells.
178+ member.pane.screen.Resize(member.rect.Width, member.rect.Height)
179+ member.pane.mu.Unlock()
180+ succeeded = true
181+ }
182+ if len(failed) != 0 {
183+ rc.scheduleResizeRetry(epoch, ac, func() { d.retryResizeMembers(sess, ac, epoch, failed) })
184+ }
185+ if succeeded {
186+ rc.invalidateForAttachment(ac, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
187+ }
188+}
189+
145 func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) {190 func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) {
146 if sess == nil || !size.Valid() {191 if sess == nil || !size.Valid() {
147 return192 return

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close