diff --git a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts index 100f4853..27232c52 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts @@ -63,6 +63,12 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { // Storage related states private _pendingTransmissions: Map = new Map(); + /** + * Tracks the pending key of the most recently started chunked upload. + * Per spec, subsequent chunks only need m= (and optionally q=), without i=. + * When a chunk arrives with no i=, this key is used to find the pending upload. + */ + private _lastPendingKey: number | undefined; private _nextImageId = 1; /** Maps Kitty protocol image ID → ImageStorage internal ID for deletion/lookup. */ private _kittyIdToStorageId: Map = new Map(); @@ -91,6 +97,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { pending.decoder.release(); } this._pendingTransmissions.clear(); + this._lastPendingKey = undefined; if (this._activeDecoder) { this._activeDecoder.release(); this._activeDecoder = null; @@ -168,8 +175,9 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { if (this._aborted) return; // Check size limit (compare encoded bytes against pre-calculated limit) - // Include cumulative size from pending transmission for multi-chunk images - const pendingKey = this._parsedCommand?.id ?? 0; + // Include cumulative size from pending transmission for multi-chunk images. + // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey. + const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0; const pending = this._pendingTransmissions.get(pendingKey); const previousEncodedSize = pending?.totalEncodedSize ?? 0; this._totalEncodedSize += end - start; @@ -182,6 +190,9 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { this._activeDecoder = null; if (pending) { this._pendingTransmissions.delete(pendingKey); + if (this._lastPendingKey === pendingKey) { + this._lastPendingKey = undefined; + } } this._aborted = true; return; @@ -203,6 +214,9 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { this._decodeError = true; if (pending) { this._pendingTransmissions.delete(pendingKey); + if (this._lastPendingKey === pendingKey) { + this._lastPendingKey = undefined; + } } } } @@ -229,7 +243,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { return this._handleDelete(cmd); } - const pendingKey = cmd.id ?? 0; + // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey. + const pendingKey = cmd.id ?? this._lastPendingKey ?? 0; const isMoreComing = cmd.more === 1; const pending = this._pendingTransmissions.get(pendingKey); @@ -246,11 +261,17 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { decodeError: this._decodeError }); } + this._lastPendingKey = pendingKey; this._activeDecoder = null; } return true; } + // Final chunk received — clear the last pending key + if (pending) { + this._lastPendingKey = undefined; + } + let decodeError = this._decodeError; let finalCmd = cmd; let decoder = this._activeDecoder; @@ -486,6 +507,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { pending.decoder.release(); } this._pendingTransmissions.clear(); + this._lastPendingKey = undefined; this._deleteAll(); break; case 'i': @@ -496,6 +518,9 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler { if (pending) { pending.decoder.release(); this._pendingTransmissions.delete(cmd.id); + if (this._lastPendingKey === cmd.id) { + this._lastPendingKey = undefined; + } } this._deleteById(cmd.id); } diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts index 7aa15a78..07964110 100644 --- a/addons/addon-image/test/KittyGraphics.test.ts +++ b/addons/addon-image/test/KittyGraphics.test.ts @@ -225,6 +225,107 @@ test.describe('Kitty Graphics Protocol', () => { (window as any).smallLimitAddon.dispose(); }); }); + + test('chunked a=T works when subsequent chunks omit i= (spec pattern)', 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=400,m=1;${part1}\x1b\\`); + await timeout(50); + strictEqual(await getImageStorageLength(), 0); + + await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`); + await timeout(100); + strictEqual(await getImageStorageLength(), 1); + }); + + test('chunked a=t works when subsequent chunks omit i= (spec pattern)', 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=401,m=1;${part1}\x1b\\`); + await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`); + await timeout(100); + + strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(401)`), true); + }); + + test('chunked data without i= on subsequent chunks is assembled correctly', 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=402,m=1;${part1}\x1b\\`); + await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`); + await timeout(100); + + const storedData = await ctx.page.evaluate(async () => { + const blob = (window as any).imageAddon._handlers.get('kitty').images.get(402).data; + const buffer = await blob.arrayBuffer(); + return Array.from(new Uint8Array(buffer)); + }); + deepStrictEqual(storedData, KITTY_BLACK_1X1_BYTES); + }); + + test('three-chunk transfer with only m= on middle and last chunks', async () => { + const third = Math.floor(KITTY_BLACK_1X1_BASE64.length / 3); + const part1 = KITTY_BLACK_1X1_BASE64.substring(0, third); + const part2End = third + Math.floor((KITTY_BLACK_1X1_BASE64.length - third) / 2); + const alignedPart2End = part2End - (part2End - third) % 4 + third; + const part2 = KITTY_BLACK_1X1_BASE64.substring(third, alignedPart2End); + const part3 = KITTY_BLACK_1X1_BASE64.substring(alignedPart2End); + + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=403,m=1;${part1}\x1b\\`); + await ctx.proxy.write(`\x1b_Gm=1;${part2}\x1b\\`); + await ctx.proxy.write(`\x1b_Gm=0;${part3}\x1b\\`); + await timeout(100); + + const storedData = await ctx.page.evaluate(async () => { + const blob = (window as any).imageAddon._handlers.get('kitty').images.get(403).data; + const buffer = await blob.arrayBuffer(); + return Array.from(new Uint8Array(buffer)); + }); + deepStrictEqual(storedData, KITTY_BLACK_1X1_BYTES); + }); + + test('chunked a=T without i= on any chunk works (no response)', 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,m=1;${part1}\x1b\\`); + await timeout(50); + strictEqual(await getImageStorageLength(), 0); + + await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`); + await timeout(100); + strictEqual(await getImageStorageLength(), 1); + }); + + test('chunked transfer responds OK on final chunk when i= on first only', async () => { + await ctx.page.evaluate(() => { + (window as any).kittyResponse = ''; + (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; }); + }); + + 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=405,m=1;${part1}\x1b\\`); + await timeout(50); + + let response: string = await ctx.page.evaluate('window.kittyResponse'); + strictEqual(response, ''); + + await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`); + await timeout(100); + + response = await ctx.page.evaluate('window.kittyResponse'); + strictEqual(response, '\x1b_Gi=405;OK\x1b\\'); + }); }); test.describe('Delete commands', () => { @@ -749,8 +850,6 @@ test.describe('Kitty Graphics Protocol', () => { strictEqual(response, '\x1b_Gi=204;OK\x1b\\'); }); - // TODO: When file-based transmission mediums (t=f, t=t, t=s) are supported, - // update these tests to verify successful transmission instead of EINVAL. test('transmit rejects t=f with id (EINVAL response)', async () => { await ctx.page.evaluate(() => { (window as any).kittyResponse = ''; @@ -803,8 +902,6 @@ test.describe('Kitty Graphics Protocol', () => { strictEqual(response, ''); }); - // TODO: When file-based transmission mediums (t=f, t=t, t=s) are supported, - // update these tests to verify successful transmit+display instead of EINVAL. test('transmit+display rejects t=f with id (EINVAL response)', async () => { await ctx.page.evaluate(() => { (window as any).kittyResponse = '';