Skip to content

Commit 27b01b2

Browse files
committed
Extract KittyImageStorage, prevent mem leak on eviction
1 parent 387ec93 commit 27b01b2

File tree

5 files changed

+253
-49
lines changed

5 files changed

+253
-49
lines changed

addons/addon-image/src/ImageAddon.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { IIPHandler } from './IIPHandler';
99
import { ImageRenderer } from './ImageRenderer';
1010
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
1111
import { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';
12+
import { KittyImageStorage } from './kitty/KittyImageStorage';
1213
import { SixelHandler } from './SixelHandler';
1314
import { SixelImageStorage } from './SixelImageStorage';
1415
import { IIPImageStorage } from './IIPImageStorage';
@@ -154,9 +155,12 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
154155

155156
// Kitty graphics handler
156157
if (this._opts.kittySupport) {
157-
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, this._storage!, terminal);
158+
const kittyStorage = new KittyImageStorage(this._storage!);
159+
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);
158160
this._handlers.set('kitty', kittyHandler);
159161
this._disposeLater(
162+
kittyStorage,
163+
kittyHandler,
160164
terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler)
161165
);
162166
}

addons/addon-image/src/ImageStorage.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export class ImageStorage implements IDisposable {
124124
private _pixelLimit: number = 2500000;
125125

126126
private _viewportMetrics: { cols: number, rows: number };
127+
public onImageDeleted: ((storageId: number) => void) | undefined;
127128

128129
constructor(
129130
private _terminal: ITerminalExt,
@@ -189,11 +190,13 @@ export class ImageStorage implements IDisposable {
189190

190191
private _delImg(id: number): void {
191192
const spec = this._images.get(id);
193+
if (!spec) return;
192194
this._images.delete(id);
193195
// FIXME: really ugly workaround to get bitmaps deallocated :(
194-
if (spec && window.ImageBitmap && spec.orig instanceof ImageBitmap) {
196+
if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {
195197
spec.orig.close();
196198
}
199+
this.onImageDeleted?.(id);
197200
}
198201

199202
/**

addons/addon-image/src/kitty/KittyGraphicsHandler.ts

Lines changed: 23 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
* @license MIT
44
*/
55

6+
import { IDisposable } from '@xterm/xterm';
67
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
78
import { ImageRenderer } from '../ImageRenderer';
8-
import { ImageStorage, CELL_SIZE_DEFAULT } from '../ImageStorage';
9+
import { CELL_SIZE_DEFAULT } from '../ImageStorage';
10+
import { KittyImageStorage } from './KittyImageStorage';
911
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
1012
import {
1113
KittyAction,
@@ -36,7 +38,7 @@ const SEMICOLON = 0x3B;
3638
/**
3739
* Kitty graphics protocol handler with streaming base64 decoding.
3840
*/
39-
export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
41+
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
4042
private _aborted = false;
4143
private _decodeError = false;
4244

@@ -69,21 +71,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
6971
* When a chunk arrives with no i=, this key is used to find the pending upload.
7072
*/
7173
private _lastPendingKey: number | undefined;
72-
private _nextImageId = 1;
73-
/** Maps Kitty protocol image ID → ImageStorage internal ID for deletion/lookup. */
74-
private _kittyIdToStorageId: Map<number, number> = new Map();
75-
// TODO: Eliminate double storage — raw image data lives here (as Blob) AND rendered
76-
// ImageBitmaps live in ImageStorage. Currently we only use ImageStorage.addImage(bitmap)
77-
// for tiling + cursor movement + marker-based eviction.
78-
//
79-
80-
// See: https://github.com/xtermjs/xterm.js/pull/5619#issuecomment-3853678815
81-
private _images: Map<number, IKittyImageData> = new Map();
8274

8375
constructor(
8476
private readonly _opts: IImageAddonOptions,
8577
private readonly _renderer: ImageRenderer,
86-
private readonly _storage: ImageStorage,
78+
private readonly _kittyStorage: KittyImageStorage,
8779
private readonly _coreTerminal: ITerminalExt
8880
) {
8981
// Convert decoded size limit -> max encoded bytes.
@@ -102,8 +94,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
10294
this._activeDecoder.release();
10395
this._activeDecoder = null;
10496
}
105-
this._images.clear();
106-
this._kittyIdToStorageId.clear();
97+
this._kittyStorage.reset();
98+
}
99+
100+
public dispose(): void {
101+
this.reset();
107102
}
108103

109104
public start(): void {
@@ -397,16 +392,13 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
397392

398393
if (decodeError || bytes.length === 0) return true;
399394

400-
const id = cmd.id ?? this._nextImageId++;
401-
const image: IKittyImageData = {
402-
id,
395+
this._kittyStorage.storeImage(cmd.id, {
403396
data: new Blob([bytes as BlobPart]),
404397
width: cmd.width ?? 0,
405398
height: cmd.height ?? 0,
406399
format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,
407400
compression: cmd.compression ?? ''
408-
};
409-
this._images.set(id, image);
401+
});
410402
return true;
411403
}
412404

@@ -426,8 +418,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
426418
if (this._pendingTransmissions.has(pendingKey)) return true;
427419

428420
// Display the completed image
429-
const id = cmd.id ?? this._nextImageId - 1;
430-
const image = this._images.get(id);
421+
const id = cmd.id ?? this._kittyStorage.lastImageId;
422+
const image = this._kittyStorage.getImage(id);
431423
if (image) {
432424
const result = this._displayImage(image, cmd);
433425
if (cmd.id !== undefined) {
@@ -508,7 +500,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
508500
}
509501
this._pendingTransmissions.clear();
510502
this._lastPendingKey = undefined;
511-
this._deleteAll();
503+
this._kittyStorage.deleteAll();
512504
break;
513505
case 'i':
514506
case 'I':
@@ -522,7 +514,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
522514
this._lastPendingKey = undefined;
523515
}
524516
}
525-
this._deleteById(cmd.id);
517+
this._kittyStorage.deleteById(cmd.id);
526518
}
527519
break;
528520
default:
@@ -532,23 +524,6 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
532524
return true;
533525
}
534526

535-
private _deleteById(id: number): void {
536-
this._images.delete(id);
537-
const storageId = this._kittyIdToStorageId.get(id);
538-
if (storageId !== undefined) {
539-
this._storage.deleteImage(storageId);
540-
this._kittyIdToStorageId.delete(id);
541-
}
542-
}
543-
544-
private _deleteAll(): void {
545-
this._images.clear();
546-
for (const storageId of this._kittyIdToStorageId.values()) {
547-
this._storage.deleteImage(storageId);
548-
}
549-
this._kittyIdToStorageId.clear();
550-
}
551-
552527
private _sendResponse(id: number, message: string, quiet: number): void {
553528
const isOk = message === 'OK';
554529
if (isOk && quiet === 1) return;
@@ -602,14 +577,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
602577
const layer: ImageLayer = (wantsBottom && this._coreTerminal.options.allowTransparency) ? 'bottom' : 'top';
603578

604579
const zIndex = cmd.zIndex ?? 0;
605-
let storageId: number;
606580
if (w !== bitmap.width || h !== bitmap.height) {
607581
const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
608-
storageId = this._storage.addImage(resized, true, layer, zIndex);
582+
this._kittyStorage.addImage(image.id, resized, true, layer, zIndex);
609583
} else {
610-
storageId = this._storage.addImage(bitmap, true, layer, zIndex);
584+
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
611585
}
612-
this._kittyIdToStorageId.set(image.id, storageId);
613586

614587
// Kitty cursor movement
615588
// Per spec: cursor placed at first column after last image column,
@@ -723,7 +696,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
723696
}
724697

725698
public get images(): ReadonlyMap<number, IKittyImageData> {
726-
return this._images;
699+
return this._kittyStorage.images;
700+
}
701+
702+
public get _kittyIdToStorageId(): ReadonlyMap<number, number> {
703+
return this._kittyStorage.kittyIdToStorageId;
727704
}
728705

729706
public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
3+
* @license MIT
4+
*/
5+
6+
import { IDisposable } from '@xterm/xterm';
7+
import { ImageStorage } from '../ImageStorage';
8+
import { ImageLayer } from '../Types';
9+
import { IKittyImageData } from './KittyGraphicsTypes';
10+
11+
/**
12+
* Kitty-specific image storage controller.
13+
*
14+
* Wraps shared ImageStorage with kitty protocol semantics:
15+
* - tracks transmitted image payloads by kitty image id
16+
* - tracks kitty image id -> shared ImageStorage id mapping for displayed images
17+
* - mirrors shared-storage evictions into kitty maps
18+
* - applies protocol-level undisplayed-image eviction policy
19+
*/
20+
export class KittyImageStorage implements IDisposable {
21+
private static readonly _maxStoredImages = 256;
22+
23+
private _nextImageId = 1;
24+
private readonly _images: Map<number, IKittyImageData> = new Map();
25+
private readonly _kittyIdToStorageId: Map<number, number> = new Map();
26+
27+
private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;
28+
private readonly _wrappedOnImageDeleted: (storageId: number) => void;
29+
private readonly _handleStorageImageDeleted = (storageId: number): void => {
30+
for (const [kittyId, mappedStorageId] of this._kittyIdToStorageId) {
31+
if (mappedStorageId === storageId) {
32+
this._kittyIdToStorageId.delete(kittyId);
33+
this._images.delete(kittyId);
34+
break;
35+
}
36+
}
37+
};
38+
39+
constructor(
40+
private readonly _storage: ImageStorage
41+
) {
42+
this._previousOnImageDeleted = this._storage.onImageDeleted;
43+
this._wrappedOnImageDeleted = (storageId: number) => {
44+
this._previousOnImageDeleted?.(storageId);
45+
this._handleStorageImageDeleted(storageId);
46+
};
47+
this._storage.onImageDeleted = this._wrappedOnImageDeleted;
48+
}
49+
50+
public reset(): void {
51+
this._nextImageId = 1;
52+
this._images.clear();
53+
this._kittyIdToStorageId.clear();
54+
}
55+
56+
public dispose(): void {
57+
this.reset();
58+
if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {
59+
this._storage.onImageDeleted = this._previousOnImageDeleted;
60+
}
61+
}
62+
63+
public storeImage(id: number | undefined, imageData: Omit<IKittyImageData, 'id'>): number {
64+
const imageId = id ?? this._nextImageId++;
65+
66+
const oldStorageId = this._kittyIdToStorageId.get(imageId);
67+
if (oldStorageId !== undefined) {
68+
this._storage.deleteImage(oldStorageId);
69+
this._kittyIdToStorageId.delete(imageId);
70+
}
71+
72+
if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {
73+
this._evictUndisplayedImages();
74+
}
75+
76+
this._images.set(imageId, {
77+
...imageData,
78+
id: imageId
79+
});
80+
return imageId;
81+
}
82+
83+
public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {
84+
const storageId = this._storage.addImage(image, scrolling, layer, zIndex);
85+
this._kittyIdToStorageId.set(kittyId, storageId);
86+
}
87+
88+
public getImage(kittyId: number): IKittyImageData | undefined {
89+
return this._images.get(kittyId);
90+
}
91+
92+
public deleteById(kittyId: number): void {
93+
this._images.delete(kittyId);
94+
const storageId = this._kittyIdToStorageId.get(kittyId);
95+
if (storageId !== undefined) {
96+
this._storage.deleteImage(storageId);
97+
this._kittyIdToStorageId.delete(kittyId);
98+
}
99+
}
100+
101+
public deleteAll(): void {
102+
this._images.clear();
103+
for (const storageId of this._kittyIdToStorageId.values()) {
104+
this._storage.deleteImage(storageId);
105+
}
106+
this._kittyIdToStorageId.clear();
107+
}
108+
109+
public get images(): ReadonlyMap<number, IKittyImageData> {
110+
return this._images;
111+
}
112+
113+
public get kittyIdToStorageId(): ReadonlyMap<number, number> {
114+
return this._kittyIdToStorageId;
115+
}
116+
117+
public get lastImageId(): number {
118+
return this._nextImageId - 1;
119+
}
120+
121+
private _evictUndisplayedImages(): void {
122+
for (const [kittyId] of this._images) {
123+
if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {
124+
break;
125+
}
126+
if (!this._kittyIdToStorageId.has(kittyId)) {
127+
this._images.delete(kittyId);
128+
}
129+
}
130+
}
131+
}

0 commit comments

Comments
 (0)