detached cf19ce4

fix(daemon): stabilize resize epoch scheduling

commit
cf19ce4471daa20839409ad157ab468bd79d8587
author
brice · · 2026-07-12 08:23 UTC
parent
fd82476
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +35 -10
M internal/usecase/daemon/render_coordinator.go +6 -2
@@ -753,7 +753,8 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli
753753 return epoch
754754 }
755755 timer := clock.NewTimer(minOutputRenderDeadline)
756- if timer.C() == nil {
756+ timerC := timer.C()
757+ if timerC == nil {
757758 stopTimer(timer)
758759 run(epoch)
759760 return epoch
@@ -768,7 +769,7 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli
768769 c.mu.Unlock()
769770 go func() {
770771 select {
771- case <-timer.C():
772+ case <-timerC:
772773 case <-cancel:
773774 return
774775 }
@@ -795,6 +796,9 @@ func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient,
795796 return false
796797 }
797798 if commit {
799+ if c.resize.committed >= epoch {
800+ return false
801+ }
798802 c.resize.committed = epoch
799803 }
800804 return true
@@ -753,7 +753,8 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli
753 return epoch753 return epoch
754 }754 }
755 timer := clock.NewTimer(minOutputRenderDeadline)755 timer := clock.NewTimer(minOutputRenderDeadline)
756- if timer.C() == nil {756+ timerC := timer.C()
757+ if timerC == nil {
757 stopTimer(timer)758 stopTimer(timer)
758 run(epoch)759 run(epoch)
759 return epoch760 return epoch
@@ -768,7 +769,7 @@ func (c *renderCoordinator) scheduleResize(size domain.Size, source *attachedCli
768 c.mu.Unlock()769 c.mu.Unlock()
769 go func() {770 go func() {
770 select {771 select {
771- case <-timer.C():772+ case <-timerC:
772 case <-cancel:773 case <-cancel:
773 return774 return
774 }775 }
@@ -795,6 +796,9 @@ func (c *renderCoordinator) resizeCurrent(epoch uint64, source *attachedClient,
795 return false796 return false
796 }797 }
797 if commit {798 if commit {
799+ if c.resize.committed >= epoch {
800+ return false
801+ }
798 c.resize.committed = epoch802 c.resize.committed = epoch
799 }803 }
800 return true804 return true
M internal/usecase/daemon/transactional_resize.go +8 -2
@@ -56,8 +56,11 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
5656
5757 // applyResize deliberately holds only a pane's resizeMu around its PTY call;
5858 // no daemon, session, tab, or pane lock crosses the external boundary.
59-func (d *Daemon) applyResize(plan *resizePlan) {
59+func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
6060 for i := range plan.members {
61+ if len(current) != 0 && !current[0]() {
62+ return false
63+ }
6164 m := &plan.members[i]
6265 m.pane.resizeMu.Lock()
6366 m.pane.mu.Lock()
@@ -74,6 +77,7 @@ func (d *Daemon) applyResize(plan *resizePlan) {
7477 }
7578 m.pane.resizeMu.Unlock()
7679 }
80+ return true
7781 }
7882
7983 func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) {
@@ -118,7 +122,9 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
118122 if !rc.resizeCurrent(epoch, ac, false) {
119123 return
120124 }
121- d.applyResize(&plan)
125+ if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) {
126+ return
127+ }
122128 if !rc.resizeCurrent(epoch, ac, true) {
123129 return
124130 }
@@ -56,8 +56,11 @@ func (d *Daemon) prepareResize(sess *session, size domain.Size) resizePlan {
56 56
57 // applyResize deliberately holds only a pane's resizeMu around its PTY call;57 // applyResize deliberately holds only a pane's resizeMu around its PTY call;
58 // no daemon, session, tab, or pane lock crosses the external boundary.58 // no daemon, session, tab, or pane lock crosses the external boundary.
59-func (d *Daemon) applyResize(plan *resizePlan) {59+func (d *Daemon) applyResize(plan *resizePlan, current ...func() bool) bool {
60 for i := range plan.members {60 for i := range plan.members {
61+ if len(current) != 0 && !current[0]() {
62+ return false
63+ }
61 m := &plan.members[i]64 m := &plan.members[i]
62 m.pane.resizeMu.Lock()65 m.pane.resizeMu.Lock()
63 m.pane.mu.Lock()66 m.pane.mu.Lock()
@@ -74,6 +77,7 @@ func (d *Daemon) applyResize(plan *resizePlan) {
74 }77 }
75 m.pane.resizeMu.Unlock()78 m.pane.resizeMu.Unlock()
76 }79 }
80+ return true
77 }81 }
78 82
79 func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) {83 func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan) {
@@ -118,7 +122,9 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, epoch u
118 if !rc.resizeCurrent(epoch, ac, false) {122 if !rc.resizeCurrent(epoch, ac, false) {
119 return123 return
120 }124 }
121- d.applyResize(&plan)125+ if !d.applyResize(&plan, func() bool { return rc.resizeCurrent(epoch, ac, false) }) {
126+ return
127+ }
122 if !rc.resizeCurrent(epoch, ac, true) {128 if !rc.resizeCurrent(epoch, ac, true) {
123 return129 return
124 }130 }
M internal/usecase/daemon/transactional_resize_test.go +21 -6
@@ -126,12 +126,11 @@ func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
126126
127127 // The failed pane must not be retried at 49x28 from the obsolete epoch.
128128 // S3 retries 59x32, then commits the parser/screen and requests a reset.
129- require.Equal(t, []domain.Size{{Cols: 59, Rows: 32}}, failed.requested())
129+ require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(),
130+ "the failed old apply is historical; only the authoritative newest epoch retries it")
130131 require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Frame.Width, Rows: retry.screen.Frame.Height})
131- ac.sendMu.Lock()
132- pending := ac.resizePaintPending
133- ac.sendMu.Unlock()
134- require.True(t, pending, "successful retry forces a final full frame")
132+ require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed,
133+ "the retry belongs to the newest committed epoch")
135134 }
136135
137136 // Stale lifecycle callbacks may never advance a transaction owned by a
@@ -186,8 +185,24 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
186185 for _, timer := range timers {
187186 timer.ch <- time.Time{}
188187 }
188+ require.Eventually(t, func() bool {
189+ return len(pty.requested()) == 1
190+ }, time.Second, time.Millisecond, "the newest timer callback must finish")
189191 require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
190- require.Equal(t, uint64(3), sess.renderCoordinator().resizeSnapshot().epoch)
192+ snapshot := sess.renderCoordinator().resizeSnapshot()
193+ require.Equal(t, uint64(3), snapshot.epoch)
194+ require.Equal(t, snapshot.epoch, snapshot.committed)
195+ // Flush the separately coalesced render invalidation; resize commits do not
196+ // bypass the S2 coordinator deadline. The transaction publishes committed
197+ // metadata immediately before enqueueing that invalidation, so observe the
198+ // enqueue explicitly rather than racing the callback goroutine.
199+ rc := sess.renderCoordinator()
200+ require.Eventually(t, func() bool {
201+ rc.mu.Lock()
202+ defer rc.mu.Unlock()
203+ return rc.pending
204+ }, time.Second, time.Millisecond)
205+ rc.fireCurrent(false)
191206 frame := <-frames
192207 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
193208 select {
@@ -126,12 +126,11 @@ func TestTransactionalResizeRetryTargetsNewestCommittedEpoch(t *testing.T) {
126 126
127 // The failed pane must not be retried at 49x28 from the obsolete epoch.127 // The failed pane must not be retried at 49x28 from the obsolete epoch.
128 // S3 retries 59x32, then commits the parser/screen and requests a reset.128 // S3 retries 59x32, then commits the parser/screen and requests a reset.
129- require.Equal(t, []domain.Size{{Cols: 59, Rows: 32}}, failed.requested())129+ require.Equal(t, []domain.Size{{Cols: 49, Rows: 28}, {Cols: 59, Rows: 32}}, failed.requested(),
130+ "the failed old apply is historical; only the authoritative newest epoch retries it")
130 require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Frame.Width, Rows: retry.screen.Frame.Height})131 require.Equal(t, domain.Size{Cols: 59, Rows: 32}, domain.Size{Cols: retry.screen.Frame.Width, Rows: retry.screen.Frame.Height})
131- ac.sendMu.Lock()132+ require.Equal(t, uint64(2), sess.renderCoordinator().resizeSnapshot().committed,
132- pending := ac.resizePaintPending133+ "the retry belongs to the newest committed epoch")
133- ac.sendMu.Unlock()
134- require.True(t, pending, "successful retry forces a final full frame")
135 }134 }
136 135
137 // Stale lifecycle callbacks may never advance a transaction owned by a136 // Stale lifecycle callbacks may never advance a transaction owned by a
@@ -186,8 +185,24 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
186 for _, timer := range timers {185 for _, timer := range timers {
187 timer.ch <- time.Time{}186 timer.ch <- time.Time{}
188 }187 }
188+ require.Eventually(t, func() bool {
189+ return len(pty.requested()) == 1
190+ }, time.Second, time.Millisecond, "the newest timer callback must finish")
189 require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())191 require.Equal(t, []domain.Size{{Cols: 120, Rows: 30}}, pty.requested())
190- require.Equal(t, uint64(3), sess.renderCoordinator().resizeSnapshot().epoch)192+ snapshot := sess.renderCoordinator().resizeSnapshot()
193+ require.Equal(t, uint64(3), snapshot.epoch)
194+ require.Equal(t, snapshot.epoch, snapshot.committed)
195+ // Flush the separately coalesced render invalidation; resize commits do not
196+ // bypass the S2 coordinator deadline. The transaction publishes committed
197+ // metadata immediately before enqueueing that invalidation, so observe the
198+ // enqueue explicitly rather than racing the callback goroutine.
199+ rc := sess.renderCoordinator()
200+ require.Eventually(t, func() bool {
201+ rc.mu.Lock()
202+ defer rc.mu.Unlock()
203+ return rc.pending
204+ }, time.Second, time.Millisecond)
205+ rc.fireCurrent(false)
191 frame := <-frames206 frame := <-frames
192 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")207 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
193 select {208 select {

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