fix(daemon): retry accepted failed layout applies
- commit
- de9b3348fd76d58fbc8fcfa2272556512d69b53f
- author
- brice · · 2026-07-25 03:54 UTC
- committer
- brice · · 2026-07-25 04:08 UTC
- parent
- 6400ab4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +143 -9
M internal/usecase/daemon/pane.go +2 -1
| @@ -18,7 +18,7 @@ type pane struct { | |||
| 18 | 18 | id layout.PaneID | |
| 19 | 19 | stableID string | |
| 20 | 20 | pty ports.PTY | |
| 21 | - | mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title | |
| 21 | + | mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizeRetry, resizePending, PTY side effects, and title | |
| 22 | 22 | resizeMu sync.Mutex // serializes PTY resizes without holding mu | |
| 23 | 23 | screen *vt.Screen | |
| 24 | 24 | history *vt.History | |
| @@ -28,6 +28,7 @@ type pane struct { | |||
| 28 | 28 | // draining into resizePending so output is replayed against the target (or | |
| 29 | 29 | // retained old) screen only after apply resolves. | |
| 30 | 30 | resizeApplying bool | |
| 31 | + | resizeRetry bool // PTY resize failed for the committed rectangle; retry on the next accepted plan. | |
| 31 | 32 | resizePending []byte | |
| 32 | 33 | ptyResponses []byte | |
| 33 | 34 | ptyClipboards []string | |
| @@ -18,7 +18,7 @@ type pane struct { | |||||
| 18 | id layout.PaneID | 18 | id layout.PaneID | ||
| 19 | stableID string | 19 | stableID string | ||
| 20 | pty ports.PTY | 20 | pty ports.PTY | ||
| 21 | - | mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizePending, PTY side effects, and title | 21 | + | mu sync.Mutex // guards screen, history, syncGen, rect, resizeApplying, resizeRetry, resizePending, PTY side effects, and title |
| 22 | resizeMu sync.Mutex // serializes PTY resizes without holding mu | 22 | resizeMu sync.Mutex // serializes PTY resizes without holding mu | ||
| 23 | screen *vt.Screen | 23 | screen *vt.Screen | ||
| 24 | history *vt.History | 24 | history *vt.History | ||
| @@ -28,6 +28,7 @@ type pane struct { | |||||
| 28 | // draining into resizePending so output is replayed against the target (or | 28 | // draining into resizePending so output is replayed against the target (or | ||
| 29 | // retained old) screen only after apply resolves. | 29 | // retained old) screen only after apply resolves. | ||
| 30 | resizeApplying bool | 30 | resizeApplying bool | ||
| 31 | + | resizeRetry bool // PTY resize failed for the committed rectangle; retry on the next accepted plan. | |||
| 31 | resizePending []byte | 32 | resizePending []byte | ||
| 32 | ptyResponses []byte | 33 | ptyResponses []byte | ||
| 33 | ptyClipboards []string | 34 | ptyClipboards []string | ||
M internal/usecase/daemon/pane_actions_test.go +87 -3
| @@ -21,13 +21,94 @@ import ( | |||
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | func TestLayoutGenerationMutationBookkeeping(t *testing.T) { | |
| 24 | - | tb := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |
| 24 | + | t.Run("focus and expanded member share one logical bump", func(t *testing.T) { | |
| 25 | + | tb := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |
| 26 | + | second := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 10}) | |
| 27 | + | tb.mu.Lock() | |
| 28 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"}, Focus: "pane-1"} | |
| 29 | + | tb.panes[second.id] = second | |
| 30 | + | require.Zero(t, tb.layoutGeneration) | |
| 31 | + | require.True(t, focusPlacementLocked(tb, second.id)) | |
| 32 | + | require.Equal(t, uint64(1), tb.layoutGeneration) | |
| 33 | + | require.False(t, focusPlacementLocked(tb, second.id)) | |
| 34 | + | require.Equal(t, uint64(1), tb.layoutGeneration) | |
| 35 | + | tb.mu.Unlock() | |
| 36 | + | }) | |
| 37 | + | ||
| 38 | + | t.Run("size changes bump only when the value changes", func(t *testing.T) { | |
| 39 | + | tb := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |
| 40 | + | tb.mu.Lock() | |
| 41 | + | if tb.size != (domain.Size{Cols: 80, Rows: 23}) { | |
| 42 | + | tb.size = domain.Size{Cols: 80, Rows: 23} | |
| 43 | + | tb.bumpLayoutGenerationLocked() | |
| 44 | + | } | |
| 45 | + | require.Equal(t, uint64(1), tb.layoutGeneration) | |
| 46 | + | if tb.size != (domain.Size{Cols: 80, Rows: 23}) { | |
| 47 | + | tb.size = domain.Size{Cols: 80, Rows: 23} | |
| 48 | + | tb.bumpLayoutGenerationLocked() | |
| 49 | + | } | |
| 50 | + | require.Equal(t, uint64(1), tb.layoutGeneration) | |
| 51 | + | tb.mu.Unlock() | |
| 52 | + | }) | |
| 53 | + | } | |
| 25 | 54 | ||
| 55 | + | type layoutRetryClock struct{ timers chan *layoutRetryTimer } | |
| 56 | + | ||
| 57 | + | type layoutRetryTimer struct { | |
| 58 | + | ch chan time.Time | |
| 59 | + | delay time.Duration | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (c *layoutRetryClock) Now() time.Time { return time.Unix(0, 0) } | |
| 63 | + | ||
| 64 | + | func (c *layoutRetryClock) NewTimer(delay time.Duration) ports.Timer { | |
| 65 | + | timer := &layoutRetryTimer{ch: make(chan time.Time, 1), delay: delay} | |
| 66 | + | c.timers <- timer | |
| 67 | + | return timer | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (t *layoutRetryTimer) C() <-chan time.Time { return t.ch } | |
| 71 | + | func (*layoutRetryTimer) Reset(time.Duration) bool { return false } | |
| 72 | + | func (*layoutRetryTimer) Stop() bool { return true } | |
| 73 | + | func (t *layoutRetryTimer) fire() { t.ch <- time.Unix(0, 0) } | |
| 74 | + | ||
| 75 | + | func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) { | |
| 76 | + | pty := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}} | |
| 77 | + | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 78 | + | clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)} | |
| 79 | + | d.clock = clock | |
| 80 | + | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |
| 26 | 81 | tb.mu.Lock() | |
| 27 | - | require.Zero(t, tb.layoutGeneration) | |
| 82 | + | tb.size = domain.Size{Cols: 100, Rows: 20} | |
| 28 | 83 | tb.bumpLayoutGenerationLocked() | |
| 29 | - | require.Equal(t, uint64(1), tb.layoutGeneration) | |
| 30 | 84 | tb.mu.Unlock() | |
| 85 | + | ||
| 86 | + | secondResize := make(chan struct{}) | |
| 87 | + | var resized sync.Once | |
| 88 | + | pty.onResize = func() { | |
| 89 | + | if len(pty.requested()) == 2 { | |
| 90 | + | resized.Do(func() { close(secondResize) }) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | d.applyTabLayout(sess, tb) | |
| 94 | + | ||
| 95 | + | var timer *layoutRetryTimer | |
| 96 | + | for timer == nil { | |
| 97 | + | candidate := <-clock.timers | |
| 98 | + | if candidate.delay == minOutputRenderDeadline { | |
| 99 | + | timer = candidate | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested()) | |
| 103 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "failed apply must not publish a VT resize") | |
| 104 | + | timer.fire() | |
| 105 | + | awaitSignal(t, secondResize, "accepted failure was not retried") | |
| 106 | + | require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}, {Cols: 100, Rows: 20}}, pty.requested()) | |
| 107 | + | require.Eventually(t, func() bool { | |
| 108 | + | p.mu.Lock() | |
| 109 | + | defer p.mu.Unlock() | |
| 110 | + | return p.screen.Frame.Width == 100 && p.screen.Frame.Height == 20 | |
| 111 | + | }, time.Second, time.Millisecond, "successful retry did not publish its VT size") | |
| 31 | 112 | } | |
| 32 | 113 | ||
| 33 | 114 | func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) { | |
| @@ -147,6 +228,8 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||
| 147 | 228 | require.False(t, p2.resizeApplying, "removed pane gate was not cancelled") | |
| 148 | 229 | p2.mu.Unlock() | |
| 149 | 230 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect) | |
| 231 | + | require.Equal(t, []domain.Size{{Cols: 40, Rows: 23}, {Cols: 80, Rows: 23}}, first.requested(), "the rejected plan must be followed by a fresh surviving-pane apply") | |
| 232 | + | require.Equal(t, []domain.Size{{Cols: 39, Rows: 23}}, second.requested(), "the removed pane may receive an obsolete external resize but must never be published") | |
| 150 | 233 | }) | |
| 151 | 234 | ||
| 152 | 235 | t.Run("focus expansion replaces a stale stack placement", func(t *testing.T) { | |
| @@ -180,6 +263,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||
| 180 | 263 | tb.mu.Unlock() | |
| 181 | 264 | require.True(t, ok) | |
| 182 | 265 | require.Equal(t, placementContent(placements, p2.id), p2.rect) | |
| 266 | + | require.Equal(t, []domain.Size{{Cols: 80, Rows: 21}}, first.requested(), "the old expanded pane is applied only by the rejected attempt") | |
| 183 | 267 | require.Equal(t, []domain.Size{rectSize(p2.rect)}, second.requested()) | |
| 184 | 268 | }) | |
| 185 | 269 | ||
| @@ -21,13 +21,94 @@ import ( | |||||
| 21 | ) | 21 | ) | ||
| 22 | 22 | ||||
| 23 | func TestLayoutGenerationMutationBookkeeping(t *testing.T) { | 23 | func TestLayoutGenerationMutationBookkeeping(t *testing.T) { | ||
| 24 | - | tb := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | 24 | + | t.Run("focus and expanded member share one logical bump", func(t *testing.T) { |
| 25 | + | tb := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |||
| 26 | + | second := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 10}) | |||
| 27 | + | tb.mu.Lock() | |||
| 28 | + | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"}, Focus: "pane-1"} | |||
| 29 | + | tb.panes[second.id] = second | |||
| 30 | + | require.Zero(t, tb.layoutGeneration) | |||
| 31 | + | require.True(t, focusPlacementLocked(tb, second.id)) | |||
| 32 | + | require.Equal(t, uint64(1), tb.layoutGeneration) | |||
| 33 | + | require.False(t, focusPlacementLocked(tb, second.id)) | |||
| 34 | + | require.Equal(t, uint64(1), tb.layoutGeneration) | |||
| 35 | + | tb.mu.Unlock() | |||
| 36 | + | }) | |||
| 37 | + | ||||
| 38 | + | t.Run("size changes bump only when the value changes", func(t *testing.T) { | |||
| 39 | + | tb := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |||
| 40 | + | tb.mu.Lock() | |||
| 41 | + | if tb.size != (domain.Size{Cols: 80, Rows: 23}) { | |||
| 42 | + | tb.size = domain.Size{Cols: 80, Rows: 23} | |||
| 43 | + | tb.bumpLayoutGenerationLocked() | |||
| 44 | + | } | |||
| 45 | + | require.Equal(t, uint64(1), tb.layoutGeneration) | |||
| 46 | + | if tb.size != (domain.Size{Cols: 80, Rows: 23}) { | |||
| 47 | + | tb.size = domain.Size{Cols: 80, Rows: 23} | |||
| 48 | + | tb.bumpLayoutGenerationLocked() | |||
| 49 | + | } | |||
| 50 | + | require.Equal(t, uint64(1), tb.layoutGeneration) | |||
| 51 | + | tb.mu.Unlock() | |||
| 52 | + | }) | |||
| 53 | + | } | |||
| 25 | 54 | ||||
| 55 | + | type layoutRetryClock struct{ timers chan *layoutRetryTimer } | |||
| 56 | + | ||||
| 57 | + | type layoutRetryTimer struct { | |||
| 58 | + | ch chan time.Time | |||
| 59 | + | delay time.Duration | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (c *layoutRetryClock) Now() time.Time { return time.Unix(0, 0) } | |||
| 63 | + | ||||
| 64 | + | func (c *layoutRetryClock) NewTimer(delay time.Duration) ports.Timer { | |||
| 65 | + | timer := &layoutRetryTimer{ch: make(chan time.Time, 1), delay: delay} | |||
| 66 | + | c.timers <- timer | |||
| 67 | + | return timer | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (t *layoutRetryTimer) C() <-chan time.Time { return t.ch } | |||
| 71 | + | func (*layoutRetryTimer) Reset(time.Duration) bool { return false } | |||
| 72 | + | func (*layoutRetryTimer) Stop() bool { return true } | |||
| 73 | + | func (t *layoutRetryTimer) fire() { t.ch <- time.Unix(0, 0) } | |||
| 74 | + | ||||
| 75 | + | func TestLayoutApplicationRetriesOnlyAcceptedFailedGeometry(t *testing.T) { | |||
| 76 | + | pty := &transactionalResizePTY{errs: []error{errors.New("first resize fails")}} | |||
| 77 | + | d, sess, _, _ := newManualSessionWithPTYs(t, pty) | |||
| 78 | + | clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 16)} | |||
| 79 | + | d.clock = clock | |||
| 80 | + | tb, p := sess.activeTab(), sess.activeTab().focusedPane() | |||
| 26 | tb.mu.Lock() | 81 | tb.mu.Lock() | ||
| 27 | - | require.Zero(t, tb.layoutGeneration) | 82 | + | tb.size = domain.Size{Cols: 100, Rows: 20} |
| 28 | tb.bumpLayoutGenerationLocked() | 83 | tb.bumpLayoutGenerationLocked() | ||
| 29 | - | require.Equal(t, uint64(1), tb.layoutGeneration) | |||
| 30 | tb.mu.Unlock() | 84 | tb.mu.Unlock() | ||
| 85 | + | ||||
| 86 | + | secondResize := make(chan struct{}) | |||
| 87 | + | var resized sync.Once | |||
| 88 | + | pty.onResize = func() { | |||
| 89 | + | if len(pty.requested()) == 2 { | |||
| 90 | + | resized.Do(func() { close(secondResize) }) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | d.applyTabLayout(sess, tb) | |||
| 94 | + | ||||
| 95 | + | var timer *layoutRetryTimer | |||
| 96 | + | for timer == nil { | |||
| 97 | + | candidate := <-clock.timers | |||
| 98 | + | if candidate.delay == minOutputRenderDeadline { | |||
| 99 | + | timer = candidate | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested()) | |||
| 103 | + | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "failed apply must not publish a VT resize") | |||
| 104 | + | timer.fire() | |||
| 105 | + | awaitSignal(t, secondResize, "accepted failure was not retried") | |||
| 106 | + | require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}, {Cols: 100, Rows: 20}}, pty.requested()) | |||
| 107 | + | require.Eventually(t, func() bool { | |||
| 108 | + | p.mu.Lock() | |||
| 109 | + | defer p.mu.Unlock() | |||
| 110 | + | return p.screen.Frame.Width == 100 && p.screen.Frame.Height == 20 | |||
| 111 | + | }, time.Second, time.Millisecond, "successful retry did not publish its VT size") | |||
| 31 | } | 112 | } | ||
| 32 | 113 | ||||
| 33 | func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) { | 114 | func TestLayoutApplicationDoesNotHoldTabLock(t *testing.T) { | ||
| @@ -147,6 +228,8 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||||
| 147 | require.False(t, p2.resizeApplying, "removed pane gate was not cancelled") | 228 | require.False(t, p2.resizeApplying, "removed pane gate was not cancelled") | ||
| 148 | p2.mu.Unlock() | 229 | p2.mu.Unlock() | ||
| 149 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect) | 230 | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p1.rect) | ||
| 231 | + | require.Equal(t, []domain.Size{{Cols: 40, Rows: 23}, {Cols: 80, Rows: 23}}, first.requested(), "the rejected plan must be followed by a fresh surviving-pane apply") | |||
| 232 | + | require.Equal(t, []domain.Size{{Cols: 39, Rows: 23}}, second.requested(), "the removed pane may receive an obsolete external resize but must never be published") | |||
| 150 | }) | 233 | }) | ||
| 151 | 234 | ||||
| 152 | t.Run("focus expansion replaces a stale stack placement", func(t *testing.T) { | 235 | t.Run("focus expansion replaces a stale stack placement", func(t *testing.T) { | ||
| @@ -180,6 +263,7 @@ func TestLayoutApplicationRejectsStaleCloseFocusAndSize(t *testing.T) { | |||||
| 180 | tb.mu.Unlock() | 263 | tb.mu.Unlock() | ||
| 181 | require.True(t, ok) | 264 | require.True(t, ok) | ||
| 182 | require.Equal(t, placementContent(placements, p2.id), p2.rect) | 265 | require.Equal(t, placementContent(placements, p2.id), p2.rect) | ||
| 266 | + | require.Equal(t, []domain.Size{{Cols: 80, Rows: 21}}, first.requested(), "the old expanded pane is applied only by the rejected attempt") | |||
| 183 | require.Equal(t, []domain.Size{rectSize(p2.rect)}, second.requested()) | 267 | require.Equal(t, []domain.Size{rectSize(p2.rect)}, second.requested()) | ||
| 184 | }) | 268 | }) | ||
| 185 | 269 | ||||
M internal/usecase/daemon/transactional_resize.go +54 -5
| @@ -70,8 +70,9 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||
| 70 | 70 | pty := p.pty | |
| 71 | 71 | // A rejected earlier attempt deliberately leaves its parser gate open. | |
| 72 | 72 | // Reapply even if the committed rectangle already matches: the PTY may | |
| 73 | - | // still be at that rejected attempt's external size. | |
| 74 | - | needsApply := p.resizeApplying || old.Width != member.rect.Width || old.Height != member.rect.Height | |
| 73 | + | // still be at that rejected attempt's external size. An accepted failed | |
| 74 | + | // attempt records resizeRetry for the same reason after its gate closes. | |
| 75 | + | needsApply := p.resizeApplying || p.resizeRetry || old.Width != member.rect.Width || old.Height != member.rect.Height | |
| 75 | 76 | if needsApply && pty != nil { | |
| 76 | 77 | p.resizeApplying = true | |
| 77 | 78 | } | |
| @@ -80,6 +81,9 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||
| 80 | 81 | member.ok = true | |
| 81 | 82 | } else if err := pty.Resize(rectSize(member.rect)); err != nil { | |
| 82 | 83 | d.log.Warn("pty resize failed", "err", err) | |
| 84 | + | p.mu.Lock() | |
| 85 | + | p.resizeRetry = true | |
| 86 | + | p.mu.Unlock() | |
| 83 | 87 | d.replayResizePending(member.session, member.tab, p, false, member.rect) | |
| 84 | 88 | member.retry = true | |
| 85 | 89 | member.err = err | |
| @@ -110,6 +114,7 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) { | |||
| 110 | 114 | member.pane.mu.Lock() | |
| 111 | 115 | member.pane.rect = member.rect | |
| 112 | 116 | if member.ok { | |
| 117 | + | member.pane.resizeRetry = false | |
| 113 | 118 | member.pane.screen.Resize(member.rect.Width, member.rect.Height) | |
| 114 | 119 | } | |
| 115 | 120 | member.pane.mu.Unlock() | |
| @@ -222,10 +227,54 @@ func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...fu | |||
| 222 | 227 | } | |
| 223 | 228 | } | |
| 224 | 229 | ||
| 225 | - | func (d *Daemon) applyTabLayout(sess *session, tb *tab) { | |
| 226 | - | if _, ok := d.applyTabLayoutTransaction(sess, tb); ok { | |
| 227 | - | markSnapshotDirty(sess) | |
| 230 | + | func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool { | |
| 231 | + | failed, ok := d.applyTabLayoutTransaction(sess, tb) | |
| 232 | + | if !ok { | |
| 233 | + | return false | |
| 234 | + | } | |
| 235 | + | markSnapshotDirty(sess) | |
| 236 | + | if len(failed) != 0 { | |
| 237 | + | d.scheduleAcceptedTabLayoutRetry(sess, tb) | |
| 228 | 238 | } | |
| 239 | + | return true | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // scheduleAcceptedTabLayoutRetry retries a failed PTY resize only after its | |
| 243 | + | // geometry has passed the tab generation and pane-membership validation. The | |
| 244 | + | // retry prepares a new canonical plan, so a later layout mutation supersedes | |
| 245 | + | // this target rather than replaying stale rectangles. | |
| 246 | + | func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | |
| 247 | + | if d.clock == nil || tb == nil || tb.ctx == nil { | |
| 248 | + | return | |
| 249 | + | } | |
| 250 | + | timer := d.clock.NewTimer(minOutputRenderDeadline) | |
| 251 | + | if timer == nil { | |
| 252 | + | return | |
| 253 | + | } | |
| 254 | + | timerC := timer.C() | |
| 255 | + | if timerC == nil { | |
| 256 | + | timer.Stop() | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | go func() { | |
| 260 | + | select { | |
| 261 | + | case <-tb.ctx.Done(): | |
| 262 | + | timer.Stop() | |
| 263 | + | return | |
| 264 | + | case <-timerC: | |
| 265 | + | } | |
| 266 | + | if tb.ctx.Err() != nil { | |
| 267 | + | return | |
| 268 | + | } | |
| 269 | + | if d.applyTabLayout(sess, tb) { | |
| 270 | + | sess.mu.Lock() | |
| 271 | + | ac := sess.client | |
| 272 | + | sess.mu.Unlock() | |
| 273 | + | if ac != nil { | |
| 274 | + | d.invalidateRender(sess, ac, true, "transactional_resize.go") | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | }() | |
| 229 | 278 | } | |
| 230 | 279 | ||
| 231 | 280 | func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan { | |
| @@ -70,8 +70,9 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||||
| 70 | pty := p.pty | 70 | pty := p.pty | ||
| 71 | // A rejected earlier attempt deliberately leaves its parser gate open. | 71 | // A rejected earlier attempt deliberately leaves its parser gate open. | ||
| 72 | // Reapply even if the committed rectangle already matches: the PTY may | 72 | // Reapply even if the committed rectangle already matches: the PTY may | ||
| 73 | - | // still be at that rejected attempt's external size. | 73 | + | // still be at that rejected attempt's external size. An accepted failed |
| 74 | - | needsApply := p.resizeApplying || old.Width != member.rect.Width || old.Height != member.rect.Height | 74 | + | // attempt records resizeRetry for the same reason after its gate closes. |
| 75 | + | needsApply := p.resizeApplying || p.resizeRetry || old.Width != member.rect.Width || old.Height != member.rect.Height | |||
| 75 | if needsApply && pty != nil { | 76 | if needsApply && pty != nil { | ||
| 76 | p.resizeApplying = true | 77 | p.resizeApplying = true | ||
| 77 | } | 78 | } | ||
| @@ -80,6 +81,9 @@ func (d *Daemon) applyPreparedTabMembers(plan *preparedTabLayout) { | |||||
| 80 | member.ok = true | 81 | member.ok = true | ||
| 81 | } else if err := pty.Resize(rectSize(member.rect)); err != nil { | 82 | } else if err := pty.Resize(rectSize(member.rect)); err != nil { | ||
| 82 | d.log.Warn("pty resize failed", "err", err) | 83 | d.log.Warn("pty resize failed", "err", err) | ||
| 84 | + | p.mu.Lock() | |||
| 85 | + | p.resizeRetry = true | |||
| 86 | + | p.mu.Unlock() | |||
| 83 | d.replayResizePending(member.session, member.tab, p, false, member.rect) | 87 | d.replayResizePending(member.session, member.tab, p, false, member.rect) | ||
| 84 | member.retry = true | 88 | member.retry = true | ||
| 85 | member.err = err | 89 | member.err = err | ||
| @@ -110,6 +114,7 @@ func commitPreparedTabLayoutLocked(plan *preparedTabLayout) { | |||||
| 110 | member.pane.mu.Lock() | 114 | member.pane.mu.Lock() | ||
| 111 | member.pane.rect = member.rect | 115 | member.pane.rect = member.rect | ||
| 112 | if member.ok { | 116 | if member.ok { | ||
| 117 | + | member.pane.resizeRetry = false | |||
| 113 | member.pane.screen.Resize(member.rect.Width, member.rect.Height) | 118 | member.pane.screen.Resize(member.rect.Width, member.rect.Height) | ||
| 114 | } | 119 | } | ||
| 115 | member.pane.mu.Unlock() | 120 | member.pane.mu.Unlock() | ||
| @@ -222,10 +227,54 @@ func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...fu | |||||
| 222 | } | 227 | } | ||
| 223 | } | 228 | } | ||
| 224 | 229 | ||||
| 225 | - | func (d *Daemon) applyTabLayout(sess *session, tb *tab) { | 230 | + | func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool { |
| 226 | - | if _, ok := d.applyTabLayoutTransaction(sess, tb); ok { | 231 | + | failed, ok := d.applyTabLayoutTransaction(sess, tb) |
| 227 | - | markSnapshotDirty(sess) | 232 | + | if !ok { |
| 233 | + | return false | |||
| 234 | + | } | |||
| 235 | + | markSnapshotDirty(sess) | |||
| 236 | + | if len(failed) != 0 { | |||
| 237 | + | d.scheduleAcceptedTabLayoutRetry(sess, tb) | |||
| 228 | } | 238 | } | ||
| 239 | + | return true | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // scheduleAcceptedTabLayoutRetry retries a failed PTY resize only after its | |||
| 243 | + | // geometry has passed the tab generation and pane-membership validation. The | |||
| 244 | + | // retry prepares a new canonical plan, so a later layout mutation supersedes | |||
| 245 | + | // this target rather than replaying stale rectangles. | |||
| 246 | + | func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | |||
| 247 | + | if d.clock == nil || tb == nil || tb.ctx == nil { | |||
| 248 | + | return | |||
| 249 | + | } | |||
| 250 | + | timer := d.clock.NewTimer(minOutputRenderDeadline) | |||
| 251 | + | if timer == nil { | |||
| 252 | + | return | |||
| 253 | + | } | |||
| 254 | + | timerC := timer.C() | |||
| 255 | + | if timerC == nil { | |||
| 256 | + | timer.Stop() | |||
| 257 | + | return | |||
| 258 | + | } | |||
| 259 | + | go func() { | |||
| 260 | + | select { | |||
| 261 | + | case <-tb.ctx.Done(): | |||
| 262 | + | timer.Stop() | |||
| 263 | + | return | |||
| 264 | + | case <-timerC: | |||
| 265 | + | } | |||
| 266 | + | if tb.ctx.Err() != nil { | |||
| 267 | + | return | |||
| 268 | + | } | |||
| 269 | + | if d.applyTabLayout(sess, tb) { | |||
| 270 | + | sess.mu.Lock() | |||
| 271 | + | ac := sess.client | |||
| 272 | + | sess.mu.Unlock() | |||
| 273 | + | if ac != nil { | |||
| 274 | + | d.invalidateRender(sess, ac, true, "transactional_resize.go") | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | }() | |||
| 229 | } | 278 | } | ||
| 230 | 279 | ||||
| 231 | func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan { | 280 | func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan { | ||