From e6fbda8a7caf560ce204edbfca61a0d4061069c0 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 20 Jun 2026 21:50:17 -0700 Subject: [PATCH] Upload Media: Retry transient wasm-vips worker decode failures In the cross-origin-isolated, multi-threaded wasm-vips worker, image decode calls (resize, transcode, rotate) can intermittently fail when the worker receives a short or garbled source buffer under heavy main-thread contention. libheif aborts with a "bad seek" / "Bitstream not supported" error that surfaces as a generic IMAGE_TRANSCODING_ERROR and cancels the upload, even though the same bytes decode correctly on a later attempt (and in Node and in manual Chrome). Wrap the four File-based worker calls in a bounded retry helper that re-reads the source buffer and re-issues the call up to VIPS_MAX_ATTEMPTS times with a short backoff, short-circuiting on an aborted signal. Mechanism-agnostic: whatever yields the transient short buffer, a fresh re-read on a later task recovers; genuinely undecodable images still fail after the final attempt. Add unit tests covering retry-then-succeed, give-up-after-max, and stop-on-abort. Issue: https://github.com/WordPress/gutenberg/issues/79377 --- packages/upload-media/CHANGELOG.md | 1 + packages/upload-media/src/store/test/vips.ts | 68 ++++++++++++++ .../upload-media/src/store/utils/index.ts | 94 ++++++++++++++----- 3 files changed, 138 insertions(+), 25 deletions(-) diff --git a/packages/upload-media/CHANGELOG.md b/packages/upload-media/CHANGELOG.md index f7ca14b37e0d83..9b08b59592482e 100644 --- a/packages/upload-media/CHANGELOG.md +++ b/packages/upload-media/CHANGELOG.md @@ -11,6 +11,7 @@ ### Bug Fix - `uploadItem` no longer dispatches `finishOperation` twice when both `onFileChange` and `onSuccess` fire for the same attachment ([#74917](https://github.com/WordPress/gutenberg/pull/74917)). +- Retry wasm-vips worker decode operations (resize, transcode, rotate) a small, bounded number of times before failing. The cross-origin-isolated, multi-threaded worker can intermittently receive a short/garbled source buffer and abort with a `bad seek` / `Bitstream not supported` error; re-reading the source and re-issuing the call recovers from this transient condition ([#79378](https://github.com/WordPress/gutenberg/pull/79378)). ## 0.33.1 (2026-06-16) diff --git a/packages/upload-media/src/store/test/vips.ts b/packages/upload-media/src/store/test/vips.ts index 3e321e9071ee1b..86f47ebb3d7aff 100644 --- a/packages/upload-media/src/store/test/vips.ts +++ b/packages/upload-media/src/store/test/vips.ts @@ -347,4 +347,72 @@ describe( 'vips utilities', () => { expect( result ).toBe( false ); } ); } ); + + describe( 'transient worker failure retry', () => { + it( 'retries a transient decode failure and resolves', async () => { + mockResizeImage + .mockRejectedValueOnce( + new Error( + 'unable to call thumbnail_buffer\nsource: bad seek to 1109' + ) + ) + .mockResolvedValueOnce( { + buffer: new ArrayBuffer( 10 ), + width: 150, + height: 150, + originalWidth: 300, + originalHeight: 300, + } ); + + const resize: ImageSizeCrop = { width: 150, height: 150 }; + const result = await vipsResizeImage( + 'item-1', + jpegFile, + resize, + false, + true + ); + + expect( result ).toBeInstanceOf( ImageFile ); + expect( result.width ).toBe( 150 ); + expect( mockResizeImage ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'gives up and rejects after the maximum number of attempts', async () => { + mockConvertImageFormat.mockRejectedValue( + new Error( 'Bitstream not supported by this decoder' ) + ); + + await expect( + vipsConvertImageFormat( 'item-1', jpegFile, 'image/avif', 0.8 ) + ).rejects.toThrow( 'Bitstream not supported' ); + + // Initial attempt plus retries (VIPS_MAX_ATTEMPTS = 3). + expect( mockConvertImageFormat ).toHaveBeenCalledTimes( 3 ); + } ); + + it( 'stops retrying once the abort signal is aborted', async () => { + const controller = new AbortController(); + mockResizeImage.mockImplementation( () => { + // Abort mid-flight, then fail: the next loop iteration should + // bail with "Operation aborted" rather than retry. + controller.abort(); + return Promise.reject( new Error( 'bad seek' ) ); + } ); + + const resize: ImageSizeCrop = { width: 150, height: 150 }; + await expect( + vipsResizeImage( + 'item-1', + jpegFile, + resize, + false, + true, + controller.signal + ) + ).rejects.toThrow( 'Operation aborted' ); + + expect( mockResizeImage ).toHaveBeenCalledTimes( 1 ); + } ); + } ); } ); diff --git a/packages/upload-media/src/store/utils/index.ts b/packages/upload-media/src/store/utils/index.ts index 841bda99d6482f..d205c84e5e840d 100644 --- a/packages/upload-media/src/store/utils/index.ts +++ b/packages/upload-media/src/store/utils/index.ts @@ -41,6 +41,62 @@ function loadVipsModule(): Promise< typeof import('@wordpress/vips/worker') > { return vipsModulePromise; } +/** + * Maximum number of attempts (initial call plus retries) for a wasm-vips + * worker call. + * + * Decoding in the cross-origin-isolated, multi-threaded wasm-vips worker can + * intermittently fail when the worker receives a short or garbled source + * buffer under heavy main-thread contention: libheif aborts with a "bad seek" + * / "Bitstream not supported" error that surfaces as a generic processing + * failure. The condition is transient - re-reading the source and re-issuing + * the call on a later task recovers - so a small, bounded number of retries + * makes the pipeline resilient without masking genuinely undecodable images, + * which still fail after the final attempt. + * + * See https://github.com/WordPress/gutenberg/issues/79377. + */ +const VIPS_MAX_ATTEMPTS = 3; + +/** + * Runs a wasm-vips worker call with a freshly read source buffer, retrying a + * bounded number of times if the worker throws. + * + * The source bytes are re-read from `file` on every attempt so a transient + * short read or transfer is never reused. An aborted signal short-circuits the + * loop so cancellation stays responsive. + * + * @param file Source file; its bytes are re-read on each attempt. + * @param run Callback performing the worker call with a fresh buffer. + * @param signal Optional abort signal; when aborted, stops retrying. + * @return The worker call result. + */ +async function withVipsRetry< T >( + file: File, + run: ( buffer: ArrayBuffer ) => Promise< T >, + signal?: AbortSignal +): Promise< T > { + let lastError: unknown; + for ( let attempt = 1; attempt <= VIPS_MAX_ATTEMPTS; attempt++ ) { + if ( signal?.aborted ) { + throw new Error( 'Operation aborted' ); + } + try { + return await run( await file.arrayBuffer() ); + } catch ( error ) { + lastError = error; + if ( attempt < VIPS_MAX_ATTEMPTS && ! signal?.aborted ) { + // Yield to a later task so the next read and worker transfer + // land outside the contended window. + await new Promise( ( resolve ) => { + setTimeout( resolve, 50 * attempt ); + } ); + } + } + } + throw lastError; +} + /** * Converts an image to a different format using vips in a web worker. * @@ -65,13 +121,8 @@ export async function vipsConvertImageFormat( ) { const { vipsConvertImageFormat: convertImageFormat } = await loadVipsModule(); - const buffer = await convertImageFormat( - id, - await file.arrayBuffer(), - file.type, - type, - quality, - interlaced + const buffer = await withVipsRetry( file, ( bytes ) => + convertImageFormat( id, bytes, file.type, type, quality, interlaced ) ); const ext = type.split( '/' )[ 1 ]; const fileName = `${ getFileBasename( file.name ) }.${ ext }`; @@ -96,12 +147,8 @@ export async function vipsCompressImage( interlaced?: boolean ) { const { vipsCompressImage: compressImage } = await loadVipsModule(); - const buffer = await compressImage( - id, - await file.arrayBuffer(), - file.type, - quality, - interlaced + const buffer = await withVipsRetry( file, ( bytes ) => + compressImage( id, bytes, file.type, quality, interlaced ) ); return new File( [ new Blob( [ buffer as ArrayBuffer ], { type: file.type } ) ], @@ -169,13 +216,11 @@ export async function vipsResizeImage( const { vipsResizeImage: resizeImage } = await loadVipsModule(); const { buffer, width, height, originalWidth, originalHeight } = - await resizeImage( - id, - await file.arrayBuffer(), - file.type, - resize, - smartCrop, - quality + await withVipsRetry( + file, + ( bytes ) => + resizeImage( id, bytes, file.type, resize, smartCrop, quality ), + signal ); let fileName = file.name; @@ -242,11 +287,10 @@ export async function vipsRotateImage( } const { vipsRotateImage: rotateImage } = await loadVipsModule(); - const { buffer, width, height } = await rotateImage( - id, - await file.arrayBuffer(), - file.type, - orientation + const { buffer, width, height } = await withVipsRetry( + file, + ( bytes ) => rotateImage( id, bytes, file.type, orientation ), + signal ); // Add '-rotated' suffix to filename, matching WordPress core behavior.