fix(vt): bound pending screen damage
- commit
- 22dd02927a2d406c9d9f978cffac6f96183ac2ee
- author
- brice · · 2026-07-12 09:12 UTC
- parent
- ef75ff9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +18 -2
M pkg/vt/screen.go +17 -1
| @@ -35,6 +35,10 @@ type screenState struct { | |||
| 35 | 35 | // so keep this value in sync if that payload cap changes. | |
| 36 | 36 | const maxEscapeBufferLen = 128 * 1024 | |
| 37 | 37 | ||
| 38 | + | // maxPendingDamage bounds metadata retained while no render transaction can | |
| 39 | + | // acknowledge a screen. Saturation falls back to one exact full redraw. | |
| 40 | + | const maxPendingDamage = 1024 | |
| 41 | + | ||
| 38 | 42 | const ( | |
| 39 | 43 | // ColorSchemeReportDark is the DEC 2031 dark-scheme report. | |
| 40 | 44 | ColorSchemeReportDark = "\x1b[?997;1n" | |
| @@ -88,6 +92,7 @@ type Screen struct { | |||
| 88 | 92 | ||
| 89 | 93 | damage []renderer.Damage | |
| 90 | 94 | damageGeneration uint64 | |
| 95 | + | damageSaturated bool | |
| 91 | 96 | escapeBuf []byte | |
| 92 | 97 | csiScratch []int | |
| 93 | 98 | sgrScratch []int | |
| @@ -173,7 +178,10 @@ type DamageCapture struct { | |||
| 173 | 178 | // Damage returns the current damage list. The caller must not modify the | |
| 174 | 179 | // returned slice; ClearDamage must be called after the damage is consumed. | |
| 175 | 180 | func (s *Screen) Damage() []renderer.Damage { return s.damage } | |
| 176 | - | func (s *Screen) ClearDamage() { s.damage = s.damage[:0] } | |
| 181 | + | func (s *Screen) ClearDamage() { | |
| 182 | + | s.damage = s.damage[:0] | |
| 183 | + | s.damageSaturated = false | |
| 184 | + | } | |
| 177 | 185 | ||
| 178 | 186 | // CaptureDamage snapshots pending damage without consuming it. | |
| 179 | 187 | func (s *Screen) CaptureDamage() DamageCapture { | |
| @@ -625,6 +633,14 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |||
| 625 | 633 | ||
| 626 | 634 | func (s *Screen) record(d renderer.Damage) { | |
| 627 | 635 | s.damageGeneration++ | |
| 636 | + | if s.damageSaturated { | |
| 637 | + | return | |
| 638 | + | } | |
| 639 | + | if len(s.damage) >= maxPendingDamage { | |
| 640 | + | s.damage = []renderer.Damage{renderer.FullRedraw()} | |
| 641 | + | s.damageSaturated = true | |
| 642 | + | return | |
| 643 | + | } | |
| 628 | 644 | // Replace FullRedraw with the first concrete damage item. | |
| 629 | 645 | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | |
| 630 | 646 | s.damage[0] = d | |
| @@ -35,6 +35,10 @@ type screenState struct { | |||||
| 35 | // so keep this value in sync if that payload cap changes. | 35 | // so keep this value in sync if that payload cap changes. | ||
| 36 | const maxEscapeBufferLen = 128 * 1024 | 36 | const maxEscapeBufferLen = 128 * 1024 | ||
| 37 | 37 | ||||
| 38 | + | // maxPendingDamage bounds metadata retained while no render transaction can | |||
| 39 | + | // acknowledge a screen. Saturation falls back to one exact full redraw. | |||
| 40 | + | const maxPendingDamage = 1024 | |||
| 41 | + | ||||
| 38 | const ( | 42 | const ( | ||
| 39 | // ColorSchemeReportDark is the DEC 2031 dark-scheme report. | 43 | // ColorSchemeReportDark is the DEC 2031 dark-scheme report. | ||
| 40 | ColorSchemeReportDark = "\x1b[?997;1n" | 44 | ColorSchemeReportDark = "\x1b[?997;1n" | ||
| @@ -88,6 +92,7 @@ type Screen struct { | |||||
| 88 | 92 | ||||
| 89 | damage []renderer.Damage | 93 | damage []renderer.Damage | ||
| 90 | damageGeneration uint64 | 94 | damageGeneration uint64 | ||
| 95 | + | damageSaturated bool | |||
| 91 | escapeBuf []byte | 96 | escapeBuf []byte | ||
| 92 | csiScratch []int | 97 | csiScratch []int | ||
| 93 | sgrScratch []int | 98 | sgrScratch []int | ||
| @@ -173,7 +178,10 @@ type DamageCapture struct { | |||||
| 173 | // Damage returns the current damage list. The caller must not modify the | 178 | // Damage returns the current damage list. The caller must not modify the | ||
| 174 | // returned slice; ClearDamage must be called after the damage is consumed. | 179 | // returned slice; ClearDamage must be called after the damage is consumed. | ||
| 175 | func (s *Screen) Damage() []renderer.Damage { return s.damage } | 180 | func (s *Screen) Damage() []renderer.Damage { return s.damage } | ||
| 176 | - | func (s *Screen) ClearDamage() { s.damage = s.damage[:0] } | 181 | + | func (s *Screen) ClearDamage() { |
| 182 | + | s.damage = s.damage[:0] | |||
| 183 | + | s.damageSaturated = false | |||
| 184 | + | } | |||
| 177 | 185 | ||||
| 178 | // CaptureDamage snapshots pending damage without consuming it. | 186 | // CaptureDamage snapshots pending damage without consuming it. | ||
| 179 | func (s *Screen) CaptureDamage() DamageCapture { | 187 | func (s *Screen) CaptureDamage() DamageCapture { | ||
| @@ -625,6 +633,14 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |||||
| 625 | 633 | ||||
| 626 | func (s *Screen) record(d renderer.Damage) { | 634 | func (s *Screen) record(d renderer.Damage) { | ||
| 627 | s.damageGeneration++ | 635 | s.damageGeneration++ | ||
| 636 | + | if s.damageSaturated { | |||
| 637 | + | return | |||
| 638 | + | } | |||
| 639 | + | if len(s.damage) >= maxPendingDamage { | |||
| 640 | + | s.damage = []renderer.Damage{renderer.FullRedraw()} | |||
| 641 | + | s.damageSaturated = true | |||
| 642 | + | return | |||
| 643 | + | } | |||
| 628 | // Replace FullRedraw with the first concrete damage item. | 644 | // Replace FullRedraw with the first concrete damage item. | ||
| 629 | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | 645 | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | ||
| 630 | s.damage[0] = d | 646 | s.damage[0] = d | ||
M pkg/vt/screen_test.go +1 -1
| @@ -2060,7 +2060,7 @@ func TestDamageSaturationBoundsPendingRecords(t *testing.T) { | |||
| 2060 | 2060 | s.ClearDamage() | |
| 2061 | 2061 | ||
| 2062 | 2062 | for i := range 1_100 { | |
| 2063 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: i % 2, Y: (i / 2) % 2, Width: 1, Height: 1, Count: 1}) | |
| 2063 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: i % 2, Width: 1, Height: 1, Count: 1}) | |
| 2064 | 2064 | } | |
| 2065 | 2065 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage()) | |
| 2066 | 2066 | ||
| @@ -2060,7 +2060,7 @@ func TestDamageSaturationBoundsPendingRecords(t *testing.T) { | |||||
| 2060 | s.ClearDamage() | 2060 | s.ClearDamage() | ||
| 2061 | 2061 | ||||
| 2062 | for i := range 1_100 { | 2062 | for i := range 1_100 { | ||
| 2063 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: i % 2, Y: (i / 2) % 2, Width: 1, Height: 1, Count: 1}) | 2063 | + | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: i % 2, Width: 1, Height: 1, Count: 1}) |
| 2064 | } | 2064 | } | ||
| 2065 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage()) | 2065 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage()) | ||
| 2066 | 2066 | ||||