mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
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>
558 lines
23 KiB
Diff
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
|