Files
orca/config/patches/xterm-src/@xterm__addon-image@0.10.0-beta.300.src.patch
T
OrcaWinandm4air e1362ada4c fix(terminal): stop inline-image decoders exhausting the renderer's wasm memory budget (#23499)
V8 reserves an 8 GiB guard region per wasm memory inside its 1 TiB sandbox,
so an Electron renderer can hold only ~124 live wasm memories regardless of
free RAM. @xterm/addon-image instantiated a SIXEL decoder per terminal at
activation (and kept IIP decoders after the first image), so ~120+ terminals
exhausted the budget: new panes raised 'WebAssembly.instantiate(): Out of
memory' rejections, and the next Kitty/IIP image threw 'WebAssembly.Memory():
could not allocate memory' out of the parser, permanently wedging that
terminal's write queue.

The addon-image source patch now borrows SIXEL decoders from a shared pool
only while a sequence is open (color registers stay on the terminal), drops
IIP decoders after each image, and turns a failed decoder allocation into a
dropped image instead of a parser throw. Bundles regenerated with
regenerate-xterm-patches.mjs --write.

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-09-29 01:20:37 -07:00

558 lines
23 KiB
Diff

diff --git a/src/IIPHandler.ts b/src/IIPHandler.ts
index 559b907416eb38318f439d060d7f89311ed34c7e..73cedbe99f831bffd202697cc8ba80a46a39cb99 100644
--- a/src/IIPHandler.ts
+++ b/src/IIPHandler.ts
@@ -13,8 +13,10 @@ import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
// Local const enum mirror - esbuild can't inline const enums from external packages
const enum DecoderConst {
- // Limit held memory in base64 decoder (encoded bytes).
- KEEP_DATA = 4194304,
+ // Held memory in base64/QOI decoders between images. Zero because each kept
+ // decoder pins a wasm memory, and V8 caps those per process (~124 in a
+ // sandboxed renderer), so idle terminals must not hold one.
+ KEEP_DATA = 0,
// Initial buffer allocation for the decoder.
INITIAL_DATA = 1048576,
// Local mirror of const enum (esbuild can't inline const enums from external packages)
@@ -34,6 +36,7 @@ const DEFAULT_HEADER: IHeaderFields = {
export class IIPHandler implements IOscHandler, IResetHandler {
+ private _generation = 0;
private _aborted = false;
private _hp = new HeaderParser();
private _header: IHeaderFields = DEFAULT_HEADER;
@@ -55,6 +58,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
}
public reset(): void {
+ this._generation++;
this._hp.reset();
this._dec.release();
this._qoiDec.release();
@@ -92,7 +96,10 @@ export class IIPHandler implements IOscHandler, IResetHandler {
this._aborted = true;
return;
}
- this._dec.init();
+ if (!this._initDecoder()) {
+ this._aborted = true;
+ return;
+ }
} else if (this._abortMulti) {
this._aborted = true;
return;
@@ -135,7 +142,9 @@ export class IIPHandler implements IOscHandler, IResetHandler {
this._isMultipart = true;
this._abortMulti = false;
this._dec.release();
- this._dec.init();
+ if (!this._initDecoder()) {
+ this._abortMulti = true;
+ }
return true;
}
@@ -179,7 +188,15 @@ export class IIPHandler implements IOscHandler, IResetHandler {
let blob: Blob | ImageData;
if (metrics.mime === 'image/qoi') {
- const data = this._qoiDec.decode(this._dec.data8);
+ let data: Uint8Array<ArrayBuffer>;
+ try {
+ data = this._qoiDec.decode(this._dec.data8);
+ } catch (e) {
+ console.warn('IIP: could not decode QOI image', e);
+ this._dec.release();
+ this._qoiDec.release();
+ return true;
+ }
blob = new ImageData(
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
this._qoiDec.width,
@@ -198,8 +215,13 @@ export class IIPHandler implements IOscHandler, IResetHandler {
blob = new Blob([this._dec.data8], { type: metrics.mime });
}
this._dec.release();
+ const generation = this._generation;
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
.then(bm => {
+ if (generation !== this._generation) {
+ bm.close();
+ return true;
+ }
this._storage.addImage(bm);
return true;
})
@@ -209,6 +231,18 @@ export class IIPHandler implements IOscHandler, IResetHandler {
});
}
+ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.
+ private _initDecoder(): boolean {
+ try {
+ this._dec.init();
+ return true;
+ } catch (e) {
+ console.warn('IIP: could not allocate decoder', e);
+ this._dec.release();
+ return false;
+ }
+ }
+
private _resize(w: number, h: number): [number, number] {
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
diff --git a/src/ImageAddon.ts b/src/ImageAddon.ts
index 8fd39543118cd420e36c1614c1af370b6c7bbfbb..0c44d2a81642113417bf8dc10a4faa76d7cc5864 100644
--- a/src/ImageAddon.ts
+++ b/src/ImageAddon.ts
@@ -113,6 +113,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
}
public dispose(): void {
+ for (const handler of this._handlers.values()) handler.reset();
for (const obj of this._disposables) {
obj.dispose();
}
diff --git a/src/ImageRenderer.ts b/src/ImageRenderer.ts
index 5854efaec1fdf9dfcb886023542998a563b6d2f2..3afaf9bd63ffd7a4cdf32bf0ac24cf33a8aa814f 100644
--- a/src/ImageRenderer.ts
+++ b/src/ImageRenderer.ts
@@ -186,16 +186,17 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._rescaleImage(imgSpec, width, height);
const img = imgSpec.actual!;
- const cols = Math.ceil(img.width / width);
+ const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;
+ const cols = Math.ceil(img.width / sourceWidth);
- const sx = (tileId % cols) * width;
- const sy = Math.floor(tileId / cols) * height;
+ const sx = (tileId % cols) * sourceWidth;
+ const sy = Math.floor(tileId / cols) * sourceHeight;
const dx = col * width;
const dy = row * height;
// safari bug: never access image source out of bounds
- const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;
- const finalHeight = sy + height > img.height ? img.height - sy : height;
+ const finalWidth = count * sourceWidth + sx > img.width ? img.width - sx : count * sourceWidth;
+ const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;
// Floor all pixel offsets to get stable tile mapping without any overflows.
// Note: For not pixel perfect aligned cells like in the DOM renderer
@@ -204,7 +205,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
- Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
+ Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight)
);
}
@@ -219,19 +220,20 @@ export class ImageRenderer extends Disposable implements IDisposable {
}
this._rescaleImage(imgSpec, width, height);
const img = imgSpec.actual!;
- const cols = Math.ceil(img.width / width);
- const sx = (tileId % cols) * width;
- const sy = Math.floor(tileId / cols) * height;
- const finalWidth = width + sx > img.width ? img.width - sx : width;
- const finalHeight = sy + height > img.height ? img.height - sy : height;
-
- const canvas = ImageRenderer.createCanvas(this.document, finalWidth, finalHeight);
+ const { width: sourceWidth, height: sourceHeight } = imgSpec.actualCellSize;
+ const cols = Math.ceil(img.width / sourceWidth);
+ const sx = (tileId % cols) * sourceWidth;
+ const sy = Math.floor(tileId / cols) * sourceHeight;
+ const finalWidth = sourceWidth + sx > img.width ? img.width - sx : sourceWidth;
+ const finalHeight = sy + sourceHeight > img.height ? img.height - sy : sourceHeight;
+
+ const canvas = ImageRenderer.createCanvas(this.document, Math.ceil(finalWidth * width / sourceWidth), Math.ceil(finalHeight * height / sourceHeight));
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),
- 0, 0, Math.floor(finalWidth), Math.floor(finalHeight)
+ 0, 0, canvas.width, canvas.height
);
return canvas;
}
@@ -299,11 +301,16 @@ export class ImageRenderer extends Disposable implements IDisposable {
spec.actualCellSize.height = originalHeight;
return;
}
- const canvas = ImageRenderer.createCanvas(
- this.document,
- Math.ceil(spec.orig!.width * currentWidth / originalWidth),
- Math.ceil(spec.orig!.height * currentHeight / originalHeight)
- );
+ const scaledWidth = Math.ceil(spec.orig!.width * currentWidth / originalWidth);
+ const scaledHeight = Math.ceil(spec.orig!.height * currentHeight / originalHeight);
+ // Upscale visible tiles directly; a full zoomed copy can dwarf the image budget.
+ if (scaledWidth * scaledHeight > spec.orig!.width * spec.orig!.height) {
+ spec.actual = spec.orig;
+ spec.actualCellSize.width = originalWidth;
+ spec.actualCellSize.height = originalHeight;
+ return;
+ }
+ const canvas = ImageRenderer.createCanvas(this.document, scaledWidth, scaledHeight);
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(spec.orig!, 0, 0, canvas.width, canvas.height);
@@ -415,7 +422,11 @@ export class ImageRenderer extends Disposable implements IDisposable {
for (let i = 0; i < width; i += bWidth) {
ctx2.drawImage(blueprint, i, 0);
}
- ImageRenderer.createImageBitmap(this._placeholder).then(bitmap => this._placeholderBitmap = bitmap);
+ const placeholder = this._placeholder;
+ ImageRenderer.createImageBitmap(placeholder).then(bitmap => {
+ if (this._placeholder !== placeholder) bitmap?.close();
+ else this._placeholderBitmap = bitmap;
+ }).catch(() => {});
}
public get document(): Document | undefined {
diff --git a/src/SixelHandler.ts b/src/SixelHandler.ts
index 1af2d85bcdd541ed60b1e707f6186a91f1bbf1b2..0711a122ea43d5212a2851add9e744b65550ec85 100644
--- a/src/SixelHandler.ts
+++ b/src/SixelHandler.ts
@@ -10,6 +10,7 @@ import { RGBA8888 } from 'sixel/lib/Types';
import { ImageRenderer } from './ImageRenderer';
import { DecoderAsync, Decoder } from 'sixel/lib/Decoder';
+import { LIMITS } from 'sixel/lib/wasm';
// always free decoder ressources after decoding if it exceeds this limit
const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB
@@ -18,48 +19,88 @@ const MEM_PERMA_LIMIT = 4194304; // 1024 pixels * 1024 pixels * 4 channels = 4MB
const DEFAULT_PALETTE = PALETTE_ANSI_256;
DEFAULT_PALETTE.set(PALETTE_VT340_COLOR);
+// Why pooled: every decoder owns a wasm memory, and V8 caps live wasm memories
+// per process (~124 in a sandboxed renderer). Terminals borrow a decoder only
+// while a SIXEL sequence is open, so idle terminals hold none.
+const MAX_IDLE_DECODERS = 2;
+const idleDecoders = new Map<number, Decoder[]>();
+let poolPrimed = false;
+
+function primeDecoderPool(memoryLimit: number): void {
+ if (poolPrimed) return;
+ poolPrimed = true;
+ // Async compile once, off the parser's hot path; later decoders reuse the cached module.
+ DecoderAsync({ memoryLimit, palette: DEFAULT_PALETTE }).then(
+ d => releaseDecoder(d, memoryLimit),
+ () => { poolPrimed = false; }
+ );
+}
+
+function acquireDecoder(memoryLimit: number): Decoder {
+ return idleDecoders.get(memoryLimit)?.pop() ?? new Decoder({ memoryLimit, palette: DEFAULT_PALETTE });
+}
+
+function releaseDecoder(dec: Decoder, memoryLimit: number): void {
+ if (dec.memoryUsage > MEM_PERMA_LIMIT) {
+ dec.release();
+ }
+ const idle = idleDecoders.get(memoryLimit) ?? [];
+ if (idle.length < MAX_IDLE_DECODERS) {
+ idle.push(dec);
+ idleDecoders.set(memoryLimit, idle);
+ }
+}
+
export class SixelHandler implements IDcsHandler, IResetHandler {
private _size = 0;
private _aborted = false;
private _dec: Decoder | undefined;
+ private _decMemoryLimit = 0;
+ // Color registers outlive a single image, so they live here rather than in a pooled decoder.
+ private readonly _palette = new Uint32Array(LIMITS.PALETTE_SIZE);
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _storage: SixelImageStorage,
private readonly _coreTerminal: ITerminalExt
) {
- DecoderAsync({
- memoryLimit: this._opts.pixelLimit * 4,
- palette: DEFAULT_PALETTE,
- paletteLimit: this._opts.sixelPaletteLimit
- }).then(d => this._dec = d);
+ this._palette.set(DEFAULT_PALETTE);
+ primeDecoderPool(this._opts.pixelLimit * 4);
}
public reset(): void {
- /**
- * reset sixel decoder to defaults:
- * - release all memory
- * - nullify palette (4096)
- * - apply default palette (256)
- */
- if (this._dec) {
- this._dec.release();
- // FIXME: missing interface on decoder to nullify full palette
- (this._dec as any)._palette.fill(0);
- this._dec.init(0, DEFAULT_PALETTE, this._opts.sixelPaletteLimit);
- }
+ this._returnDecoder();
+ this._palette.fill(0);
+ this._palette.set(DEFAULT_PALETTE);
}
public hook(params: IParams): void {
this._size = 0;
this._aborted = false;
- if (this._dec) {
- const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(
- this._coreTerminal._core._inputHandler._curAttrData,
- this._coreTerminal._core._themeService?.colors);
- this._dec.init(fillColor, null, this._opts.sixelPaletteLimit);
+ this._returnDecoder();
+ const memoryLimit = this._opts.pixelLimit * 4;
+ try {
+ this._dec = acquireDecoder(memoryLimit);
+ } catch (e) {
+ // Why: exhausting wasm memory must drop this image, not throw out of the parser and wedge the write queue.
+ console.warn(`SIXEL: could not allocate decoder - ${e}`);
+ this._aborted = true;
+ return;
}
+ this._decMemoryLimit = memoryLimit;
+ const fillColor = params.params[1] === 1 ? 0 : extractActiveBg(
+ this._coreTerminal._core._inputHandler._curAttrData,
+ this._coreTerminal._core._themeService?.colors);
+ this._dec.init(fillColor, this._palette, this._opts.sixelPaletteLimit);
+ }
+
+ private _returnDecoder(): void {
+ const dec = this._dec;
+ if (!dec) return;
+ this._dec = undefined;
+ this._palette.set(dec.palette);
+ releaseDecoder(dec, this._decMemoryLimit);
}
public put(data: Uint32Array, start: number, end: number): void {
@@ -83,6 +124,14 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
}
public unhook(success: boolean): boolean | Promise<boolean> {
+ try {
+ return this._unhook(success);
+ } finally {
+ this._returnDecoder();
+ }
+ }
+
+ private _unhook(success: boolean): boolean {
if (this._aborted || !success || !this._dec) {
return true;
}
@@ -100,9 +149,6 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
const canvas = ImageRenderer.createCanvas(undefined, width, height);
canvas.getContext('2d')?.putImageData(new ImageData(this._dec.data8 as Uint8ClampedArray<ArrayBuffer>, width, height), 0, 0);
- if (this._dec.memoryUsage > MEM_PERMA_LIMIT) {
- this._dec.release();
- }
this._storage.addImage(canvas);
return true;
}
diff --git a/src/kitty/KittyGraphicsHandler.ts b/src/kitty/KittyGraphicsHandler.ts
index de889dfff75d9ecc8ab47a025e6989ffe75bb202..cf66e5b75c78645b1641e53d1425d88201134f78 100644
--- a/src/kitty/KittyGraphicsHandler.ts
+++ b/src/kitty/KittyGraphicsHandler.ts
@@ -7,6 +7,7 @@ import { IDisposable } from '@xterm/xterm';
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
import { ImageRenderer } from '../ImageRenderer';
import { CELL_SIZE_DEFAULT } from '../ImageStorage';
+import { imageType } from '../IIPMetrics';
import { KittyImageStorage } from './KittyImageStorage';
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
import {
@@ -37,6 +38,7 @@ const DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;
// Kitty graphics protocol handler with streaming base64 decoding.
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
private _aborted = false;
+ private _generation = 0;
private _decodeError = false;
private _activeDecoder: Base64Decoder | null = null;
@@ -80,6 +82,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
public reset(): void {
+ this._generation++;
this._cleanupAllPending();
if (this._activeDecoder) {
this._activeDecoder.release();
@@ -200,8 +203,38 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
this._activeDecoder = pending.decoder;
}
if (!this._activeDecoder) {
- this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
- this._activeDecoder.init();
+ // Budget WASM capacity, including one page of decoder state and rounding.
+ const decoderCapacity = this._maxEncodedBytes + 131072;
+ if (decoderCapacity > this._opts.storageLimit * 1000000) {
+ this._aborted = true;
+ if (this._parsedCommand?.id !== undefined) {
+ this._sendResponse(this._parsedCommand.id, 'ENOMEM:pending image budget exceeded', this._parsedCommand.quiet ?? 0);
+ }
+ return;
+ }
+ const maxPending = Math.max(1, Math.floor(this._opts.storageLimit * 1000000 / decoderCapacity));
+ while (this._pendingTransmissions.size >= maxPending) {
+ const oldest = this._pendingTransmissions.entries().next().value;
+ if (!oldest) break;
+ oldest[1].decoder.release();
+ this._removePendingEntry(oldest[0]);
+ if (oldest[1].cmd.id !== undefined) {
+ this._sendResponse(oldest[1].cmd.id, 'ENOMEM:pending image budget exceeded', oldest[1].cmd.quiet ?? 0);
+ }
+ }
+ const decoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
+ try {
+ decoder.init();
+ } catch (e) {
+ // Why: wasm memory exhaustion must drop this image, not throw out of the parser and wedge the write queue.
+ console.warn('KITTY: could not allocate decoder', e);
+ this._aborted = true;
+ if (this._parsedCommand?.id !== undefined) {
+ this._sendResponse(this._parsedCommand.id, 'ENOMEM:could not allocate decoder', this._parsedCommand.quiet ?? 0);
+ }
+ return;
+ }
+ this._activeDecoder = decoder;
}
if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {
@@ -550,9 +583,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
+ const generation = this._generation;
let bitmap: ImageBitmap | undefined = await this._createBitmap(image);
try {
+ if (generation !== this._generation) throw new Error('image decode canceled');
const cropX = Math.max(0, cmd.x ?? 0);
const cropY = Math.max(0, cmd.y ?? 0);
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
@@ -660,6 +695,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
}
+ if (generation !== this._generation) throw new Error('image decode canceled');
const zIndex = cmd.zIndex ?? 0;
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
bitmap = undefined; // ownership transferred to storage
@@ -693,6 +729,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
if (image.format === KittyFormat.PNG) {
+ const metrics = imageType(bytes);
+ // IHDR dimensions are parsed with signed shifts, so a value >= 0x80000000 comes
+ // back negative and a bare `>` pixel-limit test passes it; require positive.
+ if (metrics.mime !== 'image/png' || !(metrics.width > 0) || !(metrics.height > 0) || metrics.width * metrics.height > this._opts.pixelLimit) {
+ throw new RangeError('PNG exceeds pixel limit or has invalid dimensions');
+ }
const blob = new Blob([bytes as BlobPart], { type: 'image/png' });
if (!window.createImageBitmap) {
const url = URL.createObjectURL(blob);
@@ -775,27 +817,45 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
try {
return await this._decompress(compressed, 'deflate');
- } catch {
+ } catch (error) {
+ if (error instanceof RangeError) throw error;
return await this._decompress(compressed, 'deflate-raw');
}
}
private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
- const ds = new DecompressionStream(format);
- const writer = ds.writable.getWriter();
- writer.write(compressed as BufferSource);
- writer.close();
-
+ const limit = Math.min(this._opts.kittySizeLimit, this._opts.pixelLimit * 4, this._opts.storageLimit * 1000000);
+ let offsetIn = 0;
+ // Bound inflation within one native transform before its output is budgeted.
+ const source = new ReadableStream<BufferSource>({
+ pull(controller) {
+ if (offsetIn >= compressed.length) {
+ controller.close();
+ return;
+ }
+ const end = Math.min(offsetIn + 4096, compressed.length);
+ controller.enqueue(new Uint8Array(compressed.subarray(offsetIn, end)));
+ offsetIn = end;
+ }
+ });
+ const reader = source.pipeThrough(new DecompressionStream(format)).getReader();
const chunks: Uint8Array[] = [];
- const reader = ds.readable.getReader();
-
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- chunks.push(value);
+ let totalLength = 0;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ totalLength += value.byteLength;
+ if (totalLength > limit) {
+ await reader.cancel().catch(() => {});
+ throw new RangeError('decompressed image exceeds byte limit');
+ }
+ chunks.push(value);
+ }
+ } finally {
+ reader.releaseLock();
}
- const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
diff --git a/src/kitty/KittyImageStorage.ts b/src/kitty/KittyImageStorage.ts
index 1f5c09ec9e2700f8f6dbd8436a1802217dfc99ef..016943a77c7e8e27da5899d54cd48d82761a87c8 100644
--- a/src/kitty/KittyImageStorage.ts
+++ b/src/kitty/KittyImageStorage.ts
@@ -83,6 +83,25 @@ export class KittyImageStorage implements IDisposable {
this._evictUndisplayedImages();
}
+ // Encoded images awaiting placement are outside ImageStorage's pixel budget.
+ // Unplaced payloads are evicted first so a new upload cannot erase a visible
+ // image while abandoned blobs still hold budget; placed ones go only when
+ // that is not enough, because the byte cap is a hard bound. The new image is
+ // always stored, so an oversized one overshoots by at most one payload
+ // (itself bounded by kittySizeLimit) rather than being dropped after an OK ack.
+ const byteLimit = this._storage.getLimit() * 1000000;
+ this._images.delete(imageId);
+ let retainedBytes = 0;
+ for (const image of this._images.values()) retainedBytes += image.data.size;
+ for (const evictPlaced of [false, true]) {
+ for (const [oldestId, image] of this._images) {
+ if (retainedBytes + imageData.data.size <= byteLimit) break;
+ if (this._kittyIdToStorageId.has(oldestId) !== evictPlaced) continue;
+ retainedBytes -= image.data.size;
+ this.deleteById(oldestId);
+ }
+ }
+
this._images.set(imageId, {
...imageData,
id: imageId