With node-libcurl 5.1.2 on Node.js 26.10.0 (macOS arm64 and Ubuntu CI), a finished transfer does not emit end until some other JavaScript callback runs in the process. libcurl has already received the full response by then. The same code on Node.js 24.16.0 emits end right away. Both Node.js versions bundle libuv 1.52.1.
Repro
import { openSync } from 'node:fs';
import { Curl } from 'node-libcurl';
// Any HTTP server in another process, e.g.:
// node -e "require('http').createServer((q,s)=>{q.resume();q.on('end',()=>s.end('ok'))}).listen(47126)"
const start = Date.now();
const curl = new Curl();
curl.setOpt('URL', 'http://127.0.0.1:47126/');
curl.setOpt('UPLOAD', true);
curl.setOpt('READDATA', openSync('some-file', 'r'));
curl.setOpt('NOPROGRESS', false);
let seen = false;
curl.setProgressCallback((_dlt, dl) => {
if (dl > 0 && !seen) { seen = true; console.log('body received', Date.now() - start); }
return 0;
});
curl.on('end', () => { console.log(process.version, 'end', Date.now() - start); process.exit(0); });
curl.perform();
if (process.argv[2] === 'tick') setInterval(() => {}, 300);
setTimeout(() => { console.log('still pending after 4 s'); process.exit(2); }, 4000);
| Run |
body received |
end |
| Node.js 24.16.0 |
1 ms |
2 ms |
| Node.js 26.10.0 |
4 ms |
never (still pending after 4 s) |
Node.js 26.10.0 with tick |
3 ms |
303 ms, the first interval tick |
end fires on the first unrelated timer tick. So the completion looks like it is queued as a microtask, probably the multi.perform() promise being resolved from a libuv poll or timer callback, and the queue is not drained until the next JS callback. My guess is that the resolve in Multi::CallOnMessageCallback runs without a callback scope (Napi::CallbackScope or napi_open_callback_scope), and Node.js 26 no longer drains microtasks after such native callbacks. I have not verified this.
Other effects
- GET requests to an HTTP server in the same process: once the keep-alive connection is reused, each transfer takes about 1000 ms instead of about 1 ms.
FORBID_REUSE: true avoids it. FRESH_CONNECT: true does not.
- If the caller closes the handle while
end is still pending (for example, after its own timeout), the queued onEnd later throws an uncaught CurlEasyError: Curl handle is closed at this.handle.getInfo(Curl.info.RESPONSE_CODE) (dist/Curl.js:224), which crashes the process.
Found while testing request-libcurl: veliovgroup/request-extra#16
With node-libcurl 5.1.2 on Node.js 26.10.0 (macOS arm64 and Ubuntu CI), a finished transfer does not emit
enduntil some other JavaScript callback runs in the process. libcurl has already received the full response by then. The same code on Node.js 24.16.0 emitsendright away. Both Node.js versions bundle libuv 1.52.1.Repro
endtickendfires on the first unrelated timer tick. So the completion looks like it is queued as a microtask, probably themulti.perform()promise being resolved from a libuv poll or timer callback, and the queue is not drained until the next JS callback. My guess is that the resolve inMulti::CallOnMessageCallbackruns without a callback scope (Napi::CallbackScopeornapi_open_callback_scope), and Node.js 26 no longer drains microtasks after such native callbacks. I have not verified this.Other effects
FORBID_REUSE: trueavoids it.FRESH_CONNECT: truedoes not.endis still pending (for example, after its own timeout), the queuedonEndlater throws an uncaughtCurlEasyError: Curl handle is closedatthis.handle.getInfo(Curl.info.RESPONSE_CODE)(dist/Curl.js:224), which crashes the process.Found while testing request-libcurl: veliovgroup/request-extra#16