From c11a8f43f43ce75caeb7af213e146ee3c01cfe1e Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Mon, 9 Feb 2026 13:34:27 -0800 Subject: [PATCH] abort in-flight kitty chunks on delete --- .../src/kitty/KittyGraphicsHandler.ts | 17 +++++ addons/addon-image/test/KittyGraphics.test.ts | 67 ++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts index 9739d3d3..0262af6a 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts @@ -431,6 +431,13 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { const id = cmd.id; if (id !== undefined) { + // Abort in-flight chunked upload for this specific image + const pending = this._pendingTransmissions.get(id); + if (pending) { + pending.decoder.release(); + this._pendingTransmissions.delete(id); + } + this._images.delete(id); const storageId = this._kittyIdToStorageId.get(id); if (storageId !== undefined) { @@ -438,6 +445,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { this._kittyIdToStorageId.delete(id); } } else { + // Abort all in-flight chunked uploads + for (const pending of this._pendingTransmissions.values()) { + pending.decoder.release(); + } + this._pendingTransmissions.clear(); + this._images.clear(); for (const storageId of this._kittyIdToStorageId.values()) { this._storage.deleteImage(storageId); @@ -607,4 +620,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { public get images(): ReadonlyMap { return this._images; } + + public get pendingTransmissions(): ReadonlyMap { + return this._pendingTransmissions; + } } diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts index 0cb94b75..2ddd5eb4 100644 --- a/addons/addon-image/test/KittyGraphics.test.ts +++ b/addons/addon-image/test/KittyGraphics.test.ts @@ -6,7 +6,7 @@ import test from '@playwright/test'; import { readFileSync } from 'fs'; import { ITestContext, createTestContext, openTerminal, timeout } from '../../../test/playwright/TestUtils'; -import { deepStrictEqual, strictEqual } from 'assert'; +import { deepStrictEqual, ok, strictEqual } from 'assert'; /** * Plugin ctor options. @@ -245,6 +245,71 @@ test.describe('Kitty Graphics Protocol', () => { await timeout(50); strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0); }); + + test('delete by id aborts in-flight chunked upload', async () => { + const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2); + const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half); + + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=50,m=1;${part1}\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 1); + + await ctx.proxy.write(`\x1b_Ga=d,i=50\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 0); + }); + + test('delete by id only aborts targeted upload, not others', async () => { + const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2); + const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half); + + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=55,m=1;${part1}\x1b\\`); + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=56,m=1;${part1}\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 2); + + await ctx.proxy.write(`\x1b_Ga=d,i=55\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 1); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.has(56)`), true); + }); + + test('delete all aborts in-flight chunked upload', async () => { + const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2); + const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half); + + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=60,m=1;${part1}\x1b\\`); + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=61,m=1;${part1}\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 2); + + await ctx.proxy.write(`\x1b_Ga=d\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 0); + }); + + test('chunks sent after delete are not assembled with previous data', async () => { + const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2); + const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half); + const part2 = KITTY_BLACK_1X1_BASE64.substring(half); + + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=70,m=1;${part1}\x1b\\`); + await timeout(50); + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 1); + + await ctx.proxy.write(`\x1b_Ga=d\x1b\\`); + await timeout(50); + + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=70;${part2}\x1b\\`); + await timeout(100); + + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(70)`), true); + const storedSize: number = await ctx.page.evaluate(async () => { + const blob = (window as any).imageAddon._handlers.get('kitty').images.get(70).data; + return blob.size; + }); + ok(storedSize < KITTY_BLACK_1X1_BYTES.length, 'stored data should be smaller than full image (only second half)'); + }); }); test.describe('Query support (a=q)', () => {