From 88066135fa21025855ef9ed0c18c8b6c39618350 Mon Sep 17 00:00:00 2001 From: Jonathan Cardoso Machado Date: Sun, 12 Apr 2026 12:00:35 -0300 Subject: [PATCH 1/3] fix: defer unpause in setDataStream to prevent libcurl reentrant hang The readable/end/error event handlers called unpause() synchronously, which could invoke curl_easy_pause() while libcurl was still processing the READFUNC_PAUSE return from the read callback. This reentrant call caused hangs on Linux. Defer the unpause to the next event loop tick via setImmediate, matching the pattern used by setUploadStream in Curl.ts. --- lib/CurlMimePart.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/CurlMimePart.ts b/lib/CurlMimePart.ts index d5e9d8e3..e2873d44 100644 --- a/lib/CurlMimePart.ts +++ b/lib/CurlMimePart.ts @@ -424,21 +424,35 @@ CurlMimePart.prototype.setDataStream = function ( ): typeof CurlMimePart.prototype { let streamEnded = false let streamError: Error | null = null + let paused = false + + // Defer unpause to the next event loop iteration to avoid calling + // curl_easy_pause() while libcurl is still processing the READFUNC_PAUSE + // return value from the read callback. Without this, the synchronous + // unpause can re-enter libcurl and cause a hang (observed on Linux). + const deferredUnpause = () => { + if (paused) { + paused = false + setImmediate(() => { + unpause() + }) + } + } const onReadable = () => { - unpause() + deferredUnpause() } const onEnd = () => { streamEnded = true - unpause() + deferredUnpause() cleanup() } const onError = (err: Error) => { streamError = err streamEnded = true - unpause() + deferredUnpause() cleanup() } @@ -473,6 +487,7 @@ CurlMimePart.prototype.setDataStream = function ( if (streamEnded) { return null } + paused = true return CurlReadFunc.Pause } From 8bfb1ab8f10189cee54b3e4a4b056b5f81ef89e5 Mon Sep 17 00:00:00 2001 From: Jonathan Cardoso Machado Date: Mon, 18 May 2026 23:47:15 -0300 Subject: [PATCH 2/3] fix: use setTimeout instead of setImmediate for unpause deferral setImmediate fires in the check phase of the libuv event loop, but libcurl's multi handle timeout fires in the timer phase. On Node.js 24 the setImmediate-based unpause wasn't being picked up reliably. setTimeout(fn, 0) fires in the timer phase, matching libcurl's callback scheduling and ensuring consistent behavior across Node.js versions. --- lib/CurlMimePart.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/CurlMimePart.ts b/lib/CurlMimePart.ts index e2873d44..a09935bf 100644 --- a/lib/CurlMimePart.ts +++ b/lib/CurlMimePart.ts @@ -426,16 +426,22 @@ CurlMimePart.prototype.setDataStream = function ( let streamError: Error | null = null let paused = false - // Defer unpause to the next event loop iteration to avoid calling + // Defer unpause to a later event loop iteration to avoid calling // curl_easy_pause() while libcurl is still processing the READFUNC_PAUSE // return value from the read callback. Without this, the synchronous // unpause can re-enter libcurl and cause a hang (observed on Linux). + // + // We use setTimeout(fn, 0) rather than setImmediate because setTimeout + // fires in the timer phase of the event loop, which is the same phase + // where libcurl's multi handle timeout callback fires. This ensures the + // unpause is processed in a compatible event loop phase across all + // Node.js versions. const deferredUnpause = () => { if (paused) { paused = false - setImmediate(() => { + setTimeout(() => { unpause() - }) + }, 0) } } From 1c17e0f41c323f649cf45906e0329836dd271307 Mon Sep 17 00:00:00 2001 From: Jonathan Cardoso Machado Date: Wed, 20 May 2026 21:21:25 -0300 Subject: [PATCH 3/3] fix: track CURLPAUSE_SEND state for mime read callback pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mime StaticReadCallback was returning CURL_READFUNC_PAUSE without updating pauseState, so isPausedSend/isPausedRecv both stayed false. This caused the test's conditional unpause (which checks pause state before calling curl_easy_pause) to be a silent no-op, leaving the transfer paused forever once libcurl needed a second read callback. The test only passed when the stream's 'end' event fired before libcurl's second read attempt — a race that resolved differently across Node.js versions and platforms. Additionally, the docs and examples incorrectly referenced CurlPause.Recv. The mime read callback supplies upload data, so it pauses SEND (matching Easy::ReadFunction). Updated docs, examples, and tests to use Send. Also reverted setTimeout back to setImmediate now that the underlying race is properly fixed. --- lib/CurlMimePart.ts | 24 ++++++++++-------------- lib/Easy.ts | 7 ++++--- src/CurlMime.cc | 9 ++++++++- test/curl/CurlMime.spec.ts | 24 ++++++++++++------------ 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/lib/CurlMimePart.ts b/lib/CurlMimePart.ts index a09935bf..cc3ff3e5 100644 --- a/lib/CurlMimePart.ts +++ b/lib/CurlMimePart.ts @@ -24,7 +24,7 @@ export interface MimeDataCallbacks { * * @remarks * When `CurlReadFunc.Pause` is returned, the transfer will be paused until it is - * explicitly resumed by calling `handle.pause(handle.pauseFlags & ~CurlPause.Recv)`. + * explicitly resumed by calling `handle.pause(handle.pauseFlags & ~CurlPause.Send)`. * When `CurlReadFunc.Abort` is returned, the transfer will be aborted. * * @example @@ -360,8 +360,9 @@ declare class CurlMimePart { * `CurlReadFunc.Pause`, and the `unpause` callback is invoked when data becomes * available to resume the transfer. * - * The `unpause` function should unpause the curl handle's receive operation, typically - * by calling `handle.pause(handle.pauseFlags & ~CurlPause.Recv)`. + * The `unpause` function should unpause the curl handle's send operation (mime upload + * data is sent via the read callback), typically by calling + * `handle.pause(handle.pauseFlags & ~CurlPause.Send)`. * * For very large files, consider using {@link setFileData} instead, as it streams * directly from disk without going through Node.js streams. @@ -380,7 +381,7 @@ declare class CurlMimePart { * .addPart() * .setName('document') * .setDataStream(stream, () => { - * curl.pause(curl.handle.pauseFlags & ~CurlPause.Recv) + * curl.pause(curl.handle.pauseFlags & ~CurlPause.Send) * }) * .setType('text/plain') * ``` @@ -402,7 +403,7 @@ declare class CurlMimePart { * .setName('document') * .setDataStream( * stream, - * () => curl.pause(curl.handle.pauseFlags & ~CurlPause.Recv), + * () => curl.pause(curl.handle.pauseFlags & ~CurlPause.Send), * size * ) * ``` @@ -426,22 +427,17 @@ CurlMimePart.prototype.setDataStream = function ( let streamError: Error | null = null let paused = false - // Defer unpause to a later event loop iteration to avoid calling + // Defer unpause to the next event loop iteration to avoid calling // curl_easy_pause() while libcurl is still processing the READFUNC_PAUSE // return value from the read callback. Without this, the synchronous // unpause can re-enter libcurl and cause a hang (observed on Linux). - // - // We use setTimeout(fn, 0) rather than setImmediate because setTimeout - // fires in the timer phase of the event loop, which is the same phase - // where libcurl's multi handle timeout callback fires. This ensures the - // unpause is processed in a compatible event loop phase across all - // Node.js versions. + // This matches the pattern used by setUploadStream in Curl.ts. const deferredUnpause = () => { if (paused) { paused = false - setTimeout(() => { + setImmediate(() => { unpause() - }, 0) + }) } } diff --git a/lib/Easy.ts b/lib/Easy.ts index d8dcac39..9dbabd30 100644 --- a/lib/Easy.ts +++ b/lib/Easy.ts @@ -745,7 +745,8 @@ const Easy = bindings.Easy as Easy * @remarks * For stream-based parts, you must provide the unpause callback that will be * called when more data is available. The callback should unpause the transfer - * using `handle.pause(handle.pauseFlags & ~CurlPause.Recv)`. + * using `handle.pause(handle.pauseFlags & ~CurlPause.Send)` (mime upload data + * is sent via the read callback, so it pauses SEND, not RECV). * * Available since libcurl 7.56.0. * @@ -774,7 +775,7 @@ const Easy = bindings.Easy as Easy * name: 'logfile', * stream: createReadStream('/path/to/log.txt'), * unpause: () => { - * easy.pause(easy.pauseFlags & ~CurlPause.Recv) + * easy.pause(easy.pauseFlags & ~CurlPause.Send) * }, * size: 12345 * }, @@ -832,7 +833,7 @@ Easy.prototype.setMimePost = function ( part.setDataStream( partSpec.stream, () => { - this.pause(this.pauseFlags & ~CurlPause.Recv) + this.pause(this.pauseFlags & ~CurlPause.Send) }, partSpec.size, ) diff --git a/src/CurlMime.cc b/src/CurlMime.cc index 5ce1e328..be2b4e12 100644 --- a/src/CurlMime.cc +++ b/src/CurlMime.cc @@ -595,7 +595,14 @@ size_t CurlMimePart::StaticReadCallback(char* buffer, size_t size, size_t nitems } if (result.IsNumber()) { - return result.As().Int32Value(); + int32_t returnValue = result.As().Int32Value(); + // Track pause state so isPausedSend reflects reality. + // The mime data callback pauses SEND (it supplies upload data), + // matching the behavior of Easy::ReadFunction. + if (returnValue == CURL_READFUNC_PAUSE) { + part->easy->pauseState |= CURLPAUSE_SEND; + } + return static_cast(returnValue); } // Invalid return type diff --git a/test/curl/CurlMime.spec.ts b/test/curl/CurlMime.spec.ts index 235c7f84..2606e610 100644 --- a/test/curl/CurlMime.spec.ts +++ b/test/curl/CurlMime.spec.ts @@ -813,8 +813,8 @@ describe.runIf(Curl.isVersionGreaterOrEqualThan(7, 56, 0))('CurlMime', () => { .addPart() .setName('stream_field') .setDataStream(stream, () => { - if (curl.handle.isPausedRecv) { - curl.pause(curl.handle.pauseFlags & ~CurlPause.Recv) + if (curl.handle.isPausedSend) { + curl.pause(curl.handle.pauseFlags & ~CurlPause.Send) } }) @@ -863,8 +863,8 @@ describe.runIf(Curl.isVersionGreaterOrEqualThan(7, 56, 0))('CurlMime', () => { .setDataStream( stream, () => { - if (curl.handle.isPausedRecv) { - curl.pause(curl.handle.pauseFlags & ~CurlPause.Recv) + if (curl.handle.isPausedSend) { + curl.pause(curl.handle.pauseFlags & ~CurlPause.Send) } }, testData.length, @@ -913,8 +913,8 @@ describe.runIf(Curl.isVersionGreaterOrEqualThan(7, 56, 0))('CurlMime', () => { .addPart() .setName('buffer_stream') .setDataStream(stream, () => { - if (curl.handle.isPausedRecv) { - curl.pause(curl.handle.pauseFlags & ~CurlPause.Recv) + if (curl.handle.isPausedSend) { + curl.pause(curl.handle.pauseFlags & ~CurlPause.Send) } }) @@ -961,8 +961,8 @@ describe.runIf(Curl.isVersionGreaterOrEqualThan(7, 56, 0))('CurlMime', () => { .addPart() .setName('chained_stream') .setDataStream(stream, () => { - if (curl.handle.isPausedRecv) { - curl.pause(curl.handle.pauseFlags & ~CurlPause.Recv) + if (curl.handle.isPausedSend) { + curl.pause(curl.handle.pauseFlags & ~CurlPause.Send) } }) .setType('text/plain') @@ -1012,8 +1012,8 @@ describe.runIf(Curl.isVersionGreaterOrEqualThan(7, 56, 0))('CurlMime', () => { .addPart() .setName('chunked_stream') .setDataStream(stream, () => { - if (curl.handle.isPausedRecv) { - curl.pause(curl.handle.pauseFlags & ~CurlPause.Recv) + if (curl.handle.isPausedSend) { + curl.pause(curl.handle.pauseFlags & ~CurlPause.Send) } }) @@ -1058,8 +1058,8 @@ describe.runIf(Curl.isVersionGreaterOrEqualThan(7, 56, 0))('CurlMime', () => { .addPart() .setName('empty_stream') .setDataStream(stream, () => { - if (curl.handle.isPausedRecv) { - curl.pause(curl.handle.pauseFlags & ~CurlPause.Recv) + if (curl.handle.isPausedSend) { + curl.pause(curl.handle.pauseFlags & ~CurlPause.Send) } })