detached a4dfd3e

fix(render): prevent stale frames across transitions

commit
a4dfd3e56690a3dae90d8bdf06bb9328b1f2ac66
author
brice · · 2026-07-13 06:03 UTC
parent
f3c0469
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
383383 }
384384 current.sendMu.Lock()
385385 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
386390 current.sendMu.Unlock()
387391 d.attachCoordinator(target, old, current, true)
388392 }
@@ -470,14 +474,18 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
470474 wsz := tb.size
471475 tb.mu.Unlock()
472476
473- if clientSize.Valid() && wsz != tabSize(clientSize) {
477+ resized := clientSize.Valid() && wsz != tabSize(clientSize)
478+ if resized {
474479 d.resizeForFirstPaint(sess, ac, clientSize)
475480 }
476481 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
477482 // 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.
479485 d.activateTab(sess, tb)
480- d.invalidateRenderNow(sess, ac, true, "client.go")
486+ if !resized {
487+ d.invalidateRenderNow(sess, ac, true, "client.go")
488+ }
481489 }
482490
483491 // 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.size474 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 the482 // 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 messages491 // 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 {
9292 defaultColorsKnown bool
9393 terminalTitle string
9494
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
101102
102103 scrollTop int
103104 scrollBottom int
@@ -191,6 +192,7 @@ func (s *Screen) Damage() []renderer.Damage { return s.damage }
191192 func (s *Screen) ClearDamage() {
192193 s.damage = s.damage[:0]
193194 s.damageSaturated = false
195+ s.damageFullRedrawSticky = false
194196 }
195197
196198 // CaptureDamage snapshots pending damage without consuming it.
@@ -652,7 +654,7 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
652654
653655 func (s *Screen) record(d renderer.Damage) {
654656 s.damageGeneration++
655- if s.damageSaturated {
657+ if s.damageSaturated || s.damageFullRedrawSticky {
656658 return
657659 }
658660 if len(s.damage) >= maxPendingDamage {
@@ -679,6 +681,10 @@ func (s *Screen) record(d renderer.Damage) {
679681 func (s *Screen) fullRedraw() {
680682 s.damageGeneration++
681683 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
682688 }
683689
684690 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
@@ -92,12 +92,13 @@ type Screen struct {
92 defaultColorsKnown bool92 defaultColorsKnown bool
93 terminalTitle string93 terminalTitle string
94 94
95- damage []renderer.Damage95+ damage []renderer.Damage
96- damageGeneration uint6496+ damageGeneration uint64
97- damageSaturated bool97+ damageSaturated bool
98- escapeBuf []byte98+ damageFullRedrawSticky bool
99- csiScratch []int99+ escapeBuf []byte
100- sgrScratch []int100+ csiScratch []int
101+ sgrScratch []int
101 102
102 scrollTop int103 scrollTop int
103 scrollBottom int104 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 = false194 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 return658 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) {

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