fix(daemon): retry failed transactional resizes
- commit
- 290f5386bb713cf983bee334ef57bec60b3ff4a2
- author
- brice · · 2026-07-12 08:39 UTC
- parent
- 29a2879
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 348 | 348 | d.resize(sess, nil, domain.Size{Cols: 100, Rows: 42}) | |
| 349 | 349 | require.Empty(t, pty.sizes()) | |
| 350 | 350 | ||
| 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. | |
| 353 | 353 | require.NoError(t, d.toggleFloating(sess, nil)) | |
| 354 | 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 | 356 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | |
| 357 | 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 | 360 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | |
| 361 | 361 | 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 the | 351 | + | // 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)) // hide | 360 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | ||
| 361 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | 361 | 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 { | |||
| 165 | 165 | resizeTimer ports.Timer | |
| 166 | 166 | resizeCancel chan struct{} | |
| 167 | 167 | 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 | |
| 169 | 175 | // generation invalidates callbacks from superseded deadline/watchdog timers. | |
| 170 | 176 | // Each worker also owns an explicit cancellation channel: fake timers may | |
| 171 | 177 | // 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 | 621 | // attachment. | |
| 616 | 622 | func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |
| 617 | 623 | c.mu.Lock() | |
| 618 | - | var timer, resizeTimer ports.Timer | |
| 624 | + | var timer, resizeTimer, retryTimer ports.Timer | |
| 619 | 625 | if c.attachment == ac { | |
| 620 | 626 | c.advanceAttachmentEpochLocked(ac) | |
| 621 | 627 | c.attachment = nil | |
| @@ -635,10 +641,17 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |||
| 635 | 641 | c.resizeCancel = nil | |
| 636 | 642 | } | |
| 637 | 643 | 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 | 651 | c.mu.Unlock() | |
| 640 | 652 | stopTimer(timer) | |
| 641 | 653 | stopTimer(resizeTimer) | |
| 654 | + | stopTimer(retryTimer) | |
| 642 | 655 | } | |
| 643 | 656 | ||
| 644 | 657 | // noteReplace hands the coordinator from old to replacement; callbacks | |
| @@ -653,7 +666,7 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin | |||
| 653 | 666 | // which predated coordinator ownership. In that case nil has no pending | |
| 654 | 667 | // identity to invalidate, and the replacement becomes the first bound | |
| 655 | 668 | // attachment atomically with this lifecycle transition. | |
| 656 | - | var timer, resizeTimer ports.Timer | |
| 669 | + | var timer, resizeTimer, retryTimer ports.Timer | |
| 657 | 670 | if c.attachment == old || c.attachment == nil { | |
| 658 | 671 | c.advanceAttachmentEpochLocked(old, replacement) | |
| 659 | 672 | c.attachment = replacement | |
| @@ -676,10 +689,17 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin | |||
| 676 | 689 | c.resizeCancel = nil | |
| 677 | 690 | } | |
| 678 | 691 | 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 | 699 | c.mu.Unlock() | |
| 681 | 700 | stopTimer(timer) | |
| 682 | 701 | stopTimer(resizeTimer) | |
| 702 | + | stopTimer(retryTimer) | |
| 683 | 703 | } | |
| 684 | 704 | ||
| 685 | 705 | // notePark invalidates pending wakes when the attachment parks for resume. | |
| @@ -701,6 +721,13 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||
| 701 | 721 | } | |
| 702 | 722 | resizeTimer := c.resizeTimer | |
| 703 | 723 | 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 | 731 | c.ackDeferred = false | |
| 705 | 732 | c.pendingPreview = false | |
| 706 | 733 | c.generation++ | |
| @@ -710,6 +737,7 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||
| 710 | 737 | c.mu.Unlock() | |
| 711 | 738 | stopTimer(timer) | |
| 712 | 739 | stopTimer(resizeTimer) | |
| 740 | + | stopTimer(retryTimer) | |
| 713 | 741 | stopTimers(timers) | |
| 714 | 742 | } | |
| 715 | 743 | ||
| @@ -718,14 +746,26 @@ func (c *renderCoordinator) noteSessionTeardown() { | |||
| 718 | 746 | // the strictly monotonically increased epoch, or 0 when the source is stale. | |
| 719 | 747 | func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 { | |
| 720 | 748 | c.mu.Lock() | |
| 721 | - | defer c.mu.Unlock() | |
| 722 | 749 | if c.torndown || c.attachment != source { | |
| 750 | + | c.mu.Unlock() | |
| 723 | 751 | 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 | 762 | c.resize.epoch++ | |
| 726 | 763 | c.resize.size = size | |
| 727 | 764 | c.resize.source = source | |
| 728 | - | return c.resize.epoch | |
| 765 | + | epoch := c.resize.epoch | |
| 766 | + | c.mu.Unlock() | |
| 767 | + | stopTimer(retryTimer) | |
| 768 | + | return epoch | |
| 729 | 769 | } | |
| 730 | 770 | ||
| 731 | 771 | // 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 | 844 | 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 | 915 | // resizeSnapshot returns a locked copy of the latest resize metadata. | |
| 808 | 916 | func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata { | |
| 809 | 917 | c.mu.Lock() | |
| @@ -165,7 +165,13 @@ type renderCoordinator struct { | |||||
| 165 | resizeTimer ports.Timer | 165 | resizeTimer ports.Timer | ||
| 166 | resizeCancel chan struct{} | 166 | resizeCancel chan struct{} | ||
| 167 | resizeGen uint64 | 167 | 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 | |||
| 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 may | 176 | // 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 a | 177 | // 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.Timer | 624 | + | 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 = nil | 627 | c.attachment = nil | ||
| @@ -635,10 +641,17 @@ func (c *renderCoordinator) noteDetach(ac *attachedClient) { | |||||
| 635 | c.resizeCancel = nil | 641 | c.resizeCancel = nil | ||
| 636 | } | 642 | } | ||
| 637 | resizeTimer, c.resizeTimer = c.resizeTimer, nil | 643 | 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; callbacks | 657 | // 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 pending | 666 | // which predated coordinator ownership. In that case nil has no pending | ||
| 654 | // identity to invalidate, and the replacement becomes the first bound | 667 | // 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.Timer | 669 | + | 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 = replacement | 672 | c.attachment = replacement | ||
| @@ -676,10 +689,17 @@ func (c *renderCoordinator) noteReplace(old, replacement *attachedClient, readin | |||||
| 676 | c.resizeCancel = nil | 689 | c.resizeCancel = nil | ||
| 677 | } | 690 | } | ||
| 678 | resizeTimer, c.resizeTimer = c.resizeTimer, nil | 691 | 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.resizeTimer | 722 | resizeTimer := c.resizeTimer | ||
| 703 | c.resizeTimer = nil | 723 | 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 = false | 731 | c.ackDeferred = false | ||
| 705 | c.pendingPreview = false | 732 | 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 0 | 751 | 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 = size | 763 | c.resize.size = size | ||
| 727 | c.resize.source = source | 764 | c.resize.source = source | ||
| 728 | - | return c.resize.epoch | 765 | + | 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 bounded | 771 | // 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 true | 844 | 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 { | |||
| 13 | 13 | rect domain.Rect | |
| 14 | 14 | floating floatingGeometry | |
| 15 | 15 | isFloating bool | |
| 16 | + | retry bool | |
| 16 | 17 | 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 | 49 | if i == active && tb.floating.state == floatingVisible && tb.floating.pane != nil { | |
| 47 | 50 | g := calculateContentFloatingGeometry(tabSize(size), d.currentFloatingConfig()) | |
| 48 | 51 | if g.valid() { | |
| @@ -65,8 +68,9 @@ func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool { | |||
| 65 | 68 | m.pane.resizeMu.Lock() | |
| 66 | 69 | m.pane.mu.Lock() | |
| 67 | 70 | 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 | 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 | 74 | m.ok = true | |
| 71 | 75 | } else if pty == nil { | |
| 72 | 76 | m.ok = true | |
| @@ -90,13 +94,9 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan | |||
| 90 | 94 | tb.mu.Unlock() | |
| 91 | 95 | } | |
| 92 | 96 | 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. | |
| 100 | 100 | m.pane.mu.Lock() | |
| 101 | 101 | m.pane.rect = m.rect | |
| 102 | 102 | if m.isFloating { | |
| @@ -136,12 +136,57 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u | |||
| 136 | 136 | return | |
| 137 | 137 | } | |
| 138 | 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 | 148 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 140 | 149 | // A successful transaction publishes exactly one full S2 frame. The | |
| 141 | 150 | // coordinator is the only emission route and stale epochs never reach it. | |
| 142 | 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 | 190 | func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) { | |
| 146 | 191 | if sess == nil || !size.Valid() { | |
| 147 | 192 | return | |
| @@ -13,6 +13,7 @@ type resizeMember struct { | |||||
| 13 | rect domain.Rect | 13 | rect domain.Rect | ||
| 14 | floating floatingGeometry | 14 | floating floatingGeometry | ||
| 15 | isFloating bool | 15 | isFloating bool | ||
| 16 | + | retry bool | |||
| 16 | ok bool | 17 | 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.pty | 70 | 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 = true | 74 | m.ok = true | ||
| 71 | } else if pty == nil { | 75 | } else if pty == nil { | ||
| 72 | m.ok = true | 76 | 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: it | 97 | + | // Layout geometry always publishes together. Failed members deliberately |
| 94 | - | // controls both its frame and mouse/copy hit testing. Normal layout still | 98 | + | // retain their old VT screen; capture clips that screen to this new rect |
| 95 | - | // publishes its solved rectangle, while retaining its old screen until a | 99 | + | // 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.rect | 101 | 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 | return | 136 | 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. The | 149 | // 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 | return | 192 | return | ||