Skip to content
12 changes: 9 additions & 3 deletions packages/upload-media/src/store/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,20 @@ export function cancelItem( id: QueueItemId, error: Error, silent = false ) {

item.abortController?.abort();

// Cancel any ongoing vips operations for this item.
await vipsCancelOperations( id );
/*
* Cancel any ongoing vips operations for this item. This is
* best-effort cleanup: when the worker has crashed, the cancel call
* itself rejects, and that must not abort the cancellation flow —
* onError, item removal, and worker teardown below still need to run.
*/
await vipsCancelOperations( id ).catch( () => {} );

/*
* Cancel any ongoing GIF-to-video conversion for this item so a
* cancelled upload does not leave the encoder running off-thread.
* Best-effort for the same reason as above.
*/
await cancelGifToVideoOperations( id );
await cancelGifToVideoOperations( id ).catch( () => {} );

if ( ! silent ) {
const { onError } = item;
Expand Down
58 changes: 58 additions & 0 deletions packages/upload-media/src/store/test/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,64 @@ describe( 'actions', () => {
expect( onError ).not.toHaveBeenCalled();
} );

it( 'still calls onError and removes the item when vips cancellation rejects', async () => {
/*
* When the vips worker has crashed, any call on its remote —
* including the cancellation itself — rejects. That rejection
* must not abort cancelItem, or the item would be stuck in the
* queue forever with no error surfaced.
*/
( vipsCancelOperations as jest.Mock ).mockImplementationOnce( () =>
Promise.reject( new Error( 'Worker error: crashed' ) )
);

const onError = jest.fn();
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
onError,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];

await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'Test error' ) );

expect( onError ).toHaveBeenCalledWith(
expect.objectContaining( { message: 'Test error' } )
);
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
} );

it( 'still calls onError and removes the item when GIF-to-video cancellation rejects', async () => {
( cancelGifToVideoOperations as jest.Mock ).mockImplementationOnce(
() => Promise.reject( new Error( 'Worker error: crashed' ) )
);

const onError = jest.fn();
unlock( registry.dispatch( uploadStore ) ).addItem( {
file: jpegFile,
onError,
} );
const item = unlock(
registry.select( uploadStore )
).getAllItems()[ 0 ];

await registry
.dispatch( uploadStore )
.cancelItem( item.id, new Error( 'Test error' ) );

expect( onError ).toHaveBeenCalledWith(
expect.objectContaining( { message: 'Test error' } )
);
expect(
unlock( registry.select( uploadStore ) ).getAllItems()
).toHaveLength( 0 );
} );

