Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a321f00
Fix image upload crashes and add media processing benchmarks
adamsilverstein Mar 19, 2026
ae6cdf5
Remove HEIC defensive guard from vips operations
adamsilverstein Mar 20, 2026
1e916a5
Disable vips operation cache to prevent OOM crashes and fix perf test…
adamsilverstein Mar 23, 2026
b5fca7d
Add Cache mock to wasm-vips test fixture
adamsilverstein Mar 25, 2026
e35fd52
Fix image upload crashes and add media processing benchmarks
adamsilverstein Mar 19, 2026
1568e7f
Remove HEIC defensive guard from vips operations
adamsilverstein Mar 20, 2026
7b675be
Disable vips operation cache to prevent OOM crashes and fix perf test…
adamsilverstein Mar 23, 2026
856327e
Add Cache mock to wasm-vips test fixture
adamsilverstein Mar 25, 2026
0ae4690
Fix parent item stuck in spinner when child sideload fails
adamsilverstein Apr 2, 2026
91e960e
Kick pending queue items when a cancelled item frees a concurrency slot
adamsilverstein Apr 2, 2026
96bc77a
Send generate_sub_sizes: false for client-side processed images
adamsilverstein Apr 2, 2026
b1b37a7
Cancel parent upload when all child sideloads fail
adamsilverstein Apr 2, 2026
354dbd9
Improve error message when image processing fails
adamsilverstein Apr 2, 2026
6e16c0e
Match server-side error message for unsupported image types
adamsilverstein Apr 2, 2026
23653fd
Suppress console errors for child sideload failures
adamsilverstein Apr 2, 2026
cc6a581
Add 1024x768 PNG test image for media upload benchmarks
adamsilverstein Apr 2, 2026
930eee6
Merge branch 'trunk' into fix/image-upload-crash
adamsilverstein Apr 23, 2026
619070e
Merge remote-tracking branch 'origin/fix/image-upload-crash' into fix…
adamsilverstein Apr 23, 2026
04488db
Wrap user-facing image-upload error in __()
adamsilverstein Apr 28, 2026
9339469
Delete orphaned attachment when client-side sub-size processing fails
adamsilverstein Apr 28, 2026
a29afa8
Differentiate parent-cancel error message by underlying cause
adamsilverstein Apr 28, 2026
568b15b
Preserve parent attachment when partial sub-sizes succeeded
adamsilverstein Apr 28, 2026
c186044
Reuse single editor lifecycle in media upload perf tests
adamsilverstein Apr 29, 2026
9441a0f
Fix flaky cleanup in media upload perf test
adamsilverstein Apr 29, 2026
e119959
Surface actionable error when parent vips processing fails
adamsilverstein May 6, 2026
89a8c5b
Track failed vips ops toward worker recycle budget
adamsilverstein May 6, 2026
b8aee5c
Merge trunk into fix/image-upload-crash
adamsilverstein May 18, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function useMediaUploadSettings( settings = {} ) {
mediaUpload: settings.mediaUpload,
mediaSideload: settings.mediaSideload,
mediaFinalize: settings.mediaFinalize,
mediaDelete: settings.mediaDelete,
maxUploadFileSize: settings.maxUploadFileSize,
allowedMimeTypes: settings.allowedMimeTypes,
allImageSizes: settings.allImageSizes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { mediaUpload } from '../../utils';
import mediaUploadOnSuccess from '../../utils/media-upload/on-success';
import { default as mediaSideload } from '../../utils/media-sideload';
import { default as mediaFinalize } from '../../utils/media-finalize';
import { default as mediaDelete } from '../../utils/media-delete';
import { store as editorStore } from '../../store';
import { unlock } from '../../lock-unlock';
import { useGlobalStylesContext } from '../global-styles-provider';
Expand Down Expand Up @@ -354,6 +355,7 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) {
: undefined,
mediaSideload: hasUploadPermissions ? mediaSideload : undefined,
mediaFinalize: hasUploadPermissions ? mediaFinalize : undefined,
mediaDelete: hasUploadPermissions ? mediaDelete : undefined,
__experimentalBlockPatterns: blockPatterns,
[ selectBlockPatternsKey ]: ( select ) => {
const { hasFinishedResolution, getBlockPatternsForPostType } =
Expand Down
11 changes: 11 additions & 0 deletions packages/editor/src/utils/media-delete/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* WordPress dependencies
*/
import apiFetch from '@wordpress/api-fetch';

export default async function mediaDelete( id ) {
await apiFetch( {
path: `/wp/v2/media/${ id }?force=true`,
method: 'DELETE',
} );
}
110 changes: 105 additions & 5 deletions packages/upload-media/src/store/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid';
* WordPress dependencies
*/
import type { createRegistry } from '@wordpress/data';
import { __ } from '@wordpress/i18n';

type WPDataRegistry = ReturnType< typeof createRegistry >;

Expand All @@ -24,14 +25,15 @@ import type {
RetryItemAction,
State,
} from './types';
import { Type } from './types';
import { OperationType, Type } from './types';
import type {
addItem,
processItem,
removeItem,
revokeBlobUrls,
} from './private-actions';
import { vipsCancelOperations } from './utils';
import { maybeRecycleVipsWorker, vipsCancelOperations } from './utils';
import { UploadError } from '../upload-error';
import { validateMimeType } from '../validate-mime-type';
import { validateMimeTypeForUser } from '../validate-mime-type-for-user';
import { validateFileSize } from '../validate-file-size';
Expand Down Expand Up @@ -166,13 +168,17 @@ export function cancelItem( id: QueueItemId, error: Error, silent = false ) {
if ( ! silent ) {
const { onError } = item;
onError?.( error ?? new Error( 'Upload cancelled' ) );
if ( ! onError && error ) {
// TODO: Find better way to surface errors with sideloads etc.
if ( ! onError && error && ! item.parentId ) {
// Log errors for top-level items without an onError handler.
// Child sideload errors are suppressed here because the
// parent will be notified and surface the error to the user.
// eslint-disable-next-line no-console -- Deliberately log errors here.
console.error( 'Upload cancelled', error );
}
}

const { currentOperation, parentId, batchId } = item;

dispatch< CancelAction >( {
type: Type.Cancel,
id,
Expand All @@ -181,8 +187,102 @@ export function cancelItem( id: QueueItemId, error: Error, silent = false ) {
dispatch.removeItem( id );
dispatch.revokeBlobUrls( id );

// A concurrency slot just freed up. Kick any items that were
// waiting in the queue, mirroring finishOperation's behavior.
if (
currentOperation === OperationType.ResizeCrop ||
currentOperation === OperationType.Rotate
) {
for ( const pending of select.getPendingImageProcessing() ) {
dispatch.processItem( pending.id );
}
}
if ( currentOperation === OperationType.Upload ) {
for ( const pending of select.getPendingUploads() ) {
dispatch.processItem( pending.id );
}
}

// Failed vips ops also leak WASM memory, so count them toward the
// recycle budget. Without this, a long burst of failures (e.g. a
// gallery of unsupported AVIFs) could grow memory unbounded.
if (
currentOperation === OperationType.ResizeCrop ||
currentOperation === OperationType.Rotate ||
currentOperation === OperationType.TranscodeImage
) {
maybeRecycleVipsWorker( select.getActiveImageProcessingCount() );
}

// If this was a child sideload item, handle the parent.
if ( parentId ) {
const parentItem = select.getItem( parentId );
if ( parentItem ) {
if ( select.hasPendingItemsByParentId( parentId ) ) {
// Other children remain — just notify the parent so
// it can re-check the Finalize gate.
if (
parentItem.operations &&
parentItem.operations.length > 0
) {
dispatch.processItem( parentId );
}
} else if (
parentItem.subSizes &&
parentItem.subSizes.length > 0
) {
// Partial success: at least one child sideload succeeded
// (its sub-size is already accumulated on the parent),
// but the last in-flight child failed. Keep the parent
// attachment and finalize with whichever sub-sizes did
// succeed — matching WordPress core's best-effort
// behavior when individual sub-size generations fail.
if (
parentItem.operations &&
parentItem.operations.length > 0
) {
dispatch.processItem( parentId );
}
} else {
// Total failure: no child succeeded. The parent file
// already uploaded — delete the orphaned attachment
// from the server so it doesn't appear in the media
// library.
const parentAttachmentId = parentItem.attachment?.id;
const { mediaDelete } = select.getSettings();
if ( parentAttachmentId && mediaDelete ) {
mediaDelete( parentAttachmentId ).catch( () => {
// Best-effort cleanup; surface nothing to the
// user if the delete itself fails.
} );
}
Comment on lines +247 to +258

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When could this happen?

Is this a good user experience? If the failures aren't permanent, would it be worth retrying the processing?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this fires: parent file uploaded successfully, but every child sub-size sideload failed (vips couldn't decode any size, or every sideload network call failed). If even one sub-size succeeded we now take the partial-success branch instead and keep the parent attachment.

Is this a good UX: the user still gets an error notice; deleting the orphan keeps the media library clean. The silent .catch() on mediaDelete is best-effort — if it fails the orphan stays but the error notice has already surfaced, so we don't pile a second notice on top.

Retries: agreed retry would be the better UX, but it's intentionally out of scope here. Producer-side auto-retry with eventual-failure is being built as a follow-up in #76765; this PR's job is to stop the spinner from hanging forever when retries don't yet exist.


// Cancel the parent too so the block resets rather
// than showing a partial upload. Propagate the
// underlying error's code and message — vips
// processing failures already carry an actionable
// hint at their source; network/server failures
// surface their real cause.
dispatch.cancelItem(
parentId,
new UploadError( {
code:
( error instanceof UploadError &&
error.code ) ||
'UPLOAD_ERROR',
message:
error?.message ||
__( 'The image could not be uploaded.' ),
file: parentItem.file,
cause: error instanceof Error ? error : undefined,
} )
);
}
}
}

// All items of this batch were cancelled or finished.
if ( item.batchId && select.isBatchUploaded( item.batchId ) ) {
if ( batchId && select.isBatchUploaded( batchId ) ) {
item.onBatchSuccess?.();
}
};
Expand Down
36 changes: 31 additions & 5 deletions packages/upload-media/src/store/private-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { v4 as uuidv4 } from 'uuid';
*/
import { createBlobURL, isBlobURL, revokeBlobURL } from '@wordpress/blob';
import type { createRegistry } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
type WPDataRegistry = ReturnType< typeof createRegistry >;

/**
Expand All @@ -25,6 +26,7 @@ import {
vipsConvertImageFormat,
vipsHasTransparency,
terminateVipsWorker,
maybeRecycleVipsWorker,
} from './utils';
import type {
AccumulateSubSizeAction,
Expand Down Expand Up @@ -562,6 +564,17 @@ export function finishOperation(
dispatch.processItem( pendingItem.id );
}
}

// Track vips operations across success and failure paths so a
// burst of failures can't bypass the recycle budget; the cancel
// path calls the same helper.
if (
previousOperation === OperationType.ResizeCrop ||
previousOperation === OperationType.Rotate ||
previousOperation === OperationType.TranscodeImage
) {
maybeRecycleVipsWorker( select.getActiveImageProcessingCount() );
}
};
}

Expand Down Expand Up @@ -725,9 +738,11 @@ export function prepareItem( id: QueueItemId ) {
operations,
} );

// If the file is not processed by vips, tell the server to
// generate sub-sizes since they won't be created client-side.
let updates: Partial< QueueItem > = {};
// Tell the server whether to generate sub-sizes.
// When vips handles processing client-side, set generate_sub_sizes
// to false so the server skips the image-type support check
// (allowing formats like AVIF that the server can't process).
let updates: Partial< QueueItem >;
if ( isHeic && heicJpeg ) {
// HEIC was converted to JPEG client-side. Upload the JPEG
// and let the server handle it normally (threshold scaling,
Expand All @@ -754,6 +769,13 @@ export function prepareItem( id: QueueItemId ) {
convert_format: true,
},
};
} else {
updates = {
additionalData: {
...item.additionalData,
generate_sub_sizes: false,
},
};
}

dispatch.finishOperation( id, updates );
Expand Down Expand Up @@ -896,7 +918,9 @@ export function resizeCropItem( id: QueueItemId, args?: ResizeCropItemArgs ) {
id,
new UploadError( {
code: 'IMAGE_TRANSCODING_ERROR',
message: 'File could not be uploaded',
message: __(
'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.'
),
file: item.file,
cause: error instanceof Error ? error : undefined,
} )
Expand Down Expand Up @@ -958,7 +982,9 @@ export function rotateItem( id: QueueItemId, args?: RotateItemArgs ) {
id,
new UploadError( {
code: 'IMAGE_ROTATION_ERROR',
message: 'Image could not be rotated',
message: __(
'The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading.'
),
file: item.file,
cause: error instanceof Error ? error : undefined,
} )
Expand Down
Loading
Loading