Skip to content

Add AbortSignal support for image processing cancellation - #4580

Open
niwinz wants to merge 2 commits into
lovell:mainfrom
niwinz:abort-signal-support
Open

Add AbortSignal support for image processing cancellation#4580
niwinz wants to merge 2 commits into
lovell:mainfrom
niwinz:abort-signal-support

Conversation

@niwinz

@niwinz niwinz commented Aug 3, 2026

Copy link
Copy Markdown

Summary

This PR adds support for the standard AbortSignal API to allow cancellation of in-flight image processing operations. The signal can be provided via the sharp() constructor options or output method options (toBuffer(), toFile()).

Closes #4579

Motivation

Image processing operations can be long-running, especially for:

  • Large images (multi-megapixel files)
  • Complex transformations (multiple resize, rotate, composite operations)
  • Slow encoders (AVIF, WebP with high quality settings)
  • Batch processing scenarios

Currently, once processing starts, there's no way to cancel it. This leads to:

  1. Wasted resources: CPU and memory are consumed even when the result is no longer needed
  2. Poor HTTP integration: Servers can't stop processing when clients disconnect
  3. Bad UX: Interactive applications can't respond to user cancellation requests
  4. Resource exhaustion: In high-load scenarios, abandoned operations accumulate

The existing timeout() option provides time-based cancellation, but doesn't support event-driven or user-initiated cancellation patterns that are standard in modern Node.js APIs like fetch(), fs.promises, and streams.

Implementation Approach

Cross-thread communication

The core challenge is that image processing happens on a libuv worker thread (via the C++ addon), while the abort signal is triggered on the main JavaScript thread. We need a way to communicate the cancellation across this boundary.

Solution: Use a SharedArrayBuffer(1) as a lock-free flag shared between threads.

Main Thread                    Worker Thread
    |                              |
    |-- abort() -----------------> |
    |   Atomics.store(flag, 1)     |
    |                              |-- progress callback
    |                              |-- check flag
    |                              |-- vips_image_set_kill()
    |                              |

Why SharedArrayBuffer?

  • True shared memory: Unlike regular buffers, SharedArrayBuffer provides genuine cross-thread visibility without synchronization primitives
  • No new thread-safe function plumbing: Avoids the complexity of napi_threadsafe_function for this simple signal
  • Zero overhead when unused: No buffer allocated, no callback registered when no signal is provided
  • Actually stops CPU work: Unlike approaches that just discard results, this stops the underlying libvips processing

Integration with existing timeout mechanism

The abort mechanism reuses the same libvips progress callback infrastructure as the existing timeout() feature:

  1. A progress callback is registered on the VipsImage when processing begins
  2. The callback checks the shared abort flag on each progress event
  3. When the flag is set (via Atomics.store() from the abort listener), it calls vips_image_set_kill() to stop processing
  4. libvips propagates the cancellation through the operation pipeline and returns an error

Why reuse the timeout mechanism?

  • Minimal C++ surface area: Leverages existing, tested infrastructure
  • Consistent behavior: Works the same way as timeout, just triggered differently
  • Proper cleanup: libvips handles resource cleanup when operations are killed
  • Battle-tested: The timeout mechanism has been in sharp for years and is well-tested

Error handling

When an operation is aborted, the error is converted to a standard AbortError:

{
  name: 'AbortError',
  code: 'ABORT_ERR',
  message: 'The operation was aborted'
}

This matches the standard DOMException pattern used by fetch() and other async APIs, making it easy for developers to handle cancellation consistently across their application.

Already-aborted signals

If a signal is already aborted when passed to sharp, the operation rejects immediately without invoking the native pipeline. This avoids unnecessary work and provides fast feedback.

API Design

The signal can be provided in two places:

1. Constructor options

sharp(input, { signal: controller.signal })
  .resize(800)
  .toBuffer();

Applies to all subsequent operations on this sharp instance. Convenient for simple cases where the entire pipeline should be cancellable.

2. Output method options

sharp(input)
  .resize(800)
  .toBuffer({ signal: controller.signal });

Applies to that specific output operation. Useful when you have multiple outputs from the same input and want different cancellation logic for each.

Why both?

  • Flexibility: Different use cases need different granularity
  • Backward compatibility: Existing code continues to work unchanged
  • Consistency: Matches patterns in other Node.js APIs

Testing

