diff --git a/lib/constructor.mjs b/lib/constructor.mjs index 36cb745c9..2ed26ec33 100644 --- a/lib/constructor.mjs +++ b/lib/constructor.mjs @@ -423,6 +423,14 @@ const Sharp = function (input, options) { // Function to notify of queue length changes queueListener }; + if (is.defined(options) && is.defined(options.signal)) { + if (!(options.signal instanceof AbortSignal)) { + throw is.invalidParameterError('signal', 'AbortSignal', options.signal); + } + this.options.signal = options.signal; + } else { + this.options.signal = null; + } this.options.input = this._createInputDescriptor(input, options, { allowStream: true }); return this; }; diff --git a/lib/index.d.ts b/lib/index.d.ts index d55787491..e5fa78787 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -650,35 +650,83 @@ declare namespace sharp { * @throws {Error} Invalid parameters * @returns A promise that fulfills with an object containing information on the resulting file */ - toFile(fileOut: string): Promise; - - /** - * Write output to a Buffer. JPEG, PNG, WebP, AVIF, TIFF, GIF and RAW output are supported. + toFile(fileOut: string): Promise; + + /** + * Write output image data to a file. + * @param fileOut The path to write the image data to. + * @param options Options object with signal for cancellation. + * @throws {Error} Invalid parameters + * @returns A promise that fulfills with an object containing information on the resulting file + */ + toFile(fileOut: string, options: { signal?: AbortSignal }): Promise; + + /** + * Write output to a Buffer. JPEG, PNG, WebP, AVIF, TIFF, GIF and RAW output are supported. * By default, the format will match the input image, except SVG input which becomes PNG output. * @param callback Callback function called on completion with three arguments (err, buffer, info). * @returns A sharp instance that can be used to chain operations */ toBuffer(callback: (err: Error, buffer: Buffer, info: OutputInfo) => void): Sharp; - /** - * Write output to a Buffer. JPEG, PNG, WebP, AVIF, TIFF, GIF and RAW output are supported. - * By default, the format will match the input image, except SVG input which becomes PNG output. - * The underlying `ArrayBuffer` may be marked as non-transferable by some JavaScript runtimes. - * @param options resolve options - * @param options.resolveWithObject Resolve the Promise with an Object containing data and info properties instead of resolving only with data. - * @returns A promise that resolves with the Buffer data. - */ - toBuffer(options?: { resolveWithObject: false }): Promise>; - - /** - * Write output to a Buffer. JPEG, PNG, WebP, AVIF, TIFF, GIF and RAW output are supported. - * By default, the format will match the input image, except SVG input which becomes PNG output. - * The underlying `ArrayBuffer` may be marked as non-transferable by some JavaScript runtimes. - * @param options resolve options - * @param options.resolveWithObject Resolve the Promise with an Object containing data and info properties instead of resolving only with data. - * @returns A promise that resolves with an object containing the Buffer data and an info object containing the output image format, size (bytes), width, height and channels - */ - toBuffer(options: { resolveWithObject: true }): Promise<{ data: Buffer; info: OutputInfo }>; + /** + * Write output to a Buffer. JPEG, PNG, WebP, AVIF, TIFF, GIF and RAW output are supported. + * By default, the format will match the input image, except SVG input which becomes PNG output. + * @param options Options object with signal for cancellation. + * @param callback Callback function called on completion with three arguments (err, buffer, info). + * @returns A sharp instance that can be used to chain operations + */ + toBuffer(options: { signal: AbortSignal }, callback: (err: Error, buffer: Buffer, info: OutputInfo) => void): Sharp; + + /** + * Write output to a Buffer. JPEG, PNG, WebP, AVIF, TIFF, GIF and RAW output are supported. + * By default, the format will match the input image, except SVG input which becomes PNG output. + * The underlying `ArrayBuffer` may be marked as non-transferable by some JavaScript runtimes. + * @param options Options object with signal for cancellation. + * @param options.signal AbortSignal to cancel the operation. + * @returns A promise that resolves with the Buffer data. + */ + toBuffer(options?: { signal?: AbortSignal }): Promise>; + + /** + * Write output to a Buffer. JPEG, PNG, WebP, AVIF, TIFF, GIF and RAW output are supported. + * By default, the format will match the input image, except SVG input which becomes PNG output. + * The underlying `ArrayBuffer` may be marked as non-transferable by some JavaScript runtimes. + * @param options resolve options + * @param options.resolveWithObject Resolve the Promise with an Object containing data and info properties instead of resolving only with data. + * @returns A promise that resolves with the Buffer data. + */ + toBuffer(options?: { resolveWithObject?: false }): Promise>; + + /** + * Write output to a Buffer. JPEG, PNG, WebP, AVIF, TIFF, GIF and RAW output are supported. + * By default, the format will match the input image, except SVG input which becomes PNG output. + * The underlying `ArrayBuffer` may be marked as non-transferable by some JavaScript runtimes. + * @param options resolve options + * @param options.resolveWithObject Resolve the Promise with an Object containing data and info properties instead of resolving only with data. + * @returns A promise that resolves with an object containing the Buffer data and an info object containing the output image format, size (bytes), width, height and channels + */ + toBuffer(options: { resolveWithObject: true }): Promise<{ data: Buffer; info: OutputInfo }>; + + /** + * Write output to a Buffer. JPEG, PNG, WebP, AVIF, TIFF, GIF and RAW output are supported. + * By default, the format will match the input image, except SVG input which becomes PNG output. + * @param options resolve options + * @param options.resolveWithObject Resolve the Promise with an Object containing data and info properties instead of resolving only with data. + * @param callback Callback function called on completion with three arguments (err, buffer, info). + * @returns A sharp instance that can be used to chain operations + */ + toBuffer(options: { resolveWithObject?: false }, callback: (err: Error, buffer: Buffer, info: OutputInfo) => void): Sharp; + + /** + * Write output to a Buffer. JPEG, PNG, WebP, AVIF, TIFF, GIF and RAW output are supported. + * By default, the format will match the input image, except SVG input which becomes PNG output. + * @param options resolve options + * @param options.resolveWithObject Resolve the Promise with an Object containing data and info properties instead of resolving only with data. + * @param callback Callback function called on completion with two arguments (err, result). + * @returns A sharp instance that can be used to chain operations + */ + toBuffer(options: { resolveWithObject: true }, callback: (err: Error, result: { data: Buffer; info: OutputInfo }) => void): Sharp; /** * Write output to a Uint8Array backed by a transferable ArrayBuffer. JPEG, PNG, WebP, AVIF, TIFF, GIF and RAW output are supported. @@ -1045,6 +1093,8 @@ declare namespace sharp { text?: CreateText | undefined; /** Describes how array of input images should be joined. */ join?: Join | undefined; + /** AbortSignal to cancel processing. */ + signal?: AbortSignal | undefined; } interface CacheOptions { diff --git a/lib/output.mjs b/lib/output.mjs index 00ff8b9e1..73a0ebd0f 100644 --- a/lib/output.mjs +++ b/lib/output.mjs @@ -70,7 +70,11 @@ const bitdepthFromColourCount = (colours) => 1 << 32 - Math.clz32(Math.ceil(Math * @returns {Promise} - when no callback is provided * @throws {Error} Invalid parameters */ -function toFile (fileOut, callback) { +function toFile (fileOut, options, callback) { + if (is.fn(options)) { + callback = options; + options = undefined; + } let err; if (!is.string(fileOut)) { err = new Error('Missing output file path'); @@ -86,6 +90,12 @@ function toFile (fileOut, callback) { return Promise.reject(err); } } else { + if (is.object(options) && is.defined(options.signal)) { + if (!(options.signal instanceof AbortSignal)) { + throw is.invalidParameterError('signal', 'AbortSignal', options.signal); + } + this.options.signal = options.signal; + } this.options.fileOut = fileOut; const stack = Error(); return this._pipeline(callback, stack); @@ -151,7 +161,18 @@ function toFile (fileOut, callback) { */ function toBuffer (options, callback) { if (is.object(options)) { - this._setBooleanOption('resolveWithObject', options.resolveWithObject); + if (is.defined(options.signal)) { + if (!(options.signal instanceof AbortSignal)) { + throw is.invalidParameterError('signal', 'AbortSignal', options.signal); + } + // When signal is present, reject resolveWithObject to protect from future breaking changes + if (is.defined(options.resolveWithObject)) { + throw is.invalidParameterError('resolveWithObject', 'undefined when signal is present', options.resolveWithObject); + } + this.options.signal = options.signal; + } else if (is.defined(options.resolveWithObject)) { + this._setBooleanOption('resolveWithObject', options.resolveWithObject); + } } else if (this.options.resolveWithObject) { this.options.resolveWithObject = false; } @@ -1645,29 +1666,95 @@ function _read () { * @private */ function _pipeline (callback, stack) { + const signal = this.options.signal; + + if (signal?.aborted) { + const err = new Error('The operation was aborted'); + err.name = 'AbortError'; + err.code = 'ABORT_ERR'; + if (typeof callback === 'function') { + callback(err); + return this; + } else if (this.options.streamOut) { + process.nextTick(() => { + this.emit('error', err); + this.push(null); + this.on('end', () => this.emit('close')); + }); + return this; + } else { + return Promise.reject(err); + } + } + + const pipelineOptions = { ...this.options }; + let abortView = null; + let abortListener = null; + + if (signal) { + const abortBuffer = new SharedArrayBuffer(1); + abortView = new Int8Array(abortBuffer); + pipelineOptions.abortFlag = abortBuffer; + abortListener = () => { + Atomics.store(abortView, 0, 1); + }; + signal.addEventListener('abort', abortListener, { once: true }); + } + + const cleanup = () => { + if (signal && abortListener) { + signal.removeEventListener('abort', abortListener); + } + }; + + const wrapCallback = (cb) => { + return (err, data, info) => { + cleanup(); + if (err && signal?.aborted) { + const abortErr = new Error('The operation was aborted'); + abortErr.name = 'AbortError'; + abortErr.code = 'ABORT_ERR'; + cb(abortErr); + } else if (err) { + cb(is.nativeError(err, stack)); + } else { + cb(null, data, info); + } + }; + }; + + const wrapStreamEmit = (emitError, emitInfo, pushData) => { + return (err, data, info) => { + cleanup(); + if (err && signal?.aborted) { + const abortErr = new Error('The operation was aborted'); + abortErr.name = 'AbortError'; + abortErr.code = 'ABORT_ERR'; + emitError(abortErr); + } else if (err) { + emitError(is.nativeError(err, stack)); + } else { + emitInfo(info); + pushData(data); + } + pushData(null); + }; + }; if (typeof callback === 'function') { // output=file/buffer if (this._isStreamInput()) { // output=file/buffer, input=stream this.on('finish', () => { this._flattenBufferIn(); - sharp.pipeline(this.options, (err, data, info) => { - if (err) { - callback(is.nativeError(err, stack)); - } else { - callback(null, data, info); - } - }); + sharp.pipeline(pipelineOptions, wrapCallback((err, data, info) => { + callback(err, data, info); + })); }); } else { // output=file/buffer, input=file/buffer - sharp.pipeline(this.options, (err, data, info) => { - if (err) { - callback(is.nativeError(err, stack)); - } else { - callback(null, data, info); - } - }); + sharp.pipeline(pipelineOptions, wrapCallback((err, data, info) => { + callback(err, data, info); + })); } return this; } else if (this.options.streamOut) { @@ -1676,32 +1763,22 @@ function _pipeline (callback, stack) { // output=stream, input=stream this.once('finish', () => { this._flattenBufferIn(); - sharp.pipeline(this.options, (err, data, info) => { - if (err) { - this.emit('error', is.nativeError(err, stack)); - } else { - this.emit('info', info); - this.push(data); - } - this.push(null); - this.on('end', () => this.emit('close')); - }); + sharp.pipeline(pipelineOptions, wrapStreamEmit( + (err) => this.emit('error', err), + (info) => this.emit('info', info), + (data) => this.push(data) + )); }); if (this.streamInFinished) { this.emit('finish'); } } else { // output=stream, input=file/buffer - sharp.pipeline(this.options, (err, data, info) => { - if (err) { - this.emit('error', is.nativeError(err, stack)); - } else { - this.emit('info', info); - this.push(data); - } - this.push(null); - this.on('end', () => this.emit('close')); - }); + sharp.pipeline(pipelineOptions, wrapStreamEmit( + (err) => this.emit('error', err), + (info) => this.emit('info', info), + (data) => this.push(data) + )); } return this; } else { @@ -1711,9 +1788,9 @@ function _pipeline (callback, stack) { return new Promise((resolve, reject) => { this.once('finish', () => { this._flattenBufferIn(); - sharp.pipeline(this.options, (err, data, info) => { + sharp.pipeline(pipelineOptions, wrapCallback((err, data, info) => { if (err) { - reject(is.nativeError(err, stack)); + reject(err); } else { if (this.options.resolveWithObject) { resolve({ data, info }); @@ -1721,15 +1798,15 @@ function _pipeline (callback, stack) { resolve(data); } } - }); + })); }); }); } else { // output=promise, input=file/buffer return new Promise((resolve, reject) => { - sharp.pipeline(this.options, (err, data, info) => { + sharp.pipeline(pipelineOptions, wrapCallback((err, data, info) => { if (err) { - reject(is.nativeError(err, stack)); + reject(err); } else { if (this.options.resolveWithObject) { resolve({ data, info }); @@ -1737,7 +1814,7 @@ function _pipeline (callback, stack) { resolve(data); } } - }); + })); }); } } diff --git a/src/common.cc b/src/common.cc index 15472ad5e..efb6b2fd9 100644 --- a/src/common.cc +++ b/src/common.cc @@ -868,6 +868,29 @@ namespace sharp { } } + /* + Attach an event listener for progress updates, used to detect abort via SharedArrayBuffer flag + */ + void SetAbortFlag(VImage image, std::atomic *abortFlag) { + if (abortFlag != nullptr) { + VipsImage *im = image.get_image(); + if (im->progress_signal == NULL) { + g_signal_connect(im, "eval", G_CALLBACK(VipsAbortCallBack), abortFlag); + vips_image_set_progress(im, true); + } + } + } + + /* + Event listener for progress updates, used to detect abort via SharedArrayBuffer flag + */ + void VipsAbortCallBack(VipsImage *im, VipsProgress *progress, std::atomic *abortFlag) { + if (abortFlag->load(std::memory_order_relaxed) != 0) { + vips_image_set_kill(im, true); + vips_error("abort", "%d%% complete", progress->percent); + } + } + /* Calculate the (left, top) coordinates of the output image within the input image, applying the given gravity during an embed. diff --git a/src/common.h b/src/common.h index 0ac239af5..baf2783d3 100644 --- a/src/common.h +++ b/src/common.h @@ -343,11 +343,21 @@ namespace sharp { */ void SetTimeout(VImage image, int const timeoutSeconds); + /* + Attach an event listener for progress updates, used to detect abort via SharedArrayBuffer flag + */ + void SetAbortFlag(VImage image, std::atomic *abortFlag); + /* Event listener for progress updates, used to detect timeout */ void VipsProgressCallBack(VipsImage *image, VipsProgress *progress, int *timeoutSeconds); + /* + Event listener for progress updates, used to detect abort via SharedArrayBuffer flag + */ + void VipsAbortCallBack(VipsImage *image, VipsProgress *progress, std::atomic *abortFlag); + /* Calculate the (left, top) coordinates of the output image within the input image, applying the given gravity during an embed. diff --git a/src/pipeline.cc b/src/pipeline.cc index b640bddb7..e505412f2 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -947,6 +947,9 @@ class PipelineWorker : public Napi::AsyncWorker { baton->hasAlphaOut = image.has_alpha(); // Output + if (baton->abortFlag) { + sharp::SetAbortFlag(image, baton->abortFlag); + } sharp::SetTimeout(image, baton->timeoutSeconds); if (baton->fileOut.empty()) { // Buffer output @@ -1839,6 +1842,10 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) { baton->keepGainMap = sharp::AttrAsBool(options, "keepGainMap"); baton->withGainMap = sharp::AttrAsBool(options, "withGainMap"); baton->timeoutSeconds = sharp::AttrAsUint32(options, "timeoutSeconds"); + if (sharp::HasAttr(options, "abortFlag")) { + Napi::ArrayBuffer ab = options.Get("abortFlag").As(); + baton->abortFlag = reinterpret_cast*>(ab.Data()); + } baton->loop = sharp::AttrAsUint32(options, "loop"); baton->delay = sharp::AttrAsInt32Vector(options, "delay"); // Format-specific diff --git a/src/pipeline.h b/src/pipeline.h index 78de093e5..5eeb7e0fd 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -216,6 +216,7 @@ struct PipelineBaton { bool withGainMap; bool keepGainMap; int timeoutSeconds; + std::atomic *abortFlag; std::vector convKernel; int convKernelWidth; int convKernelHeight; @@ -396,6 +397,7 @@ struct PipelineBaton { withGainMap(false), keepGainMap(false), timeoutSeconds(0), + abortFlag(nullptr), convKernelWidth(0), convKernelHeight(0), convKernelScale(0.0), diff --git a/test/unit/abort.js b/test/unit/abort.js new file mode 100644 index 000000000..2429e0f65 --- /dev/null +++ b/test/unit/abort.js @@ -0,0 +1,141 @@ +/*! + Copyright 2013 Lovell Fuller and others. + SPDX-License-Identifier: Apache-2.0 +*/ + +const { suite, test } = require('node:test'); + +const sharp = require('../../'); +const fixtures = require('../fixtures'); + +suite('AbortSignal', () => { + test('Will abort when signal is triggered during processing', async (t) => { + t.plan(1); + const controller = new AbortController(); + const promise = sharp(fixtures.inputJpg) + .blur(300) + .toBuffer({ signal: controller.signal }); + + setTimeout(() => controller.abort(), 100); + + await t.assert.rejects(promise, /abort/); + }); + + test('Will reject immediately if signal already aborted', async (t) => { + t.plan(1); + const controller = new AbortController(); + controller.abort(); + + await t.assert.rejects( + () => sharp(fixtures.inputJpg).toBuffer({ signal: controller.signal }), + { name: 'AbortError' } + ); + }); + + test('Will complete normally if signal is not aborted', async (t) => { + t.plan(1); + const controller = new AbortController(); + const data = await sharp(fixtures.inputJpg) + .resize(100) + .toBuffer({ signal: controller.signal }); + + t.assert.ok(data.length > 0); + }); + + test('Will complete normally without signal', async (t) => { + t.plan(1); + const data = await sharp(fixtures.inputJpg) + .resize(100) + .toBuffer(); + + t.assert.ok(data.length > 0); + }); + + test('invalid signal type', async (t) => { + t.plan(1); + await t.assert.throws( + () => sharp(fixtures.inputJpg, { signal: 'not-a-signal' }), + /Expected AbortSignal/ + ); + }); + + test('signal option in constructor', async (t) => { + t.plan(1); + const controller = new AbortController(); + const promise = sharp(fixtures.inputJpg, { signal: controller.signal }) + .blur(300) + .toBuffer(); + + setTimeout(() => controller.abort(), 100); + + await t.assert.rejects(promise, /abort/); + }); + + test('signal with toFile', async (t) => { + t.plan(1); + const controller = new AbortController(); + const promise = sharp(fixtures.inputJpg) + .blur(300) + .toFile(fixtures.path('output.abort.jpg'), { signal: controller.signal }); + + setTimeout(() => controller.abort(), 100); + + await t.assert.rejects(promise, /abort/); + }); + + test('signal with stream output', async (t) => { + t.plan(1); + const controller = new AbortController(); + const stream = sharp(fixtures.inputJpg, { signal: controller.signal }) + .blur(300); + + setTimeout(() => controller.abort(), 100); + + await t.assert.rejects( + () => new Promise((resolve, reject) => { + stream + .on('data', () => {}) + .on('end', resolve) + .on('error', reject); + }), + /abort/ + ); + }); + + test('toBuffer with signal rejects resolveWithObject: true', async (t) => { + t.plan(1); + const controller = new AbortController(); + await t.assert.throws( + () => sharp(fixtures.inputJpg).toBuffer({ signal: controller.signal, resolveWithObject: true }), + /resolveWithObject/ + ); + }); + + test('toBuffer with signal rejects resolveWithObject: false', async (t) => { + t.plan(1); + const controller = new AbortController(); + await t.assert.throws( + () => sharp(fixtures.inputJpg).toBuffer({ signal: controller.signal, resolveWithObject: false }), + /resolveWithObject/ + ); + }); + + test('toBuffer without signal allows resolveWithObject: true', async (t) => { + t.plan(2); + const result = await sharp(fixtures.inputJpg) + .resize(100) + .toBuffer({ resolveWithObject: true }); + + t.assert.ok(result.data.length > 0); + t.assert.ok(result.info.width === 100); + }); + + test('toBuffer without signal allows resolveWithObject: false', async (t) => { + t.plan(1); + const data = await sharp(fixtures.inputJpg) + .resize(100) + .toBuffer({ resolveWithObject: false }); + + t.assert.ok(data.length > 0); + }); +});