detached 7099f68

fix(daemon): coalesce resize redraws

commit
7099f686546ceba9bca25d7a55fe05b7299b7d72
author
brice · · 2026-07-11 10:29 UTC
parent
52c0e49
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +194 -30
M internal/usecase/daemon/client.go +59 -27
@@ -38,25 +38,28 @@ import (
3838 )
3939
4040 type attachedClient struct {
41- tr ports.Transport
42- output *outputStateStream
43- overlays *overlayRuntime
44- clientID [16]byte
45- resumeCapable bool
46- resumeToken uint64
47- parked bool
48- echoAck atomic.Uint64
49- bars barCache // only touched while sendMu is held
50- composed composedFrameCache // only touched while sendMu is held
51- size domain.Size
52- keys *keys.Router
53- sess Guarded[*session]
54- mouseScan mouse.Scanner
55- themeMu sync.Mutex
56- theme themeui.Theme
57- clientTheme themeui.Theme
58- lastCursor cursorOut
59- recentNav recentSessionNavigator
41+ tr ports.Transport
42+ output *outputStateStream
43+ overlays *overlayRuntime
44+ clientID [16]byte
45+ resumeCapable bool
46+ resumeToken uint64
47+ parked bool
48+ echoAck atomic.Uint64
49+ bars barCache // only touched while sendMu is held
50+ composed composedFrameCache // only touched while sendMu is held
51+ resizePaint pendingByteTimer // guarded by sendMu
52+ resizePaintGeneration uint64 // guarded by sendMu
53+ resizePaintPending bool // guarded by sendMu
54+ size domain.Size
55+ keys *keys.Router
56+ sess Guarded[*session]
57+ mouseScan mouse.Scanner
58+ themeMu sync.Mutex
59+ theme themeui.Theme
60+ clientTheme themeui.Theme
61+ lastCursor cursorOut
62+ recentNav recentSessionNavigator
6063 // historyNavMu protects historyNav and historyNavTimer. When paint needs
6164 // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.
6265 historyNavMu sync.Mutex
@@ -104,6 +107,16 @@ func (p *pendingByteTimer) stop() {
104107 }
105108 }
106109
110+// cancelResizePaint invalidates callbacks before detaching or replacing an
111+// attachment. It deliberately takes sendMu before any session/daemon lock.
112+func (ac *attachedClient) cancelResizePaint() {
113+ ac.sendMu.Lock()
114+ ac.resizePaintGeneration++
115+ ac.resizePaintPending = false
116+ ac.resizePaint.stop()
117+ ac.sendMu.Unlock()
118+}
119+
107120 func (ac *attachedClient) initOverlays() {
108121 if ac.overlays == nil {
109122 ac.overlays = newOverlayRuntime(ac)
@@ -421,6 +434,7 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
421434 if old == nil {
422435 return
423436 }
437+ old.cancelResizePaint()
424438 d.unregisterPreview(old)
425439 old.setSession(nil)
426440 d.log.Info("client displaced")
@@ -430,8 +444,9 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
430444 }
431445
432446 // firstPaint guarantees the freshly attached client sees the full screen: if
433-// the tab size differs from the client's it resizes first (which paints a
434-// full redraw), otherwise it forces a full paint against the fresh renderer.
447+// the tab size differs from the client's it resizes first, then immediately
448+// emits a full redraw. Attach must not wait for the resize-idle fallback
449+// timer.
435450 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
436451 tb := sess.activeTab()
437452 if tb == nil {
@@ -442,9 +457,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
442457 tb.mu.Unlock()
443458
444459 if clientSize.Valid() && wsz != tabSize(clientSize) {
445- d.resize(sess, ac, clientSize)
446- d.activateTab(sess, sess.activeTab())
447- return
460+ d.resizeForFirstPaint(sess, ac, clientSize)
448461 }
449462 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
450463 d.paint(sess, ac, true)
@@ -523,6 +536,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
523536 if !sess.detachIfCurrent(ac) {
524537 return // already displaced by a newer client; nothing to do
525538 }
539+ ac.cancelResizePaint()
526540 d.unregisterPreview(ac)
527541 sess.mu.Lock()
528542 ephemeral := sess.ephemeral
@@ -575,14 +589,31 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
575589 }
576590
577591 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
592+ d.resizeWithPaint(sess, ac, sz, true)
593+}
594+
595+// resizeForFirstPaint applies attach geometry without installing an idle timer:
596+// firstPaint immediately emits the required full frame itself.
597+func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {
598+ d.resizeWithPaint(sess, ac, sz, false)
599+}
600+
601+func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Size, schedulePaint bool) {
578602 if !sz.Valid() {
579603 return
580604 }
581605 d.exitCopyMode(ac)
582- tbSize := tabSize(sz)
583606 if ac != nil {
607+ // Suspend emission until every pane has adopted this geometry and the
608+ // bounded resize paint is armed. PTY readers remain independent.
609+ ac.sendMu.Lock()
610+ defer ac.sendMu.Unlock()
611+ if ac.currentSession() != sess {
612+ return
613+ }
584614 ac.size = sz
585615 }
616+ tbSize := tabSize(sz)
586617
587618 sess.mu.Lock()
588619 tabs := append([]*tab(nil), sess.tabs...)
@@ -601,9 +632,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
601632 // outside tab.mu so its PTY resize cannot block tab state.
602633 d.resizeActiveFloating(sess.activeTab())
603634 markSnapshotDirty(sess)
604- if ac != nil {
635+ if ac != nil && schedulePaint {
605636 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
606- d.paint(sess, ac, true)
637+ d.scheduleResizePaintLocked(sess, ac)
607638 }
608639 }
609640
@@ -614,6 +645,7 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
614645 return
615646 }
616647 if sess.detachIfCurrent(ac) {
648+ ac.cancelResizePaint()
617649 d.unregisterPreview(ac)
618650 if d.parkAttachment(sess, ac) {
619651 _ = ac.closeCapturedTransport(failed)
@@ -38,25 +38,28 @@ import (
38 )38 )
39 39
40 type attachedClient struct {40 type attachedClient struct {
41- tr ports.Transport41+ tr ports.Transport
42- output *outputStateStream42+ output *outputStateStream
43- overlays *overlayRuntime43+ overlays *overlayRuntime
44- clientID [16]byte44+ clientID [16]byte
45- resumeCapable bool45+ resumeCapable bool
46- resumeToken uint6446+ resumeToken uint64
47- parked bool47+ parked bool
48- echoAck atomic.Uint6448+ echoAck atomic.Uint64
49- bars barCache // only touched while sendMu is held49+ bars barCache // only touched while sendMu is held
50- composed composedFrameCache // only touched while sendMu is held50+ composed composedFrameCache // only touched while sendMu is held
51- size domain.Size51+ resizePaint pendingByteTimer // guarded by sendMu
52- keys *keys.Router52+ resizePaintGeneration uint64 // guarded by sendMu
53- sess Guarded[*session]53+ resizePaintPending bool // guarded by sendMu
54- mouseScan mouse.Scanner54+ size domain.Size
55- themeMu sync.Mutex55+ keys *keys.Router
56- theme themeui.Theme56+ sess Guarded[*session]
57- clientTheme themeui.Theme57+ mouseScan mouse.Scanner
58- lastCursor cursorOut58+ themeMu sync.Mutex
59- recentNav recentSessionNavigator59+ theme themeui.Theme
60+ clientTheme themeui.Theme
61+ lastCursor cursorOut
62+ recentNav recentSessionNavigator
60 // historyNavMu protects historyNav and historyNavTimer. When paint needs63 // historyNavMu protects historyNav and historyNavTimer. When paint needs
61 // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.64 // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.
62 historyNavMu sync.Mutex65 historyNavMu sync.Mutex
@@ -104,6 +107,16 @@ func (p *pendingByteTimer) stop() {
104 }107 }
105 }108 }
106 109
110+// cancelResizePaint invalidates callbacks before detaching or replacing an
111+// attachment. It deliberately takes sendMu before any session/daemon lock.
112+func (ac *attachedClient) cancelResizePaint() {
113+ ac.sendMu.Lock()
114+ ac.resizePaintGeneration++
115+ ac.resizePaintPending = false
116+ ac.resizePaint.stop()
117+ ac.sendMu.Unlock()
118+}
119+
107 func (ac *attachedClient) initOverlays() {120 func (ac *attachedClient) initOverlays() {
108 if ac.overlays == nil {121 if ac.overlays == nil {
109 ac.overlays = newOverlayRuntime(ac)122 ac.overlays = newOverlayRuntime(ac)
@@ -421,6 +434,7 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
421 if old == nil {434 if old == nil {
422 return435 return
423 }436 }
437+ old.cancelResizePaint()
424 d.unregisterPreview(old)438 d.unregisterPreview(old)
425 old.setSession(nil)439 old.setSession(nil)
426 d.log.Info("client displaced")440 d.log.Info("client displaced")
@@ -430,8 +444,9 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) {
430 }444 }
431 445
432 // firstPaint guarantees the freshly attached client sees the full screen: if446 // firstPaint guarantees the freshly attached client sees the full screen: if
433-// the tab size differs from the client's it resizes first (which paints a447+// the tab size differs from the client's it resizes first, then immediately
434-// full redraw), otherwise it forces a full paint against the fresh renderer.448+// emits a full redraw. Attach must not wait for the resize-idle fallback
449+// timer.
435 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {450 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
436 tb := sess.activeTab()451 tb := sess.activeTab()
437 if tb == nil {452 if tb == nil {
@@ -442,9 +457,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
442 tb.mu.Unlock()457 tb.mu.Unlock()
443 458
444 if clientSize.Valid() && wsz != tabSize(clientSize) {459 if clientSize.Valid() && wsz != tabSize(clientSize) {
445- d.resize(sess, ac, clientSize)460+ d.resizeForFirstPaint(sess, ac, clientSize)
446- d.activateTab(sess, sess.activeTab())
447- return
448 }461 }
449 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)462 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
450 d.paint(sess, ac, true)463 d.paint(sess, ac, true)
@@ -523,6 +536,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
523 if !sess.detachIfCurrent(ac) {536 if !sess.detachIfCurrent(ac) {
524 return // already displaced by a newer client; nothing to do537 return // already displaced by a newer client; nothing to do
525 }538 }
539+ ac.cancelResizePaint()
526 d.unregisterPreview(ac)540 d.unregisterPreview(ac)
527 sess.mu.Lock()541 sess.mu.Lock()
528 ephemeral := sess.ephemeral542 ephemeral := sess.ephemeral
@@ -575,14 +589,31 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
575 }589 }
576 590
577 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {591 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
592+ d.resizeWithPaint(sess, ac, sz, true)
593+}
594+
595+// resizeForFirstPaint applies attach geometry without installing an idle timer:
596+// firstPaint immediately emits the required full frame itself.
597+func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) {
598+ d.resizeWithPaint(sess, ac, sz, false)
599+}
600+
601+func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Size, schedulePaint bool) {
578 if !sz.Valid() {602 if !sz.Valid() {
579 return603 return
580 }604 }
581 d.exitCopyMode(ac)605 d.exitCopyMode(ac)
582- tbSize := tabSize(sz)
583 if ac != nil {606 if ac != nil {
607+ // Suspend emission until every pane has adopted this geometry and the
608+ // bounded resize paint is armed. PTY readers remain independent.
609+ ac.sendMu.Lock()
610+ defer ac.sendMu.Unlock()
611+ if ac.currentSession() != sess {
612+ return
613+ }
584 ac.size = sz614 ac.size = sz
585 }615 }
616+ tbSize := tabSize(sz)
586 617
587 sess.mu.Lock()618 sess.mu.Lock()
588 tabs := append([]*tab(nil), sess.tabs...)619 tabs := append([]*tab(nil), sess.tabs...)
@@ -601,9 +632,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
601 // outside tab.mu so its PTY resize cannot block tab state.632 // outside tab.mu so its PTY resize cannot block tab state.
602 d.resizeActiveFloating(sess.activeTab())633 d.resizeActiveFloating(sess.activeTab())
603 markSnapshotDirty(sess)634 markSnapshotDirty(sess)
604- if ac != nil {635+ if ac != nil && schedulePaint {
605 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)636 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
606- d.paint(sess, ac, true)637+ d.scheduleResizePaintLocked(sess, ac)
607 }638 }
608 }639 }
609 640
@@ -614,6 +645,7 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por
614 return645 return
615 }646 }
616 if sess.detachIfCurrent(ac) {647 if sess.detachIfCurrent(ac) {
648+ ac.cancelResizePaint()
617 d.unregisterPreview(ac)649 d.unregisterPreview(ac)
618 if d.parkAttachment(sess, ac) {650 if d.parkAttachment(sess, ac) {
619 _ = ac.closeCapturedTransport(failed)651 _ = ac.closeCapturedTransport(failed)
M internal/usecase/daemon/output_state_sync_test.go +96 -0
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "errors"
55 "io"
6+ "strings"
67 "sync"
78 "testing"
89 "time"
@@ -269,6 +270,101 @@ func TestFailedPaintSendRollsBackOutputState(t *testing.T) {
269270 require.Zero(t, ac.output.acked)
270271 }
271272
273+func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
274+ for _, tc := range []struct {
275+ name string
276+ window uint64
277+ }{
278+ {name: "datagram window", window: 1},
279+ {name: "stream window", window: maxUnackedOutputStates},
280+ } {
281+ t.Run(tc.name, func(t *testing.T) {
282+ p, releasePTY := newBlockingPTY(t)
283+ defer releasePTY()
284+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
285+ ac.output.maxOutstanding = tc.window
286+ pane := sess.activeTab().focusedPane()
287+
288+ pane.screen.Write([]byte(strings.Repeat("A", 79)))
289+ d.paint(sess, ac, true)
290+ client := vt.NewScreen(80, 24)
291+ initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
292+ ac.ackOutputState(initial.NewStateNum)
293+
294+ // The child can redraw the newly exposed columns while the resize
295+ // output is being prepared. The first visible grown frame must contain
296+ // that redraw, regardless of the negotiated output window.
297+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
298+ pane.screen.Write([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))
299+ d.paint(sess, ac, false)
300+
301+ client.Resize(120, 24)
302+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
303+ require.Equal(t, 'B', client.Frame.At(100, 1).Rune,
304+ "first grown frame exposed stale pre-SIGWINCH pane content")
305+ requireNoOutputFrame(t, sends)
306+ })
307+ }
308+}
309+
310+func TestResizeWithoutPTYOutputFlushesOneFullFrameAtDeadline(t *testing.T) {
311+ p, releasePTY := newBlockingPTY(t)
312+ defer releasePTY()
313+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
314+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
315+ d.clock = clk
316+
317+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
318+ var timer *signalTimer
319+ select {
320+ case timer = <-clk.timers:
321+ case <-time.After(time.Second):
322+ t.Fatal("resize did not schedule a bounded paint")
323+ }
324+ requireNoOutputFrame(t, sends)
325+
326+ timer.ch <- time.Now()
327+ frame := awaitFrame(t, sends, ports.MsgOutput)
328+ client := vt.NewScreen(120, 24)
329+ out := mustApplyOutput(t, client, frame)
330+ require.Zero(t, out.BaseStateNum)
331+ require.Contains(t, screenLineText(client, 0), "1")
332+ require.Contains(t, screenLineText(client, 23), "work")
333+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
334+ requireNoOutputFrame(t, sends)
335+}
336+
337+func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
338+ p, releasePTY := newBlockingPTY(t)
339+ defer releasePTY()
340+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
341+ clk := &signalClock{timers: make(chan *signalTimer, 2)}
342+ d.clock = clk
343+
344+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 24})
345+ var first *signalTimer
346+ select {
347+ case first = <-clk.timers:
348+ case <-time.After(time.Second):
349+ t.Fatal("first resize did not schedule a bounded paint")
350+ }
351+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
352+ var latest *signalTimer
353+ select {
354+ case latest = <-clk.timers:
355+ case <-time.After(time.Second):
356+ t.Fatal("latest resize did not replace the bounded paint")
357+ }
358+
359+ first.ch <- time.Now()
360+ requireNoOutputFrame(t, sends)
361+ latest.ch <- time.Now()
362+ awaitFrame(t, sends, ports.MsgOutput)
363+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
364+ require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width)
365+ requireNoOutputFrame(t, sends)
366+}
367+
272368 func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {
273369 p, releasePTY := newBlockingPTY(t)
274370 defer releasePTY()
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "errors"4 "errors"
5 "io"5 "io"
6+ "strings"
6 "sync"7 "sync"
7 "testing"8 "testing"
8 "time"9 "time"
@@ -269,6 +270,101 @@ func TestFailedPaintSendRollsBackOutputState(t *testing.T) {
269 require.Zero(t, ac.output.acked)270 require.Zero(t, ac.output.acked)
270 }271 }
271 272
273+func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
274+ for _, tc := range []struct {
275+ name string
276+ window uint64
277+ }{
278+ {name: "datagram window", window: 1},
279+ {name: "stream window", window: maxUnackedOutputStates},
280+ } {
281+ t.Run(tc.name, func(t *testing.T) {
282+ p, releasePTY := newBlockingPTY(t)
283+ defer releasePTY()
284+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
285+ ac.output.maxOutstanding = tc.window
286+ pane := sess.activeTab().focusedPane()
287+
288+ pane.screen.Write([]byte(strings.Repeat("A", 79)))
289+ d.paint(sess, ac, true)
290+ client := vt.NewScreen(80, 24)
291+ initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
292+ ac.ackOutputState(initial.NewStateNum)
293+
294+ // The child can redraw the newly exposed columns while the resize
295+ // output is being prepared. The first visible grown frame must contain
296+ // that redraw, regardless of the negotiated output window.
297+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
298+ pane.screen.Write([]byte("\x1b[1;81H" + strings.Repeat("B", 40)))
299+ d.paint(sess, ac, false)
300+
301+ client.Resize(120, 24)
302+ mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
303+ require.Equal(t, 'B', client.Frame.At(100, 1).Rune,
304+ "first grown frame exposed stale pre-SIGWINCH pane content")
305+ requireNoOutputFrame(t, sends)
306+ })
307+ }
308+}
309+
310+func TestResizeWithoutPTYOutputFlushesOneFullFrameAtDeadline(t *testing.T) {
311+ p, releasePTY := newBlockingPTY(t)
312+ defer releasePTY()
313+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
314+ clk := &signalClock{timers: make(chan *signalTimer, 1)}
315+ d.clock = clk
316+
317+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
318+ var timer *signalTimer
319+ select {
320+ case timer = <-clk.timers:
321+ case <-time.After(time.Second):
322+ t.Fatal("resize did not schedule a bounded paint")
323+ }
324+ requireNoOutputFrame(t, sends)
325+
326+ timer.ch <- time.Now()
327+ frame := awaitFrame(t, sends, ports.MsgOutput)
328+ client := vt.NewScreen(120, 24)
329+ out := mustApplyOutput(t, client, frame)
330+ require.Zero(t, out.BaseStateNum)
331+ require.Contains(t, screenLineText(client, 0), "1")
332+ require.Contains(t, screenLineText(client, 23), "work")
333+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
334+ requireNoOutputFrame(t, sends)
335+}
336+
337+func TestResizeBurstFlushesOnlyLatestGeometry(t *testing.T) {
338+ p, releasePTY := newBlockingPTY(t)
339+ defer releasePTY()
340+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
341+ clk := &signalClock{timers: make(chan *signalTimer, 2)}
342+ d.clock = clk
343+
344+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 24})
345+ var first *signalTimer
346+ select {
347+ case first = <-clk.timers:
348+ case <-time.After(time.Second):
349+ t.Fatal("first resize did not schedule a bounded paint")
350+ }
351+ d.resize(sess, ac, domain.Size{Cols: 120, Rows: 24})
352+ var latest *signalTimer
353+ select {
354+ case latest = <-clk.timers:
355+ case <-time.After(time.Second):
356+ t.Fatal("latest resize did not replace the bounded paint")
357+ }
358+
359+ first.ch <- time.Now()
360+ requireNoOutputFrame(t, sends)
361+ latest.ch <- time.Now()
362+ awaitFrame(t, sends, ports.MsgOutput)
363+ require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
364+ require.Equal(t, 120, sess.activeTab().focusedPane().screen.Frame.Width)
365+ requireNoOutputFrame(t, sends)
366+}
367+
272 func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {368 func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) {
273 p, releasePTY := newBlockingPTY(t)369 p, releasePTY := newBlockingPTY(t)
274 defer releasePTY()370 defer releasePTY()
M internal/usecase/daemon/picker_test.go +3 -0
@@ -531,6 +531,9 @@ func TestPickerResizeRecomposesModal(t *testing.T) {
531531 d.enterPicker(sess, ac)
532532 awaitFrame(t, sends, ports.MsgOutput)
533533 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
534+ // Picker recomposition is the relevant event here; do not depend on a real
535+ // resize-idle timer in this synchronous rendering test.
536+ d.paint(sess, ac, false)
534537
535538 out := awaitFrame(t, sends, ports.MsgOutput)
536539 msg, err := ports.UnmarshalOutput(out.Payload)
@@ -531,6 +531,9 @@ func TestPickerResizeRecomposesModal(t *testing.T) {
531 d.enterPicker(sess, ac)531 d.enterPicker(sess, ac)
532 awaitFrame(t, sends, ports.MsgOutput)532 awaitFrame(t, sends, ports.MsgOutput)
533 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})533 d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
534+ // Picker recomposition is the relevant event here; do not depend on a real
535+ // resize-idle timer in this synchronous rendering test.
536+ d.paint(sess, ac, false)
534 537
535 out := awaitFrame(t, sends, ports.MsgOutput)538 out := awaitFrame(t, sends, ports.MsgOutput)
536 msg, err := ports.UnmarshalOutput(out.Payload)539 msg, err := ports.UnmarshalOutput(out.Payload)
M internal/usecase/daemon/render.go +23 -0
@@ -312,7 +312,21 @@ func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {
312312 return ids
313313 }
314314
315+func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {
316+ ac.resizePaint.stop()
317+ ac.resizePaintGeneration++
318+ generation := ac.resizePaintGeneration
319+ ac.resizePaintPending = true
320+ ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) {
321+ d.paintForResizeGeneration(sess, ac, true, generation)
322+ })
323+}
324+
315325 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
326+ d.paintForResizeGeneration(sess, ac, reset, 0)
327+}
328+
329+func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration uint64) {
316330 tb := sess.activeTab()
317331 if tb == nil {
318332 return
@@ -324,6 +338,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
324338 ac.sendMu.Unlock()
325339 return
326340 }
341+ if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {
342+ ac.sendMu.Unlock()
343+ return
344+ }
345+ if ac.resizePaintPending {
346+ ac.resizePaint.stop()
347+ ac.resizePaintPending = false
348+ reset = true
349+ }
327350 // Ack-gated coalescing: while the client is at least maxUnackedOutputStates
328351 // behind, skip composing another diff and mark the paint deferred. The
329352 // MsgOutputAck handler flushes one cumulative paint once acks catch up. A
@@ -312,7 +312,21 @@ func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {
312 return ids312 return ids
313 }313 }
314 314
315+func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {
316+ ac.resizePaint.stop()
317+ ac.resizePaintGeneration++
318+ generation := ac.resizePaintGeneration
319+ ac.resizePaintPending = true
320+ ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) {
321+ d.paintForResizeGeneration(sess, ac, true, generation)
322+ })
323+}
324+
315 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {325 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
326+ d.paintForResizeGeneration(sess, ac, reset, 0)
327+}
328+
329+func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration uint64) {
316 tb := sess.activeTab()330 tb := sess.activeTab()
317 if tb == nil {331 if tb == nil {
318 return332 return
@@ -324,6 +338,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
324 ac.sendMu.Unlock()338 ac.sendMu.Unlock()
325 return339 return
326 }340 }
341+ if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {
342+ ac.sendMu.Unlock()
343+ return
344+ }
345+ if ac.resizePaintPending {
346+ ac.resizePaint.stop()
347+ ac.resizePaintPending = false
348+ reset = true
349+ }
327 // Ack-gated coalescing: while the client is at least maxUnackedOutputStates350 // Ack-gated coalescing: while the client is at least maxUnackedOutputStates
328 // behind, skip composing another diff and mark the paint deferred. The351 // behind, skip composing another diff and mark the paint deferred. The
329 // MsgOutputAck handler flushes one cumulative paint once acks catch up. A352 // MsgOutputAck handler flushes one cumulative paint once acks catch up. A
M internal/usecase/daemon/render_test.go +3 -0
@@ -932,6 +932,9 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
932932 ac.setSession(sess)
933933
934934 d.resize(sess, ac, newSize)
935+ // This test verifies resize ordering, not the idle fallback. Consume the
936+ // pending resize paint deterministically under the non-firing stub clock.
937+ d.paint(sess, ac, false)
935938
936939 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
937940 require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")
@@ -932,6 +932,9 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
932 ac.setSession(sess)932 ac.setSession(sess)
933 933
934 d.resize(sess, ac, newSize)934 d.resize(sess, ac, newSize)
935+ // This test verifies resize ordering, not the idle fallback. Consume the
936+ // pending resize paint deterministically under the non-firing stub clock.
937+ d.paint(sess, ac, false)
935 938
936 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")939 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
937 require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")940 require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")
M internal/usecase/daemon/resume.go +4 -2
@@ -48,8 +48,10 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
4848 if !d.prepareParkAttachment(sess, ac) {
4949 return false
5050 }
51- // Drop any deferred diff so a resumed client starts clean; resume forces a
52- // full reset repaint anyway.
51+ // A parked attachment has no valid visible destination. Retire both the
52+ // deferred diff and any resize-idle callback before registering its grace
53+ // timer; resume performs a fresh full paint.
54+ ac.cancelResizePaint()
5355 ac.clearPaintDeferred()
5456 d.mu.Lock()
5557 if d.closing {
@@ -48,8 +48,10 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
48 if !d.prepareParkAttachment(sess, ac) {48 if !d.prepareParkAttachment(sess, ac) {
49 return false49 return false
50 }50 }
51- // Drop any deferred diff so a resumed client starts clean; resume forces a51+ // A parked attachment has no valid visible destination. Retire both the
52- // full reset repaint anyway.52+ // deferred diff and any resize-idle callback before registering its grace
53+ // timer; resume performs a fresh full paint.
54+ ac.cancelResizePaint()
53 ac.clearPaintDeferred()55 ac.clearPaintDeferred()
54 d.mu.Lock()56 d.mu.Lock()
55 if d.closing {57 if d.closing {
M internal/usecase/daemon/session.go +1 -0
@@ -730,6 +730,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
730730 sess.client = nil
731731 sess.mu.Unlock()
732732 if ac != nil {
733+ ac.cancelResizePaint()
733734 d.unregisterPreview(ac)
734735 ac.setSession(nil)
735736 }
@@ -730,6 +730,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
730 sess.client = nil730 sess.client = nil
731 sess.mu.Unlock()731 sess.mu.Unlock()
732 if ac != nil {732 if ac != nil {
733+ ac.cancelResizePaint()
733 d.unregisterPreview(ac)734 d.unregisterPreview(ac)
734 ac.setSession(nil)735 ac.setSession(nil)
735 }736 }
M internal/usecase/daemon/status_test.go +5 -1
@@ -927,7 +927,8 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
927927 func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
928928 p1, releasePTY1 := newBlockingPTY(t)
929929 p2, releasePTY2 := newBlockingPTY(t)
930- d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
930+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
931+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), clock)
931932 tr, sends, releaseConn := newConn(t,
932933 mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
933934 frameInput([]byte("\x1b ")),
@@ -943,6 +944,9 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
943944 palette := awaitFrame(t, sends, ports.MsgOutput)
944945 created := awaitFrame(t, sends, ports.MsgOutput)
945946 switched := awaitFrame(t, sends, ports.MsgOutput)
947+ // The resize frame is intentionally idle-coalesced; fire its fake-clock
948+ // deadline rather than relying on the old synchronous behavior.
949+ (<-clock.timers).ch <- time.Time{}
946950 resized := awaitFrame(t, sends, ports.MsgOutput)
947951
948952 _ = palette
@@ -927,7 +927,8 @@ func TestStatusBarCopyFeedbackBoundaryWidths(t *testing.T) {
927 func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {927 func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
928 p1, releasePTY1 := newBlockingPTY(t)928 p1, releasePTY1 := newBlockingPTY(t)
929 p2, releasePTY2 := newBlockingPTY(t)929 p2, releasePTY2 := newBlockingPTY(t)
930- d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})930+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
931+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), clock)
931 tr, sends, releaseConn := newConn(t,932 tr, sends, releaseConn := newConn(t,
932 mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),933 mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
933 frameInput([]byte("\x1b ")),934 frameInput([]byte("\x1b ")),
@@ -943,6 +944,9 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
943 palette := awaitFrame(t, sends, ports.MsgOutput)944 palette := awaitFrame(t, sends, ports.MsgOutput)
944 created := awaitFrame(t, sends, ports.MsgOutput)945 created := awaitFrame(t, sends, ports.MsgOutput)
945 switched := awaitFrame(t, sends, ports.MsgOutput)946 switched := awaitFrame(t, sends, ports.MsgOutput)
947+ // The resize frame is intentionally idle-coalesced; fire its fake-clock
948+ // deadline rather than relying on the old synchronous behavior.
949+ (<-clock.timers).ch <- time.Time{}
946 resized := awaitFrame(t, sends, ports.MsgOutput)950 resized := awaitFrame(t, sends, ports.MsgOutput)
947 951
948 _ = palette952 _ = palette

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 2

Tags 13

↑↓ moveenter openesc close