detached 1a1fa2b

fix(render): avoid duplicate floating first paint

commit
1a1fa2b5cf2bef6ab217fd007d3ce6799054ff68
author
brice · · 2026-07-13 07:05 UTC
committer
brice · · 2026-07-13 07:17 UTC
parent
c58a7c1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +83 -22
M internal/usecase/daemon/client.go +11 -11
@@ -499,16 +499,16 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
499499 wsz := tb.size
500500 tb.mu.Unlock()
501501
502- resized := clientSize.Valid() && wsz != tabSize(clientSize)
503- if resized {
504- d.resizeForFirstPaint(sess, ac, clientSize)
502+ outerResizeAccepted := false
503+ if clientSize.Valid() && wsz != tabSize(clientSize) {
504+ outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
505505 }
506506 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
507- // Activation can synchronously resize a retained floating pane through the
508- // same transaction. A resize already emitted the mandatory reset, so avoid
509- // queuing a second source-session frame that could outlive a hand-off.
510- d.activateTab(sess, tb)
511- if !resized {
507+ // Activation can synchronously resize a retained floating pane. An accepted
508+ // synchronous outer request already includes that pane and emits the reset,
509+ // but activation still performs its warmup work.
510+ activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted)
511+ if !outerResizeAccepted && !activationResized {
512512 d.invalidateRenderNow(sess, ac, true, "client.go")
513513 }
514514 }
@@ -646,9 +646,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
646646 }
647647
648648 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
649-// firstPaint caller emits its full frame immediately after this commit.
650-func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {
651- d.requestTransactionalResize(sess, ac, sz, true)
649+// returned value reports whether the synchronous request was accepted.
650+func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
651+ return d.requestTransactionalResize(sess, ac, sz, true)
652652 }
653653
654654 // detachOnSendError drops a client whose transport failed, leaving the session
@@ -499,16 +499,16 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
499 wsz := tb.size499 wsz := tb.size
500 tb.mu.Unlock()500 tb.mu.Unlock()
501 501
502- resized := clientSize.Valid() && wsz != tabSize(clientSize)502+ outerResizeAccepted := false
503- if resized {503+ if clientSize.Valid() && wsz != tabSize(clientSize) {
504- d.resizeForFirstPaint(sess, ac, clientSize)504+ outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
505 }505 }
506 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)506 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
507- // Activation can synchronously resize a retained floating pane through the507+ // Activation can synchronously resize a retained floating pane. An accepted
508- // same transaction. A resize already emitted the mandatory reset, so avoid508+ // synchronous outer request already includes that pane and emits the reset,
509- // queuing a second source-session frame that could outlive a hand-off.509+ // but activation still performs its warmup work.
510- d.activateTab(sess, tb)510+ activationResized := d.activateTabAfterResize(sess, tb, outerResizeAccepted)
511- if !resized {511+ if !outerResizeAccepted && !activationResized {
512 d.invalidateRenderNow(sess, ac, true, "client.go")512 d.invalidateRenderNow(sess, ac, true, "client.go")
513 }513 }
514 }514 }
@@ -646,9 +646,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
646 }646 }
647 647
648 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The648 // resizeForFirstPaint retains attach's synchronous geometry guarantee. The
649-// firstPaint caller emits its full frame immediately after this commit.649+// returned value reports whether the synchronous request was accepted.
650-func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {650+func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
651- d.requestTransactionalResize(sess, ac, sz, true)651+ return d.requestTransactionalResize(sess, ac, sz, true)
652 }652 }
653 653
654 // detachOnSendError drops a client whose transport failed, leaving the session654 // detachOnSendError drops a client whose transport failed, leaving the session
M internal/usecase/daemon/floating.go +14 -4
@@ -154,9 +154,15 @@ func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
154154 // activateTab performs the work associated with making a tab the destination.
155155 // It is deliberately idempotent: every tab transition can use this single
156156 // hook, while inactive restored tabs remain cold until actually selected.
157-func (d *Daemon) activateTab(sess *session, tb *tab) {
157+func (d *Daemon) activateTab(sess *session, tb *tab) bool {
158+ return d.activateTabAfterResize(sess, tb, false)
159+}
160+
161+// activateTabAfterResize retains tab activation's warmup work while avoiding a
162+// second resize when a synchronous outer resize request was already accepted.
163+func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool {
158164 if d == nil || sess == nil || tb == nil {
159- return
165+ return false
160166 }
161167 // A headless session has no actual terminal destination. Deferring warmup
162168 // until firstPaint keeps restored tabs cold and avoids launching children
@@ -166,16 +172,20 @@ func (d *Daemon) activateTab(sess *session, tb *tab) {
166172 sess.mu.Unlock()
167173 d.exitCopyMode(ac)
168174 if ac == nil {
169- return
175+ return false
170176 }
171177 d.ensureFloatingWarm(sess, tb)
178+ if outerResizeAccepted {
179+ return false
180+ }
172181 tb.mu.Lock()
173182 hasFloating := tb.floating.pane != nil
174183 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
175184 tb.mu.Unlock()
176185 if hasFloating {
177- d.requestTransactionalResize(sess, ac, size, true)
186+ return d.requestTransactionalResize(sess, ac, size, true)
178187 }
188+ return false
179189 }
180190
181191 // toggleFloating changes only this tab's slot. Opening an uninitialized slot
@@ -154,9 +154,15 @@ func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
154 // activateTab performs the work associated with making a tab the destination.154 // activateTab performs the work associated with making a tab the destination.
155 // It is deliberately idempotent: every tab transition can use this single155 // It is deliberately idempotent: every tab transition can use this single
156 // hook, while inactive restored tabs remain cold until actually selected.156 // hook, while inactive restored tabs remain cold until actually selected.
157-func (d *Daemon) activateTab(sess *session, tb *tab) {157+func (d *Daemon) activateTab(sess *session, tb *tab) bool {
158+ return d.activateTabAfterResize(sess, tb, false)
159+}
160+
161+// activateTabAfterResize retains tab activation's warmup work while avoiding a
162+// second resize when a synchronous outer resize request was already accepted.
163+func (d *Daemon) activateTabAfterResize(sess *session, tb *tab, outerResizeAccepted bool) bool {
158 if d == nil || sess == nil || tb == nil {164 if d == nil || sess == nil || tb == nil {
159- return165+ return false
160 }166 }
161 // A headless session has no actual terminal destination. Deferring warmup167 // A headless session has no actual terminal destination. Deferring warmup
162 // until firstPaint keeps restored tabs cold and avoids launching children168 // until firstPaint keeps restored tabs cold and avoids launching children
@@ -166,16 +172,20 @@ func (d *Daemon) activateTab(sess *session, tb *tab) {
166 sess.mu.Unlock()172 sess.mu.Unlock()
167 d.exitCopyMode(ac)173 d.exitCopyMode(ac)
168 if ac == nil {174 if ac == nil {
169- return175+ return false
170 }176 }
171 d.ensureFloatingWarm(sess, tb)177 d.ensureFloatingWarm(sess, tb)
178+ if outerResizeAccepted {
179+ return false
180+ }
172 tb.mu.Lock()181 tb.mu.Lock()
173 hasFloating := tb.floating.pane != nil182 hasFloating := tb.floating.pane != nil
174 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}183 size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2}
175 tb.mu.Unlock()184 tb.mu.Unlock()
176 if hasFloating {185 if hasFloating {
177- d.requestTransactionalResize(sess, ac, size, true)186+ return d.requestTransactionalResize(sess, ac, size, true)
178 }187 }
188+ return false
179 }189 }
180 190
181 // toggleFloating changes only this tab's slot. Opening an uninitialized slot191 // toggleFloating changes only this tab's slot. Opening an uninitialized slot
M internal/usecase/daemon/render_coordinator_test.go +11 -0
@@ -1558,4 +1558,15 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
15581558 requireNoInvalidation(t, invs)
15591559 requireNoCoordinatorOutputFrame(t, sends)
15601560 })
1561+
1562+ t.Run("async request reports coordinator schedule acceptance", func(t *testing.T) {
1563+ d, sess, ac, _, clk, _ := newResizeFixture(t)
1564+
1565+ require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, false))
1566+ awaitCoordinatorScheduledTimer(t, clk)
1567+
1568+ // A torn-down coordinator cannot accept a stale attachment's schedule.
1569+ sess.renderCoordinator().noteSessionTeardown()
1570+ require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false))
1571+ })
15611572 }
@@ -1558,4 +1558,15 @@ func TestRenderCoordinatorResizeEpochDispatch(t *testing.T) {
1558 requireNoInvalidation(t, invs)1558 requireNoInvalidation(t, invs)
1559 requireNoCoordinatorOutputFrame(t, sends)1559 requireNoCoordinatorOutputFrame(t, sends)
1560 })1560 })
1561+
1562+ t.Run("async request reports coordinator schedule acceptance", func(t *testing.T) {
1563+ d, sess, ac, _, clk, _ := newResizeFixture(t)
1564+
1565+ require.True(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 120, Rows: 24}, false))
1566+ awaitCoordinatorScheduledTimer(t, clk)
1567+
1568+ // A torn-down coordinator cannot accept a stale attachment's schedule.
1569+ sess.renderCoordinator().noteSessionTeardown()
1570+ require.False(t, d.requestTransactionalResize(sess, ac, domain.Size{Cols: 140, Rows: 30}, false))
1571+ })
1561 }1572 }
M internal/usecase/daemon/render_test.go +37 -0
@@ -55,6 +55,43 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
5555 require.True(t, paletteVisible, "palette must remain composed above copy mode")
5656 }
5757
58+func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
59+ for _, tc := range []struct {
60+ name string
61+ clientSize domain.Size
62+ }{
63+ {name: "matching outer size", clientSize: domain.Size{Cols: 80, Rows: 25}},
64+ {name: "different outer size", clientSize: domain.Size{Cols: 100, Rows: 40}},
65+ } {
66+ t.Run(tc.name, func(t *testing.T) {
67+ pty, releasePTY := newBlockingPTY(t)
68+ defer releasePTY()
69+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
70+ ac.sendMu.Lock()
71+ ac.size = tc.clientSize
72+ ac.sendMu.Unlock()
73+
74+ // A retained visible popup still needs activation warmup. When the
75+ // outer terminal also changes, its completed transaction must cover
76+ // that popup rather than emitting a second reset-producing frame.
77+ floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})
78+ installTestFloating(sess.activeTab(), floating, true)
79+
80+ d.firstPaint(sess, ac, tc.clientSize)
81+
82+ frame := awaitFrame(t, sends, ports.MsgOutput)
83+ output, err := ports.UnmarshalOutput(frame.Payload)
84+ require.NoError(t, err)
85+ require.Zero(t, output.BaseStateNum, "first paint must be a mandatory reset")
86+ select {
87+ case extra := <-sends:
88+ t.Fatalf("first paint emitted duplicate frame after floating activation resize: %#v", extra)
89+ default:
90+ }
91+ })
92+ }
93+}
94+
5895 func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
5996 p, release := newBlockingPTY(t)
6097 defer release()
@@ -55,6 +55,43 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
55 require.True(t, paletteVisible, "palette must remain composed above copy mode")55 require.True(t, paletteVisible, "palette must remain composed above copy mode")
56 }56 }
57 57
58+func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
59+ for _, tc := range []struct {
60+ name string
61+ clientSize domain.Size
62+ }{
63+ {name: "matching outer size", clientSize: domain.Size{Cols: 80, Rows: 25}},
64+ {name: "different outer size", clientSize: domain.Size{Cols: 100, Rows: 40}},
65+ } {
66+ t.Run(tc.name, func(t *testing.T) {
67+ pty, releasePTY := newBlockingPTY(t)
68+ defer releasePTY()
69+ d, sess, ac, sends := newManualSessionWithPTYs(t, pty)
70+ ac.sendMu.Lock()
71+ ac.size = tc.clientSize
72+ ac.sendMu.Unlock()
73+
74+ // A retained visible popup still needs activation warmup. When the
75+ // outer terminal also changes, its completed transaction must cover
76+ // that popup rather than emitting a second reset-producing frame.
77+ floating := newPane(layout.PaneID("floating"), nil, domain.Size{Cols: 20, Rows: 8})
78+ installTestFloating(sess.activeTab(), floating, true)
79+
80+ d.firstPaint(sess, ac, tc.clientSize)
81+
82+ frame := awaitFrame(t, sends, ports.MsgOutput)
83+ output, err := ports.UnmarshalOutput(frame.Payload)
84+ require.NoError(t, err)
85+ require.Zero(t, output.BaseStateNum, "first paint must be a mandatory reset")
86+ select {
87+ case extra := <-sends:
88+ t.Fatalf("first paint emitted duplicate frame after floating activation resize: %#v", extra)
89+ default:
90+ }
91+ })
92+ }
93+}
94+
58 func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {95 func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
59 p, release := newBlockingPTY(t)96 p, release := newBlockingPTY(t)
60 defer release()97 defer release()
M internal/usecase/daemon/transactional_resize.go +10 -7
@@ -242,9 +242,11 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
242242 }
243243 }
244244
245-func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) {
245+// requestTransactionalResize reports whether the coordinator accepted the
246+// request. Immediate requests complete synchronously before it returns.
247+func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
246248 if sess == nil || !size.Valid() {
247- return
249+ return false
248250 }
249251 if ac == nil {
250252 // Headless geometry has no coordinator/transport to coalesce, but keeps
@@ -252,15 +254,16 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
252254 plan := d.prepareResize(sess, size)
253255 d.applyResize(&plan)
254256 d.commitResize(sess, nil, plan)
255- return
257+ return true
256258 }
257259 rc := d.attachCoordinator(sess, nil, ac, true)
258260 if immediate {
259261 epoch := rc.recordResizeRequest(size, ac)
260- if epoch != 0 {
261- d.runResizeTransaction(sess, ac, epoch)
262+ if epoch == 0 {
263+ return false
262264 }
263- return
265+ d.runResizeTransaction(sess, ac, epoch)
266+ return true
264267 }
265- rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) })
268+ return rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) != 0
266269 }
@@ -242,9 +242,11 @@ func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, epoch uin
242 }242 }
243 }243 }
244 244
245-func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) {245+// requestTransactionalResize reports whether the coordinator accepted the
246+// request. Immediate requests complete synchronously before it returns.
247+func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, size domain.Size, immediate bool) bool {
246 if sess == nil || !size.Valid() {248 if sess == nil || !size.Valid() {
247- return249+ return false
248 }250 }
249 if ac == nil {251 if ac == nil {
250 // Headless geometry has no coordinator/transport to coalesce, but keeps252 // Headless geometry has no coordinator/transport to coalesce, but keeps
@@ -252,15 +254,16 @@ func (d *Daemon) requestTransactionalResize(sess *session, ac *attachedClient, s
252 plan := d.prepareResize(sess, size)254 plan := d.prepareResize(sess, size)
253 d.applyResize(&plan)255 d.applyResize(&plan)
254 d.commitResize(sess, nil, plan)256 d.commitResize(sess, nil, plan)
255- return257+ return true
256 }258 }
257 rc := d.attachCoordinator(sess, nil, ac, true)259 rc := d.attachCoordinator(sess, nil, ac, true)
258 if immediate {260 if immediate {
259 epoch := rc.recordResizeRequest(size, ac)261 epoch := rc.recordResizeRequest(size, ac)
260- if epoch != 0 {262+ if epoch == 0 {
261- d.runResizeTransaction(sess, ac, epoch)263+ return false
262 }264 }
263- return265+ d.runResizeTransaction(sess, ac, epoch)
266+ return true
264 }267 }
265- rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) })268+ return rc.scheduleResize(size, ac, func(epoch uint64) { d.runResizeTransaction(sess, ac, epoch) }) != 0
266 }269 }

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