perf(screen): skip unused eviction copies and reuse Kitty escape buffers
emitLineEvicted returns early without history or OnLineEvicted and otherwise builds the row in a Screen-owned scratch buffer; OnLineEvicted still receives a fresh copy. Fragmented Kitty APCs reuse a bounded escapeBuf/combine buffer and dispatch borrowed APC bytes, since the session copies what it retains.
- commit
- 178c6c50d86975d8b656036997dcd3c4dcf2b377
- author
- brice · · 2026-10-02 09:33 UTC
- parent
- 2234949
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +130 -19
M screen.go +65 -18
| @@ -18,6 +18,11 @@ const maxEscapeBufferLen = 128 * 1024 | |||
| 18 | 18 | // through the text path or truncated at the OSC clipboard bound. | |
| 19 | 19 | const maxKittyEscapeBufferLen = int(kittygraphics.DefaultMaxAPCBytes) + 5 | |
| 20 | 20 | ||
| 21 | + | // maxRetainedEscapeBufferCap bounds the capacity of the escape scratch buffers | |
| 22 | + | // kept between writes so that a single huge APC does not pin its memory, while | |
| 23 | + | // ordinary fragmented uploads still reuse their buffer. | |
| 24 | + | const maxRetainedEscapeBufferCap = 1 << 20 | |
| 25 | + | ||
| 21 | 26 | const ( | |
| 22 | 27 | // ColorSchemeReportDark is the DEC 2031 dark-scheme report. | |
| 23 | 28 | ColorSchemeReportDark = "\x1b[?997;1n" | |
| @@ -72,6 +77,8 @@ type Screen struct { | |||
| 72 | 77 | damageSaturated bool | |
| 73 | 78 | damageFullRedrawSticky bool | |
| 74 | 79 | escapeBuf []byte | |
| 80 | + | combineBuf []byte | |
| 81 | + | evictScratch []renderer.Cell | |
| 75 | 82 | kittyDiscard bool | |
| 76 | 83 | kittyDiscardEscaped bool | |
| 77 | 84 | kittyPendingDisplay *kittygraphics.Controls | |
| @@ -168,7 +175,9 @@ func (s *Screen) LineBounds() []LineBound { | |||
| 168 | 175 | } | |
| 169 | 176 | ||
| 170 | 177 | func (s *Screen) Write(data []byte) { | |
| 171 | - | if len(s.escapeBuf) > 0 && isKittyEscapeContinuation(s.escapeBuf, data) { | |
| 178 | + | if len(s.escapeBuf) == 0 { | |
| 179 | + | s.trimEscapeBuffers() | |
| 180 | + | } else if isKittyEscapeContinuation(s.escapeBuf, data) { | |
| 172 | 181 | prefix := s.escapeBuf | |
| 173 | 182 | s.escapeBuf = nil | |
| 174 | 183 | data = s.continueKittyEscape(prefix, data) | |
| @@ -177,11 +186,20 @@ func (s *Screen) Write(data []byte) { | |||
| 177 | 186 | } | |
| 178 | 187 | } | |
| 179 | 188 | if len(s.escapeBuf) > 0 { | |
| 180 | - | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | |
| 189 | + | // Reuse a bounded scratch buffer: data is consumed before Write returns | |
| 190 | + | // and nothing retains slices of it (the partial-escape path copies). | |
| 191 | + | need := len(s.escapeBuf) + len(data) | |
| 192 | + | combined := s.combineBuf[:0] | |
| 193 | + | if cap(combined) < need { | |
| 194 | + | combined = make([]byte, 0, need) | |
| 195 | + | } | |
| 181 | 196 | combined = append(combined, s.escapeBuf...) | |
| 182 | 197 | combined = append(combined, data...) | |
| 198 | + | if cap(combined) <= maxRetainedEscapeBufferCap { | |
| 199 | + | s.combineBuf = combined | |
| 200 | + | } | |
| 183 | 201 | data = combined | |
| 184 | - | s.escapeBuf = nil | |
| 202 | + | s.escapeBuf = s.escapeBuf[:0] | |
| 185 | 203 | } | |
| 186 | 204 | if s.kittyDiscard { | |
| 187 | 205 | consumed := s.consumeKittyDiscard(data) | |
| @@ -249,42 +267,55 @@ func isKittyEscapeContinuation(prefix, data []byte) bool { | |||
| 249 | 267 | return true | |
| 250 | 268 | } | |
| 251 | 269 | ||
| 270 | + | // trimEscapeBuffers drops oversized idle escape scratch buffers. | |
| 271 | + | func (s *Screen) trimEscapeBuffers() { | |
| 272 | + | if cap(s.escapeBuf) > maxRetainedEscapeBufferCap { | |
| 273 | + | s.escapeBuf = nil | |
| 274 | + | } | |
| 275 | + | if cap(s.combineBuf) > maxRetainedEscapeBufferCap { | |
| 276 | + | s.combineBuf = nil | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // recycleEscapeBuf keeps buf's capacity for the next fragmented escape when no | |
| 281 | + | // other buffer has been installed meanwhile. Callers must be done with buf. | |
| 282 | + | func (s *Screen) recycleEscapeBuf(buf []byte) { | |
| 283 | + | if s.escapeBuf == nil && cap(buf) <= maxRetainedEscapeBufferCap { | |
| 284 | + | s.escapeBuf = buf[:0] | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // continueKittyEscape completes or extends the pending Kitty APC in prefix, | |
| 289 | + | // which is the Screen's former escapeBuf (s.escapeBuf is nil on entry). The | |
| 290 | + | // protocol adapter copies whatever it retains from a dispatched APC, so prefix | |
| 291 | + | // is recycled afterwards instead of being reallocated for every APC. | |
| 252 | 292 | func (s *Screen) continueKittyEscape(prefix, data []byte) []byte { | |
| 253 | 293 | if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' { | |
| 254 | 294 | if len(prefix)+1 <= maxKittyEscapeBufferLen { | |
| 255 | 295 | apc := append(prefix, data[:1]...) | |
| 256 | 296 | s.dispatchKittyGraphics(apc) | |
| 297 | + | s.recycleEscapeBuf(apc) | |
| 257 | 298 | } else { | |
| 258 | 299 | s.abortKittyPendingDisplay() | |
| 300 | + | s.recycleEscapeBuf(prefix) | |
| 259 | 301 | } | |
| 260 | 302 | return data[1:] | |
| 261 | 303 | } | |
| 262 | 304 | for i := 0; i < len(data); i++ { | |
| 263 | 305 | if data[i] == 0x9c { | |
| 264 | 306 | end := i + 1 | |
| 265 | - | if len(prefix)+end <= maxKittyEscapeBufferLen { | |
| 266 | - | apc := append(prefix, data[:end]...) | |
| 267 | - | s.dispatchKittyGraphics(apc) | |
| 268 | - | } else { | |
| 269 | - | s.abortKittyPendingDisplay() | |
| 270 | - | } | |
| 307 | + | s.finishKittyEscape(prefix, data[:end]) | |
| 271 | 308 | return data[end:] | |
| 272 | 309 | } | |
| 273 | 310 | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | |
| 274 | 311 | end := i + 2 | |
| 275 | - | if len(prefix)+end <= maxKittyEscapeBufferLen { | |
| 276 | - | apc := append(prefix, data[:end]...) | |
| 277 | - | s.dispatchKittyGraphics(apc) | |
| 278 | - | } else { | |
| 279 | - | s.abortKittyPendingDisplay() | |
| 280 | - | } | |
| 312 | + | s.finishKittyEscape(prefix, data[:end]) | |
| 281 | 313 | return data[end:] | |
| 282 | 314 | } | |
| 283 | 315 | } | |
| 284 | 316 | remaining := maxKittyEscapeBufferLen - len(prefix) | |
| 285 | 317 | if len(data) <= remaining { | |
| 286 | - | returnData := append(prefix, data...) | |
| 287 | - | s.escapeBuf = returnData | |
| 318 | + | s.escapeBuf = append(prefix, data...) | |
| 288 | 319 | return nil | |
| 289 | 320 | } | |
| 290 | 321 | s.abortKittyPendingDisplay() | |
| @@ -294,10 +325,24 @@ func (s *Screen) continueKittyEscape(prefix, data []byte) []byte { | |||
| 294 | 325 | escaped = data[remaining-1] == 0x1b | |
| 295 | 326 | } | |
| 296 | 327 | s.kittyDiscardEscaped = escaped | |
| 328 | + | s.recycleEscapeBuf(prefix) | |
| 297 | 329 | tail := data[remaining:] | |
| 298 | 330 | return tail[s.consumeKittyDiscard(tail):] | |
| 299 | 331 | } | |
| 300 | 332 | ||
| 333 | + | // finishKittyEscape dispatches prefix+terminated, or aborts when the framed | |
| 334 | + | // APC would exceed the Kitty escape bound, then recycles prefix. | |
| 335 | + | func (s *Screen) finishKittyEscape(prefix, terminated []byte) { | |
| 336 | + | if len(prefix)+len(terminated) > maxKittyEscapeBufferLen { | |
| 337 | + | s.abortKittyPendingDisplay() | |
| 338 | + | s.recycleEscapeBuf(prefix) | |
| 339 | + | return | |
| 340 | + | } | |
| 341 | + | apc := append(prefix, terminated...) | |
| 342 | + | s.dispatchKittyGraphics(apc) | |
| 343 | + | s.recycleEscapeBuf(apc) | |
| 344 | + | } | |
| 345 | + | ||
| 301 | 346 | func (s *Screen) consumeKittyDiscard(data []byte) int { | |
| 302 | 347 | for i, b := range data { | |
| 303 | 348 | if s.kittyDiscardEscaped { | |
| @@ -418,7 +463,9 @@ func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool) | |||
| 418 | 463 | } | |
| 419 | 464 | ||
| 420 | 465 | func (s *Screen) dispatchKittyGraphics(apc []byte) { | |
| 421 | - | command, err := kittygraphics.ParseAPC(apc) | |
| 466 | + | // The session copies any payload it retains, so the APC bytes may be | |
| 467 | + | // borrowed for the duration of this call only. | |
| 468 | + | command, err := kittygraphics.ParseAPCBorrowed(apc) | |
| 422 | 469 | if err != nil { | |
| 423 | 470 | s.abortKittyPendingDisplay() | |
| 424 | 471 | return | |
| @@ -18,6 +18,11 @@ const maxEscapeBufferLen = 128 * 1024 | |||||
| 18 | // through the text path or truncated at the OSC clipboard bound. | 18 | // through the text path or truncated at the OSC clipboard bound. | ||
| 19 | const maxKittyEscapeBufferLen = int(kittygraphics.DefaultMaxAPCBytes) + 5 | 19 | const maxKittyEscapeBufferLen = int(kittygraphics.DefaultMaxAPCBytes) + 5 | ||
| 20 | 20 | ||||
| 21 | + | // maxRetainedEscapeBufferCap bounds the capacity of the escape scratch buffers | |||
| 22 | + | // kept between writes so that a single huge APC does not pin its memory, while | |||
| 23 | + | // ordinary fragmented uploads still reuse their buffer. | |||
| 24 | + | const maxRetainedEscapeBufferCap = 1 << 20 | |||
| 25 | + | ||||
| 21 | const ( | 26 | const ( | ||
| 22 | // ColorSchemeReportDark is the DEC 2031 dark-scheme report. | 27 | // ColorSchemeReportDark is the DEC 2031 dark-scheme report. | ||
| 23 | ColorSchemeReportDark = "\x1b[?997;1n" | 28 | ColorSchemeReportDark = "\x1b[?997;1n" | ||
| @@ -72,6 +77,8 @@ type Screen struct { | |||||
| 72 | damageSaturated bool | 77 | damageSaturated bool | ||
| 73 | damageFullRedrawSticky bool | 78 | damageFullRedrawSticky bool | ||
| 74 | escapeBuf []byte | 79 | escapeBuf []byte | ||
| 80 | + | combineBuf []byte | |||
| 81 | + | evictScratch []renderer.Cell | |||
| 75 | kittyDiscard bool | 82 | kittyDiscard bool | ||
| 76 | kittyDiscardEscaped bool | 83 | kittyDiscardEscaped bool | ||
| 77 | kittyPendingDisplay *kittygraphics.Controls | 84 | kittyPendingDisplay *kittygraphics.Controls | ||
| @@ -168,7 +175,9 @@ func (s *Screen) LineBounds() []LineBound { | |||||
| 168 | } | 175 | } | ||
| 169 | 176 | ||||
| 170 | func (s *Screen) Write(data []byte) { | 177 | func (s *Screen) Write(data []byte) { | ||
| 171 | - | if len(s.escapeBuf) > 0 && isKittyEscapeContinuation(s.escapeBuf, data) { | 178 | + | if len(s.escapeBuf) == 0 { |
| 179 | + | s.trimEscapeBuffers() | |||
| 180 | + | } else if isKittyEscapeContinuation(s.escapeBuf, data) { | |||
| 172 | prefix := s.escapeBuf | 181 | prefix := s.escapeBuf | ||
| 173 | s.escapeBuf = nil | 182 | s.escapeBuf = nil | ||
| 174 | data = s.continueKittyEscape(prefix, data) | 183 | data = s.continueKittyEscape(prefix, data) | ||
| @@ -177,11 +186,20 @@ func (s *Screen) Write(data []byte) { | |||||
| 177 | } | 186 | } | ||
| 178 | } | 187 | } | ||
| 179 | if len(s.escapeBuf) > 0 { | 188 | if len(s.escapeBuf) > 0 { | ||
| 180 | - | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | 189 | + | // Reuse a bounded scratch buffer: data is consumed before Write returns |
| 190 | + | // and nothing retains slices of it (the partial-escape path copies). | |||
| 191 | + | need := len(s.escapeBuf) + len(data) | |||
| 192 | + | combined := s.combineBuf[:0] | |||
| 193 | + | if cap(combined) < need { | |||
| 194 | + | combined = make([]byte, 0, need) | |||
| 195 | + | } | |||
| 181 | combined = append(combined, s.escapeBuf...) | 196 | combined = append(combined, s.escapeBuf...) | ||
| 182 | combined = append(combined, data...) | 197 | combined = append(combined, data...) | ||
| 198 | + | if cap(combined) <= maxRetainedEscapeBufferCap { | |||
| 199 | + | s.combineBuf = combined | |||
| 200 | + | } | |||
| 183 | data = combined | 201 | data = combined | ||
| 184 | - | s.escapeBuf = nil | 202 | + | s.escapeBuf = s.escapeBuf[:0] |
| 185 | } | 203 | } | ||
| 186 | if s.kittyDiscard { | 204 | if s.kittyDiscard { | ||
| 187 | consumed := s.consumeKittyDiscard(data) | 205 | consumed := s.consumeKittyDiscard(data) | ||
| @@ -249,42 +267,55 @@ func isKittyEscapeContinuation(prefix, data []byte) bool { | |||||
| 249 | return true | 267 | return true | ||
| 250 | } | 268 | } | ||
| 251 | 269 | ||||
| 270 | + | // trimEscapeBuffers drops oversized idle escape scratch buffers. | |||
| 271 | + | func (s *Screen) trimEscapeBuffers() { | |||
| 272 | + | if cap(s.escapeBuf) > maxRetainedEscapeBufferCap { | |||
| 273 | + | s.escapeBuf = nil | |||
| 274 | + | } | |||
| 275 | + | if cap(s.combineBuf) > maxRetainedEscapeBufferCap { | |||
| 276 | + | s.combineBuf = nil | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // recycleEscapeBuf keeps buf's capacity for the next fragmented escape when no | |||
| 281 | + | // other buffer has been installed meanwhile. Callers must be done with buf. | |||
| 282 | + | func (s *Screen) recycleEscapeBuf(buf []byte) { | |||
| 283 | + | if s.escapeBuf == nil && cap(buf) <= maxRetainedEscapeBufferCap { | |||
| 284 | + | s.escapeBuf = buf[:0] | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | // continueKittyEscape completes or extends the pending Kitty APC in prefix, | |||
| 289 | + | // which is the Screen's former escapeBuf (s.escapeBuf is nil on entry). The | |||
| 290 | + | // protocol adapter copies whatever it retains from a dispatched APC, so prefix | |||
| 291 | + | // is recycled afterwards instead of being reallocated for every APC. | |||
| 252 | func (s *Screen) continueKittyEscape(prefix, data []byte) []byte { | 292 | func (s *Screen) continueKittyEscape(prefix, data []byte) []byte { | ||
| 253 | if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' { | 293 | if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' { | ||
| 254 | if len(prefix)+1 <= maxKittyEscapeBufferLen { | 294 | if len(prefix)+1 <= maxKittyEscapeBufferLen { | ||
| 255 | apc := append(prefix, data[:1]...) | 295 | apc := append(prefix, data[:1]...) | ||
| 256 | s.dispatchKittyGraphics(apc) | 296 | s.dispatchKittyGraphics(apc) | ||
| 297 | + | s.recycleEscapeBuf(apc) | |||
| 257 | } else { | 298 | } else { | ||
| 258 | s.abortKittyPendingDisplay() | 299 | s.abortKittyPendingDisplay() | ||
| 300 | + | s.recycleEscapeBuf(prefix) | |||
| 259 | } | 301 | } | ||
| 260 | return data[1:] | 302 | return data[1:] | ||
| 261 | } | 303 | } | ||
| 262 | for i := 0; i < len(data); i++ { | 304 | for i := 0; i < len(data); i++ { | ||
| 263 | if data[i] == 0x9c { | 305 | if data[i] == 0x9c { | ||
| 264 | end := i + 1 | 306 | end := i + 1 | ||
| 265 | - | if len(prefix)+end <= maxKittyEscapeBufferLen { | 307 | + | s.finishKittyEscape(prefix, data[:end]) |
| 266 | - | apc := append(prefix, data[:end]...) | |||
| 267 | - | s.dispatchKittyGraphics(apc) | |||
| 268 | - | } else { | |||
| 269 | - | s.abortKittyPendingDisplay() | |||
| 270 | - | } | |||
| 271 | return data[end:] | 308 | return data[end:] | ||
| 272 | } | 309 | } | ||
| 273 | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | 310 | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | ||
| 274 | end := i + 2 | 311 | end := i + 2 | ||
| 275 | - | if len(prefix)+end <= maxKittyEscapeBufferLen { | 312 | + | s.finishKittyEscape(prefix, data[:end]) |
| 276 | - | apc := append(prefix, data[:end]...) | |||
| 277 | - | s.dispatchKittyGraphics(apc) | |||
| 278 | - | } else { | |||
| 279 | - | s.abortKittyPendingDisplay() | |||
| 280 | - | } | |||
| 281 | return data[end:] | 313 | return data[end:] | ||
| 282 | } | 314 | } | ||
| 283 | } | 315 | } | ||
| 284 | remaining := maxKittyEscapeBufferLen - len(prefix) | 316 | remaining := maxKittyEscapeBufferLen - len(prefix) | ||
| 285 | if len(data) <= remaining { | 317 | if len(data) <= remaining { | ||
| 286 | - | returnData := append(prefix, data...) | 318 | + | s.escapeBuf = append(prefix, data...) |
| 287 | - | s.escapeBuf = returnData | |||
| 288 | return nil | 319 | return nil | ||
| 289 | } | 320 | } | ||
| 290 | s.abortKittyPendingDisplay() | 321 | s.abortKittyPendingDisplay() | ||
| @@ -294,10 +325,24 @@ func (s *Screen) continueKittyEscape(prefix, data []byte) []byte { | |||||
| 294 | escaped = data[remaining-1] == 0x1b | 325 | escaped = data[remaining-1] == 0x1b | ||
| 295 | } | 326 | } | ||
| 296 | s.kittyDiscardEscaped = escaped | 327 | s.kittyDiscardEscaped = escaped | ||
| 328 | + | s.recycleEscapeBuf(prefix) | |||
| 297 | tail := data[remaining:] | 329 | tail := data[remaining:] | ||
| 298 | return tail[s.consumeKittyDiscard(tail):] | 330 | return tail[s.consumeKittyDiscard(tail):] | ||
| 299 | } | 331 | } | ||
| 300 | 332 | ||||
| 333 | + | // finishKittyEscape dispatches prefix+terminated, or aborts when the framed | |||
| 334 | + | // APC would exceed the Kitty escape bound, then recycles prefix. | |||
| 335 | + | func (s *Screen) finishKittyEscape(prefix, terminated []byte) { | |||
| 336 | + | if len(prefix)+len(terminated) > maxKittyEscapeBufferLen { | |||
| 337 | + | s.abortKittyPendingDisplay() | |||
| 338 | + | s.recycleEscapeBuf(prefix) | |||
| 339 | + | return | |||
| 340 | + | } | |||
| 341 | + | apc := append(prefix, terminated...) | |||
| 342 | + | s.dispatchKittyGraphics(apc) | |||
| 343 | + | s.recycleEscapeBuf(apc) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 301 | func (s *Screen) consumeKittyDiscard(data []byte) int { | 346 | func (s *Screen) consumeKittyDiscard(data []byte) int { | ||
| 302 | for i, b := range data { | 347 | for i, b := range data { | ||
| 303 | if s.kittyDiscardEscaped { | 348 | if s.kittyDiscardEscaped { | ||
| @@ -418,7 +463,9 @@ func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool) | |||||
| 418 | } | 463 | } | ||
| 419 | 464 | ||||
| 420 | func (s *Screen) dispatchKittyGraphics(apc []byte) { | 465 | func (s *Screen) dispatchKittyGraphics(apc []byte) { | ||
| 421 | - | command, err := kittygraphics.ParseAPC(apc) | 466 | + | // The session copies any payload it retains, so the APC bytes may be |
| 467 | + | // borrowed for the duration of this call only. | |||
| 468 | + | command, err := kittygraphics.ParseAPCBorrowed(apc) | |||
| 422 | if err != nil { | 469 | if err != nil { | ||
| 423 | s.abortKittyPendingDisplay() | 470 | s.abortKittyPendingDisplay() | ||
| 424 | return | 471 | return | ||
A screen_evict_scratch_test.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 1 | + | package vt | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | renderer "github.com/bnema/vev-vt/core" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // OnLineEvicted callers may retain the slice: successive evictions must not | |
| 10 | + | // share the Screen's internal scratch row. | |
| 11 | + | func TestOnLineEvictedRowsAreIndependentCopies(t *testing.T) { | |
| 12 | + | s := NewScreen(4, 2) | |
| 13 | + | var rows [][]renderer.Cell | |
| 14 | + | s.OnLineEvicted = func(row []renderer.Cell) { rows = append(rows, row) } | |
| 15 | + | s.Write([]byte("aaaa\r\nbbbb\r\ncccc\r\ndddd")) | |
| 16 | + | if len(rows) != 2 { | |
| 17 | + | t.Fatalf("evicted %d rows, want 2", len(rows)) | |
| 18 | + | } | |
| 19 | + | if rows[0][0].Rune != 'a' || rows[1][0].Rune != 'b' { | |
| 20 | + | t.Fatalf("evicted rows = %q, %q", rows[0][0].Rune, rows[1][0].Rune) | |
| 21 | + | } | |
| 22 | + | rows[0][0].Rune = 'z' | |
| 23 | + | if rows[1][0].Rune != 'b' { | |
| 24 | + | t.Fatal("evicted rows alias each other") | |
| 25 | + | } | |
| 26 | + | } | |
| @@ -0,0 +1,26 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // OnLineEvicted callers may retain the slice: successive evictions must not | |||
| 10 | + | // share the Screen's internal scratch row. | |||
| 11 | + | func TestOnLineEvictedRowsAreIndependentCopies(t *testing.T) { | |||
| 12 | + | s := NewScreen(4, 2) | |||
| 13 | + | var rows [][]renderer.Cell | |||
| 14 | + | s.OnLineEvicted = func(row []renderer.Cell) { rows = append(rows, row) } | |||
| 15 | + | s.Write([]byte("aaaa\r\nbbbb\r\ncccc\r\ndddd")) | |||
| 16 | + | if len(rows) != 2 { | |||
| 17 | + | t.Fatalf("evicted %d rows, want 2", len(rows)) | |||
| 18 | + | } | |||
| 19 | + | if rows[0][0].Rune != 'a' || rows[1][0].Rune != 'b' { | |||
| 20 | + | t.Fatalf("evicted rows = %q, %q", rows[0][0].Rune, rows[1][0].Rune) | |||
| 21 | + | } | |||
| 22 | + | rows[0][0].Rune = 'z' | |||
| 23 | + | if rows[1][0].Rune != 'b' { | |||
| 24 | + | t.Fatal("evicted rows alias each other") | |||
| 25 | + | } | |||
| 26 | + | } | |||
M screen_graphics_test.go +25 -0
| @@ -331,3 +331,28 @@ func TestScreenKittyPlacementScrollSkipsBottomEdgeOverflow(t *testing.T) { | |||
| 331 | 331 | placement = screen.GraphicsSnapshot().Placements()[0] | |
| 332 | 332 | require.Equal(t, nearLimit, placement.Destination()) | |
| 333 | 333 | } | |
| 334 | + | ||
| 335 | + | // Fragmented APCs reuse the Screen's escape buffer, so retained graphics state | |
| 336 | + | // (assets and pending chunked uploads) must not alias it. | |
| 337 | + | func TestScreenKittyFragmentedAPCsDoNotAliasEscapeBuffer(t *testing.T) { | |
| 338 | + | screen := NewScreen(16, 3) | |
| 339 | + | writeByteWise := func(data []byte) { | |
| 340 | + | for i := range data { | |
| 341 | + | screen.Write(data[i : i+1]) | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | enc := func(b ...byte) string { return base64.StdEncoding.EncodeToString(b) } | |
| 345 | + | writeByteWise([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;" + enc(1, 2, 3, 4) + "\x1b\\")) | |
| 346 | + | // Chunked upload interleaved with other escape sequences reusing the buffer. | |
| 347 | + | writeByteWise([]byte("\x1b_Ga=t,i=2,f=32,s=1,v=1,m=1;" + enc(9, 9, 9, 9)[:4] + "\x1b\\")) | |
| 348 | + | writeByteWise([]byte("\x1b[1mtext\x1b[0m")) | |
| 349 | + | writeByteWise([]byte("\x1b_Gm=0;" + enc(9, 9, 9, 9)[4:] + "\x1b\\")) | |
| 350 | + | ||
| 351 | + | snapshot := screen.GraphicsSnapshot() | |
| 352 | + | require.NotNil(t, snapshot) | |
| 353 | + | var got [][]byte | |
| 354 | + | for _, asset := range snapshot.Assets() { | |
| 355 | + | got = append(got, asset.Bytes()) | |
| 356 | + | } | |
| 357 | + | require.ElementsMatch(t, [][]byte{{1, 2, 3, 4}, {9, 9, 9, 9}}, got) | |
| 358 | + | } | |
| @@ -331,3 +331,28 @@ func TestScreenKittyPlacementScrollSkipsBottomEdgeOverflow(t *testing.T) { | |||||
| 331 | placement = screen.GraphicsSnapshot().Placements()[0] | 331 | placement = screen.GraphicsSnapshot().Placements()[0] | ||
| 332 | require.Equal(t, nearLimit, placement.Destination()) | 332 | require.Equal(t, nearLimit, placement.Destination()) | ||
| 333 | } | 333 | } | ||
| 334 | + | ||||
| 335 | + | // Fragmented APCs reuse the Screen's escape buffer, so retained graphics state | |||
| 336 | + | // (assets and pending chunked uploads) must not alias it. | |||
| 337 | + | func TestScreenKittyFragmentedAPCsDoNotAliasEscapeBuffer(t *testing.T) { | |||
| 338 | + | screen := NewScreen(16, 3) | |||
| 339 | + | writeByteWise := func(data []byte) { | |||
| 340 | + | for i := range data { | |||
| 341 | + | screen.Write(data[i : i+1]) | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | enc := func(b ...byte) string { return base64.StdEncoding.EncodeToString(b) } | |||
| 345 | + | writeByteWise([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;" + enc(1, 2, 3, 4) + "\x1b\\")) | |||
| 346 | + | // Chunked upload interleaved with other escape sequences reusing the buffer. | |||
| 347 | + | writeByteWise([]byte("\x1b_Ga=t,i=2,f=32,s=1,v=1,m=1;" + enc(9, 9, 9, 9)[:4] + "\x1b\\")) | |||
| 348 | + | writeByteWise([]byte("\x1b[1mtext\x1b[0m")) | |||
| 349 | + | writeByteWise([]byte("\x1b_Gm=0;" + enc(9, 9, 9, 9)[4:] + "\x1b\\")) | |||
| 350 | + | ||||
| 351 | + | snapshot := screen.GraphicsSnapshot() | |||
| 352 | + | require.NotNil(t, snapshot) | |||
| 353 | + | var got [][]byte | |||
| 354 | + | for _, asset := range snapshot.Assets() { | |||
| 355 | + | got = append(got, asset.Bytes()) | |||
| 356 | + | } | |||
| 357 | + | require.ElementsMatch(t, [][]byte{{1, 2, 3, 4}, {9, 9, 9, 9}}, got) | |||
| 358 | + | } | |||
M screen_scroll.go +14 -1
| @@ -103,10 +103,23 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||
| 103 | 103 | if s.alternate != nil || top != 0 { | |
| 104 | 104 | return | |
| 105 | 105 | } | |
| 106 | + | // Without a history or an observer nothing consumes the evicted rows, so | |
| 107 | + | // skip materialising them. | |
| 108 | + | if s.history == nil && s.OnLineEvicted == nil { | |
| 109 | + | return | |
| 110 | + | } | |
| 106 | 111 | // Read boundaries and IDs before the caller rotates the frame: a soft link | |
| 107 | 112 | // belongs to the row it follows, and rotation reassigns row indices. | |
| 108 | 113 | for y := top; y < top+n; y++ { | |
| 109 | - | s.recordEvicted(s.frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y]) | |
| 114 | + | // The scratch row is only valid for this call: History copies appended | |
| 115 | + | // rows and OnLineEvicted receives its own copy (see recordEvicted). | |
| 116 | + | row := s.evictScratch[:0] | |
| 117 | + | for x := range s.frame.Width { | |
| 118 | + | row = append(row, s.frame.Cell(x, y)) | |
| 119 | + | } | |
| 120 | + | s.evictScratch = row | |
| 121 | + | s.recordEvicted(row, s.buffer.bound(y), s.buffer.rowIDs[y]) | |
| 122 | + | clear(s.evictScratch) | |
| 110 | 123 | } | |
| 111 | 124 | } | |
| 112 | 125 | ||
| @@ -103,10 +103,23 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||||
| 103 | if s.alternate != nil || top != 0 { | 103 | if s.alternate != nil || top != 0 { | ||
| 104 | return | 104 | return | ||
| 105 | } | 105 | } | ||
| 106 | + | // Without a history or an observer nothing consumes the evicted rows, so | |||
| 107 | + | // skip materialising them. | |||
| 108 | + | if s.history == nil && s.OnLineEvicted == nil { | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 106 | // Read boundaries and IDs before the caller rotates the frame: a soft link | 111 | // Read boundaries and IDs before the caller rotates the frame: a soft link | ||
| 107 | // belongs to the row it follows, and rotation reassigns row indices. | 112 | // belongs to the row it follows, and rotation reassigns row indices. | ||
| 108 | for y := top; y < top+n; y++ { | 113 | for y := top; y < top+n; y++ { | ||
| 109 | - | s.recordEvicted(s.frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y]) | 114 | + | // The scratch row is only valid for this call: History copies appended |
| 115 | + | // rows and OnLineEvicted receives its own copy (see recordEvicted). | |||
| 116 | + | row := s.evictScratch[:0] | |||
| 117 | + | for x := range s.frame.Width { | |||
| 118 | + | row = append(row, s.frame.Cell(x, y)) | |||
| 119 | + | } | |||
| 120 | + | s.evictScratch = row | |||
| 121 | + | s.recordEvicted(row, s.buffer.bound(y), s.buffer.rowIDs[y]) | |||
| 122 | + | clear(s.evictScratch) | |||
| 110 | } | 123 | } | ||
| 111 | } | 124 | } | ||
| 112 | 125 | ||||