From 121c9790801c29718a3b29c0088bdab3a711ecc6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 26 Jul 2026 14:33:04 +0000 Subject: [PATCH] fix(image): discard stale async decode results Co-authored-by: joaner --- .../panels/Image/core/ImageRender.worker.ts | 75 ++++++++++--------- .../panels/Image/core/asyncEpoch.test.ts | 18 +++++ src/features/panels/Image/core/asyncEpoch.ts | 19 +++++ 3 files changed, 78 insertions(+), 34 deletions(-) create mode 100644 src/features/panels/Image/core/asyncEpoch.test.ts create mode 100644 src/features/panels/Image/core/asyncEpoch.ts diff --git a/src/features/panels/Image/core/ImageRender.worker.ts b/src/features/panels/Image/core/ImageRender.worker.ts index 2b5d8b0..b57483f 100644 --- a/src/features/panels/Image/core/ImageRender.worker.ts +++ b/src/features/panels/Image/core/ImageRender.worker.ts @@ -36,6 +36,7 @@ import { normalizeCompressedMime, type ImageSurfaceStatus, } from './imageTypes'; +import { discardStaleAsyncResult } from './asyncEpoch'; import type { ImageRenderOptions, ImageRenderMetrics, @@ -583,6 +584,9 @@ class ImageRenderWorkerRuntime { // ROS compressedDepth: PNG → 16UC1/32FC1, then same colormap path as RawImage. if (isCompressedDepthFormat(frame.format)) { const decoded = await decodeCompressedDepth(bytes, frame.format); + if (epoch !== this.#epoch) { + return; + } this.#renderRawFrame({ receiveTime: frame.receiveTime, encoding: decoded.encoding, @@ -600,6 +604,9 @@ class ImageRenderWorkerRuntime { if (kind === 'h264') { await this.#decoder.submitFrame(frame, bytes, sortKey); + if (epoch !== this.#epoch) { + return; + } this.#updateH264Pressure(); this.#emitMetricsIfDue(); return; @@ -611,36 +618,38 @@ class ImageRenderWorkerRuntime { `Compressed image decode timed out: ${frame.format}`, closeCanvasImageSource, ); - let sourceToClose: ImageBitmap | VideoFrame | null = imageSource; - try { - const width = 'displayWidth' in imageSource ? imageSource.displayWidth : imageSource.width; - const height = 'displayHeight' in imageSource ? imageSource.displayHeight : imageSource.height; - const bitmap = isImageBitmap(imageSource) - ? imageSource - : await withTimeout( - createImageBitmap(imageSource as ImageBitmapSource), - OUTPUT_TIMEOUT_MS, - `Compressed image bitmap creation timed out: ${frame.format}`, - closeImageBitmap, - ); - if (isImageBitmap(imageSource)) { - sourceToClose = null; + if (discardStaleAsyncResult(imageSource, epoch, this.#epoch)) { + return; + } + const width = 'displayWidth' in imageSource ? imageSource.displayWidth : imageSource.width; + const height = 'displayHeight' in imageSource ? imageSource.displayHeight : imageSource.height; + let bitmap: ImageBitmap; + if (isImageBitmap(imageSource)) { + bitmap = imageSource; + } else { + try { + bitmap = await withTimeout( + createImageBitmap(imageSource as ImageBitmapSource), + OUTPUT_TIMEOUT_MS, + `Compressed image bitmap creation timed out: ${frame.format}`, + closeImageBitmap, + ); + } finally { + closeCanvasImageSource(imageSource); + } + if (discardStaleAsyncResult(bitmap, epoch, this.#epoch)) { + return; } - closeCanvasImageSourceIfNeeded(sourceToClose); - sourceToClose = null; - this.#storeBitmap(bitmap, width, height, frame.format, frame.receiveTime); - this.#drawBitmap(bitmap, width, height); - this.#emitStatus({ - phase: 'ready', - width, - height, - encoding: frame.format, - receiveTime: frame.receiveTime, - }); - } catch (err) { - closeCanvasImageSourceIfNeeded(sourceToClose); - throw err; } + this.#storeBitmap(bitmap, width, height, frame.format, frame.receiveTime); + this.#drawBitmap(bitmap, width, height); + this.#emitStatus({ + phase: 'ready', + width, + height, + encoding: frame.format, + receiveTime: frame.receiveTime, + }); return; } @@ -736,6 +745,7 @@ class ImageRenderWorkerRuntime { return; } const { videoFrame, sourceFrame } = pending; + const epoch = this.#epoch; const now = performance.now(); try { const frameTimeNs = timeToKey(sourceFrame.receiveTime); @@ -763,6 +773,9 @@ class ImageRenderWorkerRuntime { if (this.#h264Pressure.mode === 'normal' && now - this.#lastH264BitmapAt >= 500) { try { const bitmap = await createImageBitmap(videoFrame); + if (discardStaleAsyncResult(bitmap, epoch, this.#epoch)) { + return; + } this.#storeBitmap( bitmap, width, @@ -1214,12 +1227,6 @@ function closeCanvasImageSource(source: ImageBitmap | VideoFrame): void { source.close(); } -function closeCanvasImageSourceIfNeeded(source: ImageBitmap | VideoFrame | null): void { - if (source) { - closeCanvasImageSource(source); - } -} - function closeImageBitmap(bitmap: ImageBitmap): void { bitmap.close(); } diff --git a/src/features/panels/Image/core/asyncEpoch.test.ts b/src/features/panels/Image/core/asyncEpoch.test.ts new file mode 100644 index 0000000..02059d6 --- /dev/null +++ b/src/features/panels/Image/core/asyncEpoch.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest'; +import { discardStaleAsyncResult } from './asyncEpoch'; + +describe('discardStaleAsyncResult', () => { + it('retains a result from the current epoch', () => { + const result = { close: vi.fn() }; + + expect(discardStaleAsyncResult(result, 3, 3)).toBe(false); + expect(result.close).not.toHaveBeenCalled(); + }); + + it('disposes a result from an invalidated epoch', () => { + const result = { close: vi.fn() }; + + expect(discardStaleAsyncResult(result, 3, 4)).toBe(true); + expect(result.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/panels/Image/core/asyncEpoch.ts b/src/features/panels/Image/core/asyncEpoch.ts new file mode 100644 index 0000000..85825c6 --- /dev/null +++ b/src/features/panels/Image/core/asyncEpoch.ts @@ -0,0 +1,19 @@ +export interface ClosableAsyncResult { + close(): void; +} + +/** + * Disposes an async result that completed after its owning runtime was reset. + * Returns true when the caller must stop processing the stale result. + */ +export function discardStaleAsyncResult( + result: T, + startedEpoch: number, + currentEpoch: number, +): boolean { + if (startedEpoch === currentEpoch) { + return false; + } + result.close(); + return true; +}