Skip to content

Commit e468331

Browse files
Kitty support did not have any ways of limiting how much memory we use. (#433)
Based on the spec, this now: * A default maximum of 320 megs, up to 4 gigs of ram. * If we hit the limit, the first images to go will be the not-visible ones, follow by the oldest ones, and then anything is fair game until we meet the quota. * RIS reset drops all kitty images, and entering the alt buffer clears any lingering alt images even when you switch back using ?47l (which preserves the alt buffer state)
1 parent e81c583 commit e468331

4 files changed

Lines changed: 199 additions & 16 deletions

File tree

Sources/SwiftTerm/KittyGraphics.swift

Lines changed: 123 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ enum KittyGraphicsPayload {
121121

122122
struct KittyGraphicsImage {
123123
let payload: KittyGraphicsPayload
124+
let byteSize: Int
125+
var lastAccessTick: UInt64
124126
}
125127

126128
struct KittyGraphicsPending {
@@ -135,11 +137,14 @@ final class KittyGraphicsState {
135137
var nextPlacementId: UInt32 = 1
136138
var pending: KittyGraphicsPending?
137139
var placementsByKey: [KittyPlacementKey: KittyPlacementRecord] = [:]
140+
var totalImageBytes: Int = 0
141+
var nextImageAccessTick: UInt64 = 1
138142
}
139143

140144
extension Terminal {
141145
private static let kittyMaxImageBytes = 400 * 1024 * 1024
142146
private static let kittyMaxImageDimension = 10000
147+
private static let kittyMaxImageCacheBytes = 4 * 1024 * 1024 * 1024
143148

144149
func handleKittyGraphics(_ data: ArraySlice<UInt8>) {
145150
guard let (control, payload) = parseKittyGraphicsControl(data) else {
@@ -333,10 +338,7 @@ extension Terminal {
333338
}
334339

335340
if let id = resolved.imageId {
336-
kittyGraphicsState.imagesById[id] = KittyGraphicsImage(payload: payload)
337-
if let number = resolved.imageNumber {
338-
kittyGraphicsState.imageNumbers[number] = id
339-
}
341+
storeKittyImage(payload: payload, imageId: id, imageNumber: resolved.imageNumber)
340342
}
341343

342344
var displayed = true
@@ -440,10 +442,10 @@ extension Terminal {
440442
}
441443

442444
private func resolveKittyImageForDisplay(control: KittyGraphicsControl) -> (image: KittyGraphicsImage?, imageId: UInt32?, imageNumber: UInt32?, shouldReply: Bool) {
443-
if let number = control.imageNumber, let imageId = kittyGraphicsState.imageNumbers[number], let image = kittyGraphicsState.imagesById[imageId] {
445+
if let number = control.imageNumber, let imageId = kittyGraphicsState.imageNumbers[number], let image = updateKittyImageAccess(imageId: imageId) {
444446
return (image, imageId, number, control.suppressResponses == 0)
445447
}
446-
if let imageId = control.imageId, let image = kittyGraphicsState.imagesById[imageId] {
448+
if let imageId = control.imageId, let image = updateKittyImageAccess(imageId: imageId) {
447449
return (image, imageId, nil, control.suppressResponses == 0)
448450
}
449451
return (nil, nil, nil, control.suppressResponses == 0)
@@ -1468,9 +1470,23 @@ extension Terminal {
14681470
kittyGraphicsState.imagesById.removeAll()
14691471
kittyGraphicsState.imageNumbers.removeAll()
14701472
kittyGraphicsState.placementsByKey.removeAll()
1473+
kittyGraphicsState.totalImageBytes = 0
1474+
kittyGraphicsState.nextImageAccessTick = 1
14711475
updateRange(startLine: buffer.scrollTop, endLine: buffer.scrollBottom)
14721476
}
14731477

1478+
func clearKittyImages(in buffer: Buffer, isAlternateBuffer: Bool) {
1479+
let removedKeys = removeKittyPlacements(in: buffer, lineRange: 0..<buffer.lines.count) { _ in true }
1480+
let recordKeys = removePlacementRecords { record in
1481+
record.isAlternateBuffer == isAlternateBuffer
1482+
}
1483+
let extraKeys = recordKeys.subtracting(removedKeys)
1484+
if !extraKeys.isEmpty {
1485+
_ = removeKittyPlacementsByKey(extraKeys)
1486+
}
1487+
cleanupUnusedKittyImages()
1488+
}
1489+
14741490
private func deletePlacementsVisibleOnScreen() {
14751491
let start = buffer.yBase
14761492
let end = min(buffer.yBase + rows, buffer.lines.count)
@@ -1704,20 +1720,113 @@ extension Terminal {
17041720
}
17051721

17061722
private func cleanupUnusedKittyImages() {
1723+
let used = collectUsedKittyImageIds()
1724+
let unusedIds = kittyGraphicsState.imagesById.keys.filter { !used.contains($0) }
1725+
for id in unusedIds {
1726+
removeKittyImage(imageId: id)
1727+
}
1728+
}
1729+
1730+
private func storeKittyImage(payload: KittyGraphicsPayload, imageId: UInt32, imageNumber: UInt32?) {
1731+
let byteSize = kittyPayloadByteSize(payload)
1732+
let lastAccessTick = nextKittyImageAccessTick()
1733+
if let existing = kittyGraphicsState.imagesById[imageId] {
1734+
kittyGraphicsState.totalImageBytes = max(0, kittyGraphicsState.totalImageBytes - existing.byteSize)
1735+
}
1736+
kittyGraphicsState.imagesById[imageId] = KittyGraphicsImage(payload: payload,
1737+
byteSize: byteSize,
1738+
lastAccessTick: lastAccessTick)
1739+
kittyGraphicsState.totalImageBytes += byteSize
1740+
if let number = imageNumber {
1741+
kittyGraphicsState.imageNumbers[number] = imageId
1742+
}
1743+
enforceKittyImageCacheLimit()
1744+
}
1745+
1746+
private func updateKittyImageAccess(imageId: UInt32) -> KittyGraphicsImage? {
1747+
guard var image = kittyGraphicsState.imagesById[imageId] else {
1748+
return nil
1749+
}
1750+
image.lastAccessTick = nextKittyImageAccessTick()
1751+
kittyGraphicsState.imagesById[imageId] = image
1752+
return image
1753+
}
1754+
1755+
private func kittyPayloadByteSize(_ payload: KittyGraphicsPayload) -> Int {
1756+
switch payload {
1757+
case .png(let data):
1758+
return data.count
1759+
case .rgba(let bytes, _, _):
1760+
return bytes.count
1761+
}
1762+
}
1763+
1764+
private func nextKittyImageAccessTick() -> UInt64 {
1765+
let tick = kittyGraphicsState.nextImageAccessTick
1766+
kittyGraphicsState.nextImageAccessTick &+= 1
1767+
return tick
1768+
}
1769+
1770+
private func enforceKittyImageCacheLimit() {
1771+
let limit = clampedKittyImageCacheLimitBytes()
1772+
guard kittyGraphicsState.totalImageBytes > limit else {
1773+
return
1774+
}
1775+
1776+
let used = collectUsedKittyImageIds()
1777+
let unusedIds = kittyGraphicsState.imagesById
1778+
.filter { !used.contains($0.key) }
1779+
.sorted { $0.value.lastAccessTick < $1.value.lastAccessTick }
1780+
.map { $0.key }
1781+
for id in unusedIds {
1782+
removeKittyImage(imageId: id)
1783+
if kittyGraphicsState.totalImageBytes <= limit {
1784+
return
1785+
}
1786+
}
1787+
1788+
let oldestIds = kittyGraphicsState.imagesById
1789+
.sorted { $0.value.lastAccessTick < $1.value.lastAccessTick }
1790+
.map { $0.key }
1791+
for id in oldestIds {
1792+
removeKittyImage(imageId: id)
1793+
if kittyGraphicsState.totalImageBytes <= limit {
1794+
return
1795+
}
1796+
}
1797+
}
1798+
1799+
private func clampedKittyImageCacheLimitBytes() -> Int {
1800+
let configured = options.kittyImageCacheLimitBytes
1801+
if configured <= 0 {
1802+
return 0
1803+
}
1804+
return min(configured, Terminal.kittyMaxImageCacheBytes)
1805+
}
1806+
1807+
private func removeKittyImage(imageId: UInt32) {
1808+
guard let removed = kittyGraphicsState.imagesById.removeValue(forKey: imageId) else {
1809+
return
1810+
}
1811+
kittyGraphicsState.totalImageBytes = max(0, kittyGraphicsState.totalImageBytes - removed.byteSize)
1812+
removeKittyImageNumbers(for: imageId)
1813+
}
1814+
1815+
private func removeKittyImageNumbers(for imageId: UInt32) {
1816+
let numbers = kittyGraphicsState.imageNumbers.filter { $0.value == imageId }.map { $0.key }
1817+
for number in numbers {
1818+
kittyGraphicsState.imageNumbers.removeValue(forKey: number)
1819+
}
1820+
}
1821+
1822+
private func collectUsedKittyImageIds() -> Set<UInt32> {
17071823
var used = Set<UInt32>()
17081824
collectUsedKittyImageIds(from: normalBuffer, into: &used)
17091825
collectUsedKittyImageIds(from: altBuffer, into: &used)
17101826
for record in kittyGraphicsState.placementsByKey.values {
17111827
used.insert(record.imageId)
17121828
}
1713-
let unusedIds = kittyGraphicsState.imagesById.keys.filter { !used.contains($0) }
1714-
for id in unusedIds {
1715-
kittyGraphicsState.imagesById.removeValue(forKey: id)
1716-
}
1717-
let unusedNumbers = kittyGraphicsState.imageNumbers.filter { !used.contains($0.value) }.map { $0.key }
1718-
for number in unusedNumbers {
1719-
kittyGraphicsState.imageNumbers.removeValue(forKey: number)
1720-
}
1829+
return used
17211830
}
17221831

17231832
private func collectUsedKittyImageIds(from buffer: Buffer, into set: inout Set<UInt32>) {

Sources/SwiftTerm/Terminal.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,7 @@ open class Terminal {
699699
// when activated.
700700

701701
if clearAlt {
702+
clearKittyImages(in: altBuffer, isAlternateBuffer: true)
702703
altBuffer.clear ()
703704
}
704705
buffer = normalBuffer
@@ -716,6 +717,7 @@ open class Terminal {
716717

717718
altBuffer.fillViewportRows(attribute: fillAttr)
718719
buffer = altBuffer
720+
clearKittyImages(in: altBuffer, isAlternateBuffer: true)
719721
}
720722

721723
func setupTabStops (index: Int = -1)
@@ -4903,6 +4905,7 @@ open class Terminal {
49034905
options.cols = cols
49044906
let savedCursorHidden = cursorHidden
49054907
setup (isReset: true)
4908+
clearAllKittyImages()
49064909
cursorHidden = savedCursorHidden
49074910
refresh (startRow: 0, endRow: rows-1)
49084911
syncScrollArea ()

Sources/SwiftTerm/TerminalOptions.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ public struct TerminalOptions {
5858
public var tabStopWidth: Int
5959
/// Whether to report that sixel support is present
6060
public var enableSixelReported:Bool
61+
/// Maximum total bytes to keep for kitty image data; defaults to 320MB and is clamped to 4GB.
62+
public var kittyImageCacheLimitBytes: Int
6163

6264
/// Default options
6365
public static let `default` = TerminalOptions.init(cols: 80,
@@ -68,10 +70,11 @@ public struct TerminalOptions {
6870
screenReaderMode: false,
6971
scrollback: 500,
7072
tabStopWidth: 8,
71-
enableSixelReported: true)
73+
enableSixelReported: true,
74+
kittyImageCacheLimitBytes: 320 * 1024 * 1024)
7275

7376
public init(cols: Int = Self.default.cols, rows: Int = Self.default.rows, convertEol: Bool = Self.default.convertEol, termName: String = Self.default.termName, cursorStyle: CursorStyle = Self.default.cursorStyle, screenReaderMode: Bool = Self.default.screenReaderMode, scrollback: Int = Self.default.scrollback, tabStopWidth: Int = Self.default.tabStopWidth,
74-
enableSixelReported: Bool = Self.default.enableSixelReported) {
77+
enableSixelReported: Bool = Self.default.enableSixelReported, kittyImageCacheLimitBytes: Int = Self.default.kittyImageCacheLimitBytes) {
7578
self.cols = cols
7679
self.rows = rows
7780
self.convertEol = convertEol
@@ -81,5 +84,6 @@ public struct TerminalOptions {
8184
self.scrollback = scrollback
8285
self.tabStopWidth = tabStopWidth
8386
self.enableSixelReported = enableSixelReported
87+
self.kittyImageCacheLimitBytes = kittyImageCacheLimitBytes
8488
}
8589
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//
2+
// KittyGraphicsLifecycleTests.swift
3+
//
4+
#if os(macOS)
5+
import Foundation
6+
import Testing
7+
8+
@testable import SwiftTerm
9+
10+
final class KittyGraphicsLifecycleTests {
11+
private func makeHeadlessTerminal() -> HeadlessTerminal {
12+
HeadlessTerminal(queue: SwiftTermTests.queue, options: TerminalOptions(cols: 10, rows: 5)) { _ in }
13+
}
14+
15+
private func sendKitty(terminal: Terminal, control: String, payload: [UInt8]) {
16+
let base64 = Data(payload).base64EncodedString()
17+
let sequence = "\u{1b}_G\(control);\(base64)\u{1b}\\"
18+
terminal.feed(text: sequence)
19+
}
20+
21+
@Test func testKittyImagesClearedOnReset() {
22+
let h = makeHeadlessTerminal()
23+
let t = h.terminal!
24+
25+
sendKitty(terminal: t,
26+
control: "a=T,f=24,s=1,v=1,t=d,c=1,r=1,i=1,U=1",
27+
payload: [1, 2, 3])
28+
29+
#expect(t.kittyGraphicsState.imagesById[1] != nil)
30+
#expect(!t.kittyGraphicsState.placementsByKey.isEmpty)
31+
32+
t.feed(text: "\u{1b}c")
33+
34+
#expect(t.kittyGraphicsState.imagesById.isEmpty)
35+
#expect(t.kittyGraphicsState.imageNumbers.isEmpty)
36+
#expect(t.kittyGraphicsState.placementsByKey.isEmpty)
37+
}
38+
39+
@Test func testKittyImagesClearedWhenEnteringAltBuffer() {
40+
let h = makeHeadlessTerminal()
41+
let t = h.terminal!
42+
43+
t.feed(text: "\u{1b}[?1049h")
44+
#expect(t.isCurrentBufferAlternate)
45+
46+
sendKitty(terminal: t,
47+
control: "a=T,f=24,s=1,v=1,t=d,c=1,r=1,i=1,U=1",
48+
payload: [1, 2, 3])
49+
50+
#expect(t.kittyGraphicsState.imagesById[1] != nil)
51+
#expect(!t.kittyGraphicsState.placementsByKey.isEmpty)
52+
53+
t.feed(text: "\u{1b}[?47l")
54+
#expect(!t.isCurrentBufferAlternate)
55+
56+
#expect(t.kittyGraphicsState.imagesById[1] != nil)
57+
#expect(!t.kittyGraphicsState.placementsByKey.isEmpty)
58+
59+
t.feed(text: "\u{1b}[?1049h")
60+
#expect(t.isCurrentBufferAlternate)
61+
62+
#expect(t.kittyGraphicsState.imagesById.isEmpty)
63+
#expect(t.kittyGraphicsState.imageNumbers.isEmpty)
64+
#expect(t.kittyGraphicsState.placementsByKey.isEmpty)
65+
}
66+
}
67+
#endif

0 commit comments

Comments
 (0)