fix(daemon): coalesce resize redraws
- commit
- 7099f686546ceba9bca25d7a55fe05b7299b7d72
- author
- brice · · 2026-07-11 10:29 UTC
- parent
- 52c0e49
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +194 -30
M internal/usecase/daemon/client.go +59 -27
| @@ -38,25 +38,28 @@ import ( | |||
| 38 | 38 | ) | |
| 39 | 39 | ||
| 40 | 40 | 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 | |
| 60 | 63 | // historyNavMu protects historyNav and historyNavTimer. When paint needs | |
| 61 | 64 | // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu. | |
| 62 | 65 | 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 | 120 | func (ac *attachedClient) initOverlays() { | |
| 108 | 121 | if ac.overlays == nil { | |
| 109 | 122 | ac.overlays = newOverlayRuntime(ac) | |
| @@ -421,6 +434,7 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) { | |||
| 421 | 434 | if old == nil { | |
| 422 | 435 | return | |
| 423 | 436 | } | |
| 437 | + | old.cancelResizePaint() | |
| 424 | 438 | d.unregisterPreview(old) | |
| 425 | 439 | old.setSession(nil) | |
| 426 | 440 | d.log.Info("client displaced") | |
| @@ -430,8 +444,9 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) { | |||
| 430 | 444 | } | |
| 431 | 445 | ||
| 432 | 446 | // 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. | |
| 435 | 450 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | |
| 436 | 451 | tb := sess.activeTab() | |
| 437 | 452 | if tb == nil { | |
| @@ -442,9 +457,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 442 | 457 | tb.mu.Unlock() | |
| 443 | 458 | ||
| 444 | 459 | 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) | |
| 448 | 461 | } | |
| 449 | 462 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 450 | 463 | d.paint(sess, ac, true) | |
| @@ -523,6 +536,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||
| 523 | 536 | if !sess.detachIfCurrent(ac) { | |
| 524 | 537 | return // already displaced by a newer client; nothing to do | |
| 525 | 538 | } | |
| 539 | + | ac.cancelResizePaint() | |
| 526 | 540 | d.unregisterPreview(ac) | |
| 527 | 541 | sess.mu.Lock() | |
| 528 | 542 | ephemeral := sess.ephemeral | |
| @@ -575,14 +589,31 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) | |||
| 575 | 589 | } | |
| 576 | 590 | ||
| 577 | 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 | 602 | if !sz.Valid() { | |
| 579 | 603 | return | |
| 580 | 604 | } | |
| 581 | 605 | d.exitCopyMode(ac) | |
| 582 | - | tbSize := tabSize(sz) | |
| 583 | 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 | 614 | ac.size = sz | |
| 585 | 615 | } | |
| 616 | + | tbSize := tabSize(sz) | |
| 586 | 617 | ||
| 587 | 618 | sess.mu.Lock() | |
| 588 | 619 | tabs := append([]*tab(nil), sess.tabs...) | |
| @@ -601,9 +632,9 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 601 | 632 | // outside tab.mu so its PTY resize cannot block tab state. | |
| 602 | 633 | d.resizeActiveFloating(sess.activeTab()) | |
| 603 | 634 | markSnapshotDirty(sess) | |
| 604 | - | if ac != nil { | |
| 635 | + | if ac != nil && schedulePaint { | |
| 605 | 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 | 645 | return | |
| 615 | 646 | } | |
| 616 | 647 | if sess.detachIfCurrent(ac) { | |
| 648 | + | ac.cancelResizePaint() | |
| 617 | 649 | d.unregisterPreview(ac) | |
| 618 | 650 | if d.parkAttachment(sess, ac) { | |
| 619 | 651 | _ = ac.closeCapturedTransport(failed) | |
| @@ -38,25 +38,28 @@ import ( | |||||
| 38 | ) | 38 | ) | ||
| 39 | 39 | ||||
| 40 | type attachedClient struct { | 40 | type attachedClient struct { | ||
| 41 | - | tr ports.Transport | 41 | + | tr ports.Transport |
| 42 | - | output *outputStateStream | 42 | + | output *outputStateStream |
| 43 | - | overlays *overlayRuntime | 43 | + | overlays *overlayRuntime |
| 44 | - | clientID [16]byte | 44 | + | clientID [16]byte |
| 45 | - | resumeCapable bool | 45 | + | resumeCapable bool |
| 46 | - | resumeToken uint64 | 46 | + | resumeToken uint64 |
| 47 | - | parked bool | 47 | + | parked bool |
| 48 | - | echoAck atomic.Uint64 | 48 | + | echoAck atomic.Uint64 |
| 49 | - | bars barCache // only touched while sendMu is held | 49 | + | bars barCache // only touched while sendMu is held |
| 50 | - | composed composedFrameCache // only touched while sendMu is held | 50 | + | composed composedFrameCache // only touched while sendMu is held |
| 51 | - | size domain.Size | 51 | + | resizePaint pendingByteTimer // guarded by sendMu |
| 52 | - | keys *keys.Router | 52 | + | resizePaintGeneration uint64 // guarded by sendMu |
| 53 | - | sess Guarded[*session] | 53 | + | resizePaintPending bool // guarded by sendMu |
| 54 | - | mouseScan mouse.Scanner | 54 | + | size domain.Size |
| 55 | - | themeMu sync.Mutex | 55 | + | keys *keys.Router |
| 56 | - | theme themeui.Theme | 56 | + | sess Guarded[*session] |
| 57 | - | clientTheme themeui.Theme | 57 | + | mouseScan mouse.Scanner |
| 58 | - | lastCursor cursorOut | 58 | + | themeMu sync.Mutex |
| 59 | - | recentNav recentSessionNavigator | 59 | + | theme themeui.Theme |
| 60 | + | clientTheme themeui.Theme | |||
| 61 | + | lastCursor cursorOut | |||
| 62 | + | recentNav recentSessionNavigator | |||
| 60 | // historyNavMu protects historyNav and historyNavTimer. When paint needs | 63 | // 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.Mutex | 65 | 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 | return | 435 | 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: if | 446 | // 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 | 447 | + | // 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 do | 537 | 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.ephemeral | 542 | 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 | return | 603 | 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 = sz | 614 | 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 | return | 645 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "io" | |
| 6 | + | "strings" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| @@ -269,6 +270,101 @@ func TestFailedPaintSendRollsBackOutputState(t *testing.T) { | |||
| 269 | 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 | 368 | func TestDatagramWindowOneCoalescesPaintsUntilMsgAck(t *testing.T) { | |
| 273 | 369 | p, releasePTY := newBlockingPTY(t) | |
| 274 | 370 | 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) { | |||
| 531 | 531 | d.enterPicker(sess, ac) | |
| 532 | 532 | awaitFrame(t, sends, ports.MsgOutput) | |
| 533 | 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 | 538 | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 536 | 539 | 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 { | |||
| 312 | 312 | 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 | 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 | 330 | tb := sess.activeTab() | |
| 317 | 331 | if tb == nil { | |
| 318 | 332 | return | |
| @@ -324,6 +338,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 324 | 338 | ac.sendMu.Unlock() | |
| 325 | 339 | 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 | 350 | // Ack-gated coalescing: while the client is at least maxUnackedOutputStates | |
| 328 | 351 | // behind, skip composing another diff and mark the paint deferred. The | |
| 329 | 352 | // 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 ids | 312 | 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 | return | 332 | return | ||
| @@ -324,6 +338,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 324 | ac.sendMu.Unlock() | 338 | ac.sendMu.Unlock() | ||
| 325 | return | 339 | 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 maxUnackedOutputStates | 350 | // Ack-gated coalescing: while the client is at least maxUnackedOutputStates | ||
| 328 | // behind, skip composing another diff and mark the paint deferred. The | 351 | // behind, skip composing another diff and mark the paint deferred. The | ||
| 329 | // MsgOutputAck handler flushes one cumulative paint once acks catch up. A | 352 | // 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) { | |||
| 932 | 932 | ac.setSession(sess) | |
| 933 | 933 | ||
| 934 | 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 | 939 | require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize") | |
| 937 | 940 | 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 { | |||
| 48 | 48 | if !d.prepareParkAttachment(sess, ac) { | |
| 49 | 49 | return false | |
| 50 | 50 | } | |
| 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() | |
| 53 | 55 | ac.clearPaintDeferred() | |
| 54 | 56 | d.mu.Lock() | |
| 55 | 57 | 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 false | 49 | return false | ||
| 50 | } | 50 | } | ||
| 51 | - | // Drop any deferred diff so a resumed client starts clean; resume forces a | 51 | + | // 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 { | |||
| 730 | 730 | sess.client = nil | |
| 731 | 731 | sess.mu.Unlock() | |
| 732 | 732 | if ac != nil { | |
| 733 | + | ac.cancelResizePaint() | |
| 733 | 734 | d.unregisterPreview(ac) | |
| 734 | 735 | ac.setSession(nil) | |
| 735 | 736 | } | |
| @@ -730,6 +730,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 730 | sess.client = nil | 730 | 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) { | |||
| 927 | 927 | func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) { | |
| 928 | 928 | p1, releasePTY1 := newBlockingPTY(t) | |
| 929 | 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 | 932 | tr, sends, releaseConn := newConn(t, | |
| 932 | 933 | mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}), | |
| 933 | 934 | frameInput([]byte("\x1b ")), | |
| @@ -943,6 +944,9 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) { | |||
| 943 | 944 | palette := awaitFrame(t, sends, ports.MsgOutput) | |
| 944 | 945 | created := awaitFrame(t, sends, ports.MsgOutput) | |
| 945 | 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 | 950 | resized := awaitFrame(t, sends, ports.MsgOutput) | |
| 947 | 951 | ||
| 948 | 952 | _ = 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 | _ = palette | 952 | _ = palette | ||