From 6f3a09d0312b93680974415412d94911387224b5 Mon Sep 17 00:00:00 2001 From: Jayesh Bhade <52350067+Jaybhade@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:20:04 +0530 Subject: [PATCH] Prevent hang when output is requested after Stream input has finished Every Stream-based input path waits for the Writable side to emit 'finish' before flattening the accumulated chunks. 'finish' is emitted only once, so any of these registered after it has already fired never runs: the Promise never settles and the callback is never called, with no error and no timeout. const pipeline = sharp().resize(320, 240); createReadStream(input).pipe(pipeline); await once(pipeline, 'finish'); await pipeline.toBuffer(); // never resolves This affected toBuffer, toFile, metadata via callback, stats and clone. Two paths already worked around it in isolation: metadata via Promise checked writableFinished, and Stream output re-emitted 'finish' when streamInFinished was set. Replace both ad-hoc guards with a shared _whenStreamInFinished helper and use it everywhere the input stream is awaited. Re-emitting 'finish' is no longer needed, so the streamInFinished bookkeeping in _write goes too, along with the 'finish' listener it attached to every instance. --- lib/constructor.mjs | 2 +- lib/input.mjs | 35 ++++++++++++--------- lib/output.mjs | 9 ++---- test/unit/io.js | 76 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 22 deletions(-) diff --git a/lib/constructor.mjs b/lib/constructor.mjs index 36cb745c9..f2b98696c 100644 --- a/lib/constructor.mjs +++ b/lib/constructor.mjs @@ -492,7 +492,7 @@ function clone () { clone.options.queueListener = queueListener; // Pass 'finish' event to clone for Stream-based input if (this._isStreamInput()) { - this.on('finish', () => { + this._whenStreamInFinished(() => { // Clone inherits input data this._flattenBufferIn(); clone.options.input.buffer = this.options.input.buffer; diff --git a/lib/input.mjs b/lib/input.mjs index 3ea76d032..0cab99dd4 100644 --- a/lib/input.mjs +++ b/lib/input.mjs @@ -531,11 +531,6 @@ function _createInputDescriptor (input, inputOptions, containerOptions) { function _write (chunk, _encoding, callback) { if (Array.isArray(this.options.input.buffer)) { if (is.buffer(chunk)) { - if (this.options.input.buffer.length === 0) { - this.on('finish', () => { - this.streamInFinished = true; - }); - } this.options.input.buffer.push(chunk); callback(); } else { @@ -565,6 +560,20 @@ function _isStreamInput () { return Array.isArray(this.options.input.buffer); } +/** + * Call fn when the Writable side of Stream-based input has finished, + * or immediately when it already has, as 'finish' is emitted only once. + * @private + * @param {Function} fn + */ +function _whenStreamInFinished (fn) { + if (this.writableFinished) { + fn(); + } else { + this.once('finish', fn); + } +} + /** * Fast access to (uncached) image metadata without decoding any compressed pixel data. * @@ -642,7 +651,7 @@ function metadata (callback) { const stack = Error(); if (is.fn(callback)) { if (this._isStreamInput()) { - this.on('finish', () => { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.metadata(this.options, (err, metadata) => { if (err) { @@ -665,7 +674,7 @@ function metadata (callback) { } else { if (this._isStreamInput()) { return new Promise((resolve, reject) => { - const finished = () => { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.metadata(this.options, (err, metadata) => { if (err) { @@ -674,12 +683,7 @@ function metadata (callback) { resolve(metadata); } }); - }; - if (this.writableFinished) { - finished(); - } else { - this.once('finish', finished); - } + }); }); } else { return new Promise((resolve, reject) => { @@ -744,7 +748,7 @@ function stats (callback) { const stack = Error(); if (is.fn(callback)) { if (this._isStreamInput()) { - this.on('finish', () => { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.stats(this.options, (err, stats) => { if (err) { @@ -767,7 +771,7 @@ function stats (callback) { } else { if (this._isStreamInput()) { return new Promise((resolve, reject) => { - this.on('finish', function () { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.stats(this.options, (err, stats) => { if (err) { @@ -805,6 +809,7 @@ export default (Sharp) => { _write, _flattenBufferIn, _isStreamInput, + _whenStreamInFinished, // Public metadata, stats diff --git a/lib/output.mjs b/lib/output.mjs index 00ff8b9e1..7372e62e1 100644 --- a/lib/output.mjs +++ b/lib/output.mjs @@ -1649,7 +1649,7 @@ function _pipeline (callback, stack) { // output=file/buffer if (this._isStreamInput()) { // output=file/buffer, input=stream - this.on('finish', () => { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.pipeline(this.options, (err, data, info) => { if (err) { @@ -1674,7 +1674,7 @@ function _pipeline (callback, stack) { // output=stream if (this._isStreamInput()) { // output=stream, input=stream - this.once('finish', () => { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.pipeline(this.options, (err, data, info) => { if (err) { @@ -1687,9 +1687,6 @@ function _pipeline (callback, stack) { this.on('end', () => this.emit('close')); }); }); - if (this.streamInFinished) { - this.emit('finish'); - } } else { // output=stream, input=file/buffer sharp.pipeline(this.options, (err, data, info) => { @@ -1709,7 +1706,7 @@ function _pipeline (callback, stack) { if (this._isStreamInput()) { // output=promise, input=stream return new Promise((resolve, reject) => { - this.once('finish', () => { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.pipeline(this.options, (err, data, info) => { if (err) { diff --git a/test/unit/io.js b/test/unit/io.js index 6ff2a9843..f314a1cf5 100644 --- a/test/unit/io.js +++ b/test/unit/io.js @@ -3,6 +3,7 @@ SPDX-License-Identifier: Apache-2.0 */ +const { once } = require('node:events'); const { createReadStream, createWriteStream } = require('node:fs'); const fs = require('node:fs/promises'); const path = require('node:path'); @@ -368,6 +369,81 @@ suite('Input/output', () => { await fs.rm(outputJpg); }); + suite('Output requested after Writable side of Stream-based input has finished', () => { + const finishedPipeline = async (pipeline) => { + createReadStream(fixtures.inputJpg).pipe(pipeline); + await once(pipeline, 'finish'); + return pipeline; + }; + + test('Write to Buffer via Promise', async (t) => { + t.plan(2); + const pipeline = await finishedPipeline(sharp().resize(320, 240)); + const { info } = await pipeline.toBuffer({ resolveWithObject: true }); + t.assert.strictEqual(320, info.width); + t.assert.strictEqual(240, info.height); + }); + + test('Write to Buffer via callback', async (t) => { + t.plan(2); + const pipeline = await finishedPipeline(sharp().resize(320, 240)); + const info = await new Promise((resolve, reject) => { + pipeline.toBuffer((err, _data, info) => err ? reject(err) : resolve(info)); + }); + t.assert.strictEqual(320, info.width); + t.assert.strictEqual(240, info.height); + }); + + test('Write to File via Promise', async (t) => { + t.plan(2); + const pipeline = await finishedPipeline(sharp().resize(320, 240)); + const info = await pipeline.toFile(outputJpg); + t.assert.strictEqual(320, info.width); + t.assert.strictEqual(240, info.height); + await fs.rm(outputJpg); + }); + + test('Read metadata via Promise', async (t) => { + t.plan(1); + const pipeline = await finishedPipeline(sharp()); + const { width } = await pipeline.metadata(); + t.assert.strictEqual(2725, width); + }); + + test('Read metadata via callback', async (t) => { + t.plan(1); + const pipeline = await finishedPipeline(sharp()); + const { width } = await new Promise((resolve, reject) => { + pipeline.metadata((err, metadata) => err ? reject(err) : resolve(metadata)); + }); + t.assert.strictEqual(2725, width); + }); + + test('Read stats via Promise', async (t) => { + t.plan(1); + const pipeline = await finishedPipeline(sharp()); + const { isOpaque } = await pipeline.stats(); + t.assert.strictEqual(true, isOpaque); + }); + + test('Read stats via callback', async (t) => { + t.plan(1); + const pipeline = await finishedPipeline(sharp()); + const { isOpaque } = await new Promise((resolve, reject) => { + pipeline.stats((err, stats) => err ? reject(err) : resolve(stats)); + }); + t.assert.strictEqual(true, isOpaque); + }); + + test('Clone inherits input', async (t) => { + t.plan(2); + const pipeline = await finishedPipeline(sharp()); + const { info } = await pipeline.clone().resize(320, 240).toBuffer({ resolveWithObject: true }); + t.assert.strictEqual(320, info.width); + t.assert.strictEqual(240, info.height); + }); + }); + test('Non-Stream input generates error when provided Stream-like data', async (t) => { t.plan(2); t.assert.throws(