Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/upload-media/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
68 changes: 68 additions & 0 deletions packages/upload-media/src/store/test/vips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
} );
} );
} );
94 changes: 69 additions & 25 deletions packages/upload-media/src/store/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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 }`;
Expand All @@ -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 } ) ],
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Loading