describe( 'parent cancellation when child sideload fails', () => {
// Helpers used by every scenario below. Set up a parent that
// has finished its primary upload (so it has an attachment.id),
Expand Down
4 changes: 4 additions & 0 deletions packages/worker-threads/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Bug Fixes

- Reject pending RPC calls when the worker fails (`error` / `messageerror` events) or is terminated via `terminate()`, instead of leaving the promises pending forever ([#79955](https://github.com/WordPress/gutenberg/pull/79955)).

## 1.11.0 (2026-07-14)

## 1.10.0 (2026-07-01)
Expand Down
128 changes: 114 additions & 14 deletions packages/worker-threads/src/main-thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,43 @@ class WorkerInjectAdapter implements Adapter {
}

/**
* WeakMap to store workers for each remote proxy.
* Internal state tracked for each wrapped worker.
*
* comctx only settles a call promise when a response message arrives from
* the worker, so a worker that crashes, fails to initialize, or is
* terminated mid-call would otherwise leave its call promises pending
* forever. Pending rejecters are tracked here so those promises can be
* rejected when the worker fails or is terminated.
*/
interface WorkerState {
worker: Worker;
/** Rejecters for calls that have not settled yet. */
pendingRejects: Set< ( reason: Error ) => void >;
/** Set once the worker has failed or been terminated. */
failure?: Error;
/** Removes the worker error event listeners. */
removeListeners: () => void;
}

/**
* WeakMap to store the state for each remote proxy.
*/
const remoteWorkers = new WeakMap< object, Worker >();
const remoteStates = new WeakMap< object, WorkerState >();

/**
* Rejects all pending calls and marks the worker as failed so that
* subsequent calls reject immediately instead of hanging.
*
* @param state Worker state.
* @param error Rejection reason.
*/
function failWorker( state: WorkerState, error: Error ): void {
state.failure ??= error;
for ( const reject of state.pendingRejects ) {
reject( state.failure );
}
state.pendingRejects.clear();
}

/**
* Wraps a Worker to provide a type-safe RPC interface.
Expand All @@ -46,6 +80,10 @@ const remoteWorkers = new WeakMap< object, Worker >();
* were local async functions. Each method call is automatically serialized,
* sent to the worker, and the result is returned as a Promise.
*
* If the worker fails (its `error` or `messageerror` event fires) or is
* terminated via terminate(), all pending calls are rejected and any
* subsequent calls reject immediately.
*
* @example
* ```typescript
* const worker = new Worker(new URL('./worker.js', import.meta.url));
Expand All @@ -70,10 +108,40 @@ export function wrap< T extends object >( worker: Worker ): Remote< T > {
// Create the proxy using the injector.
const comctxRemote = inject( new WorkerInjectAdapter( worker ) );

// Store the worker reference.
remoteWorkers.set( comctxRemote as object, worker );
const onError = ( event: Event ) => {
Comment thread
adamsilverstein marked this conversation as resolved.
const message = ( event as ErrorEvent ).message;
failWorker(
state,
new Error(
message
? `Worker error: ${ message }`
: 'Worker error: the worker crashed or failed to load.'
)
);
};
const onMessageError = () => {
failWorker(
state,
new Error( 'Worker error: a message could not be deserialized.' )
);
};

// Detect worker failures (crash, failed script load/instantiation, or
// undeserializable messages) so pending calls do not hang forever.
worker.addEventListener( 'error', onError );
worker.addEventListener( 'messageerror', onMessageError );

const state: WorkerState = {
worker,
pendingRejects: new Set(),
removeListeners: () => {
worker.removeEventListener( 'error', onError );
worker.removeEventListener( 'messageerror', onMessageError );
},
};

// Create a wrapper proxy that adds WORKER_SYMBOL support.
// Create a wrapper proxy that adds WORKER_SYMBOL support and tracks
// pending calls so they can be rejected on worker failure/termination.
const proxy = new Proxy( comctxRemote as Remote< T > & WithWorker, {
get( target, prop: string | symbol ) {
// Return the worker for the WORKER_SYMBOL.
Expand All @@ -82,12 +150,40 @@ export function wrap< T extends object >( worker: Worker ): Remote< T > {
}

// Delegate all other property access to the comctx remote.
return Reflect.get( target, prop );
const value = Reflect.get( target, prop );

if ( typeof value !== 'function' ) {
return value;
}

return ( ...args: unknown[] ) => {
if ( state.failure ) {
return Promise.reject( state.failure );
}

return new Promise( ( resolve, reject ) => {
state.pendingRejects.add( reject );
// Wrap the call so a synchronous throw or a non-thenable
// return value still removes the rejecter from the set.
Promise.resolve()
.then( () => value( ...args ) )
.then(
( result ) => {
state.pendingRejects.delete( reject );
resolve( result );
},
( error ) => {
state.pendingRejects.delete( reject );
reject( error );
}
);
} );
};
},
} );

// Store the worker for the proxy as well.
remoteWorkers.set( proxy, worker );
// Store the state for the proxy.
remoteStates.set( proxy, state );

return proxy;
}
Expand All @@ -108,16 +204,20 @@ export function wrap< T extends object >( worker: Worker ): Remote< T > {
* @param remote - The wrapped worker proxy returned by wrap().
*/
export function terminate( remote: Remote< unknown > ): void {
// Get the worker from the proxy.
const worker = ( remote as unknown as WithWorker )[ WORKER_SYMBOL ];
const state = remoteStates.get( remote as object );

if ( ! worker ) {
if ( ! state ) {
return;
}

// Clean up the worker reference.
remoteWorkers.delete( remote as object );
// Reject pending calls; they would otherwise never settle.
failWorker( state, new Error( 'Worker was terminated.' ) );

state.removeListeners();

// Clean up the state reference.
remoteStates.delete( remote as object );

// Terminate the worker.
worker.terminate();
state.worker.terminate();
}
Loading
Loading