Comprehensive tests cover:

  • Abort during processing (slow blur operation)
  • Already-aborted signal (immediate rejection)
  • Normal completion without abort
  • Invalid signal type validation
  • Signal via constructor option
  • Signal via toFile()
  • Signal via stream output

All existing tests continue to pass with 100% code coverage maintained.

Backward compatibility

This change is fully backward compatible:

  • The signal option is optional
  • When not provided, behavior is identical to before
  • No changes to existing APIs or behavior
  • No performance impact when signal is not used

Use cases

1. HTTP request cancellation

app.get('/image/:id', (req, res) => {
  const controller = new AbortController();
  req.on('close', () => controller.abort());
  
  sharp(imagePath)
    .resize(800)
    .toBuffer({ signal: controller.signal })
    .then(buffer => res.send(buffer))
    .catch(err => {
      if (err.name === 'AbortError') return;
      res.status(500).send(err.message);
    });
});

2. User-initiated cancellation

const controller = new AbortController();

// Start processing
const promise = processImage(controller.signal);

// User clicks cancel
cancelButton.addEventListener('click', () => {
  controller.abort();
});

await promise.catch(err => {
  if (err.name === 'AbortError') {
    console.log('Processing cancelled by user');
  }
});

3. Batch processing with early termination

const controller = new AbortController();

for (const file of files) {
  try {
    await sharp(file)
      .resize(800)
      .toBuffer({ signal: controller.signal });
  } catch (err) {
    if (err.name === 'AbortError') {
      console.log('Batch processing cancelled');
      break;
    }
    throw err;
  }
}

4. Timeout with custom logic

const controller = new AbortController();

// Custom timeout logic
const timeoutId = setTimeout(() => {
  controller.abort();
}, 5000);

try {
  await sharp(input)
    .complexOperation()
    .toBuffer({ signal: controller.signal });
} finally {
  clearTimeout(timeoutId);
}

Notes

This implementation provides a foundation for cancellation support that aligns with modern Node.js patterns while maintaining full backward compatibility.

@lovell

lovell commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Thanks for the PR, it looks like this is mostly LLM-generated.

At first glance:

  • The new signal feature should replace the existing timeout() API, which will be deprecated and under the hood can use AbortSignal.timeout().

  • The use of the resolveWithObject option on toBuffer() will soon be deprecated (and always set to true). If someone wants to set signal then they should not have control over resolveWithObject. This is to help protect consumers of this new API from upcoming breaking changes.

@niwinz

niwinz commented Aug 3, 2026

Copy link
Copy Markdown
Author

Yes, i'm using LLM for my work, but all of this is revewed by me in any case. I'm not very familiar with sharp codebase, so any feedback is more than welcome. We are starting to use sharp on penpot and cancellation was one thing i have missed on the API, this is the reason of this PR.

@niwinz

niwinz commented Aug 3, 2026

Copy link
Copy Markdown
Author

Thanks for the PR, it looks like this is mostly LLM-generated.

At first glance:

  • The new signal feature should replace the existing timeout() API, which will be deprecated and under the hood can use AbortSignal.timeout().

I'm not in position on take this decision from the beginning, i mean, this is something that only you can know, if you provide me a guidance i will try to adapt the PR to the changes. Do you want me to do this also? (in my opinion this can be addressed in other PR)

  • The use of the resolveWithObject option on toBuffer() will soon be deprecated (and always set to true). If someone wants to set signal then they should not have control over resolveWithObject. This is to help protect consumers of this new API from upcoming breaking changes.

ACK, i will try to address this

@niwinz
niwinz force-pushed the abort-signal-support branch 3 times, most recently from 14c93f6 to 675e71e Compare August 3, 2026 12:36
Prepare for upcoming deprecation of resolveWithObject option by enforcing
separation between signal and resolveWithObject options in toBuffer().

Changes:
- Update TypeScript definitions to remove resolveWithObject from signal overloads
- Add validation in toBuffer() to reject resolveWithObject when signal is present
- Add tests to verify the new behavior

This protects users from future breaking changes when resolveWithObject
is deprecated and always set to true. Users can now use either signal
(for cancellation) OR resolveWithObject (for structured output), but not both.

All 1825 tests passing with 100% coverage.
@niwinz
niwinz force-pushed the abort-signal-support branch from 675e71e to 552e503 Compare August 3, 2026 12:47
@niwinz

niwinz commented Aug 3, 2026

Copy link
Copy Markdown
Author

I think i have addressed the second point of the feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for AbortSignal to allow cancellation of in-flight image processing operations.

2 participants