fix(render): prevent stale frames across transitions
- commit
- a4dfd3e56690a3dae90d8bdf06bb9328b1f2ac66
- author
- brice · · 2026-07-13 06:03 UTC
- parent
- f3c0469
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +24 -10
M internal/usecase/daemon/client.go +11 -3
| @@ -383,6 +383,10 @@ func (d *Daemon) handoffCoordinator(from, target *session, old, current *attache | |||
| 383 | 383 | } | |
| 384 | 384 | current.sendMu.Lock() | |
| 385 | 385 | current.output.rebase() | |
| 386 | + | // Capture snapshots are attachment-owned but pane IDs are only session- | |
| 387 | + | // local. Discard them while the attachment is exclusively held so a pane | |
| 388 | + | // in target cannot reuse pixels from an identically named source pane. | |
| 389 | + | current.captureFrames = nil | |
| 386 | 390 | current.sendMu.Unlock() | |
| 387 | 391 | d.attachCoordinator(target, old, current, true) | |
| 388 | 392 | } | |
| @@ -470,14 +474,18 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 470 | 474 | wsz := tb.size | |
| 471 | 475 | tb.mu.Unlock() | |
| 472 | 476 | ||
| 473 | - | if clientSize.Valid() && wsz != tabSize(clientSize) { | |
| 477 | + | resized := clientSize.Valid() && wsz != tabSize(clientSize) | |
| 478 | + | if resized { | |
| 474 | 479 | d.resizeForFirstPaint(sess, ac, clientSize) | |
| 475 | 480 | } | |
| 476 | 481 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 477 | 482 | // Activation can synchronously resize a retained floating pane through the | |
| 478 | - | // same transaction. Fold its invalidation into this mandatory Welcome frame. | |
| 483 | + | // same transaction. A resize already emitted the mandatory reset, so avoid | |
| 484 | + | // queuing a second source-session frame that could outlive a hand-off. | |
| 479 | 485 | d.activateTab(sess, tb) | |
| 480 | - | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 486 | + | if !resized { | |
| 487 | + | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 488 | + | } | |
| 481 | 489 | } | |
| 482 | 490 | ||
| 483 | 491 | // runConnLoop is the per-connection input router: it pumps client messages | |
| @@ -383,6 +383,10 @@ func (d *Daemon) handoffCoordinator(from, target *session, old, current *attache | |||||
| 383 | } | 383 | } | ||
| 384 | current.sendMu.Lock() | 384 | current.sendMu.Lock() | ||
| 385 | current.output.rebase() | 385 | current.output.rebase() | ||
| 386 | + | // Capture snapshots are attachment-owned but pane IDs are only session- | |||
| 387 | + | // local. Discard them while the attachment is exclusively held so a pane | |||
| 388 | + | // in target cannot reuse pixels from an identically named source pane. | |||
| 389 | + | current.captureFrames = nil | |||
| 386 | current.sendMu.Unlock() | 390 | current.sendMu.Unlock() | ||
| 387 | d.attachCoordinator(target, old, current, true) | 391 | d.attachCoordinator(target, old, current, true) | ||
| 388 | } | 392 | } | ||
| @@ -470,14 +474,18 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||||
| 470 | wsz := tb.size | 474 | wsz := tb.size | ||
| 471 | tb.mu.Unlock() | 475 | tb.mu.Unlock() | ||
| 472 | 476 | ||||
| 473 | - | if clientSize.Valid() && wsz != tabSize(clientSize) { | 477 | + | resized := clientSize.Valid() && wsz != tabSize(clientSize) |
| 478 | + | if resized { | |||
| 474 | d.resizeForFirstPaint(sess, ac, clientSize) | 479 | d.resizeForFirstPaint(sess, ac, clientSize) | ||
| 475 | } | 480 | } | ||
| 476 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | 481 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | ||
| 477 | // Activation can synchronously resize a retained floating pane through the | 482 | // Activation can synchronously resize a retained floating pane through the | ||
| 478 | - | // same transaction. Fold its invalidation into this mandatory Welcome frame. | 483 | + | // same transaction. A resize already emitted the mandatory reset, so avoid |
| 484 | + | // queuing a second source-session frame that could outlive a hand-off. | |||
| 479 | d.activateTab(sess, tb) | 485 | d.activateTab(sess, tb) | ||
| 480 | - | d.invalidateRenderNow(sess, ac, true, "client.go") | 486 | + | if !resized { |
| 487 | + | d.invalidateRenderNow(sess, ac, true, "client.go") | |||
| 488 | + | } | |||
| 481 | } | 489 | } | ||
| 482 | 490 | ||||
| 483 | // runConnLoop is the per-connection input router: it pumps client messages | 491 | // runConnLoop is the per-connection input router: it pumps client messages | ||
M pkg/vt/screen.go +13 -7
| @@ -92,12 +92,13 @@ type Screen struct { | |||
| 92 | 92 | defaultColorsKnown bool | |
| 93 | 93 | terminalTitle string | |
| 94 | 94 | ||
| 95 | - | damage []renderer.Damage | |
| 96 | - | damageGeneration uint64 | |
| 97 | - | damageSaturated bool | |
| 98 | - | escapeBuf []byte | |
| 99 | - | csiScratch []int | |
| 100 | - | sgrScratch []int | |
| 95 | + | damage []renderer.Damage | |
| 96 | + | damageGeneration uint64 | |
| 97 | + | damageSaturated bool | |
| 98 | + | damageFullRedrawSticky bool | |
| 99 | + | escapeBuf []byte | |
| 100 | + | csiScratch []int | |
| 101 | + | sgrScratch []int | |
| 101 | 102 | ||
| 102 | 103 | scrollTop int | |
| 103 | 104 | scrollBottom int | |
| @@ -191,6 +192,7 @@ func (s *Screen) Damage() []renderer.Damage { return s.damage } | |||
| 191 | 192 | func (s *Screen) ClearDamage() { | |
| 192 | 193 | s.damage = s.damage[:0] | |
| 193 | 194 | s.damageSaturated = false | |
| 195 | + | s.damageFullRedrawSticky = false | |
| 194 | 196 | } | |
| 195 | 197 | ||
| 196 | 198 | // CaptureDamage snapshots pending damage without consuming it. | |
| @@ -652,7 +654,7 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |||
| 652 | 654 | ||
| 653 | 655 | func (s *Screen) record(d renderer.Damage) { | |
| 654 | 656 | s.damageGeneration++ | |
| 655 | - | if s.damageSaturated { | |
| 657 | + | if s.damageSaturated || s.damageFullRedrawSticky { | |
| 656 | 658 | return | |
| 657 | 659 | } | |
| 658 | 660 | if len(s.damage) >= maxPendingDamage { | |
| @@ -679,6 +681,10 @@ func (s *Screen) record(d renderer.Damage) { | |||
| 679 | 681 | func (s *Screen) fullRedraw() { | |
| 680 | 682 | s.damageGeneration++ | |
| 681 | 683 | s.damage = []renderer.Damage{renderer.FullRedraw()} | |
| 684 | + | // A structural frame replacement (such as DEC 1049 exit) cannot be | |
| 685 | + | // represented by the following text damage alone. Retain the redraw until | |
| 686 | + | // the render owner acknowledges this screen generation. | |
| 687 | + | s.damageFullRedrawSticky = true | |
| 682 | 688 | } | |
| 683 | 689 | ||
| 684 | 690 | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | |
| @@ -92,12 +92,13 @@ type Screen struct { | |||||
| 92 | defaultColorsKnown bool | 92 | defaultColorsKnown bool | ||
| 93 | terminalTitle string | 93 | terminalTitle string | ||
| 94 | 94 | ||||
| 95 | - | damage []renderer.Damage | 95 | + | damage []renderer.Damage |
| 96 | - | damageGeneration uint64 | 96 | + | damageGeneration uint64 |
| 97 | - | damageSaturated bool | 97 | + | damageSaturated bool |
| 98 | - | escapeBuf []byte | 98 | + | damageFullRedrawSticky bool |
| 99 | - | csiScratch []int | 99 | + | escapeBuf []byte |
| 100 | - | sgrScratch []int | 100 | + | csiScratch []int |
| 101 | + | sgrScratch []int | |||
| 101 | 102 | ||||
| 102 | scrollTop int | 103 | scrollTop int | ||
| 103 | scrollBottom int | 104 | scrollBottom int | ||
| @@ -191,6 +192,7 @@ func (s *Screen) Damage() []renderer.Damage { return s.damage } | |||||
| 191 | func (s *Screen) ClearDamage() { | 192 | func (s *Screen) ClearDamage() { | ||
| 192 | s.damage = s.damage[:0] | 193 | s.damage = s.damage[:0] | ||
| 193 | s.damageSaturated = false | 194 | s.damageSaturated = false | ||
| 195 | + | s.damageFullRedrawSticky = false | |||
| 194 | } | 196 | } | ||
| 195 | 197 | ||||
| 196 | // CaptureDamage snapshots pending damage without consuming it. | 198 | // CaptureDamage snapshots pending damage without consuming it. | ||
| @@ -652,7 +654,7 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |||||
| 652 | 654 | ||||
| 653 | func (s *Screen) record(d renderer.Damage) { | 655 | func (s *Screen) record(d renderer.Damage) { | ||
| 654 | s.damageGeneration++ | 656 | s.damageGeneration++ | ||
| 655 | - | if s.damageSaturated { | 657 | + | if s.damageSaturated || s.damageFullRedrawSticky { |
| 656 | return | 658 | return | ||
| 657 | } | 659 | } | ||
| 658 | if len(s.damage) >= maxPendingDamage { | 660 | if len(s.damage) >= maxPendingDamage { | ||
| @@ -679,6 +681,10 @@ func (s *Screen) record(d renderer.Damage) { | |||||
| 679 | func (s *Screen) fullRedraw() { | 681 | func (s *Screen) fullRedraw() { | ||
| 680 | s.damageGeneration++ | 682 | s.damageGeneration++ | ||
| 681 | s.damage = []renderer.Damage{renderer.FullRedraw()} | 683 | s.damage = []renderer.Damage{renderer.FullRedraw()} | ||
| 684 | + | // A structural frame replacement (such as DEC 1049 exit) cannot be | |||
| 685 | + | // represented by the following text damage alone. Retain the redraw until | |||
| 686 | + | // the render owner acknowledges this screen generation. | |||
| 687 | + | s.damageFullRedrawSticky = true | |||
| 682 | } | 688 | } | ||
| 683 | 689 | ||||
| 684 | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | 690 | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | ||