You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Node.js 26: 'end' is not delivered until another JS callback runs #454
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)"conststart=Date.now();constcurl=newCurl();curl.setOpt('URL','http://127.0.0.1:47126/');curl.setOpt('UPLOAD',true);curl.setOpt('READDATA',openSync('some-file','r'));curl.setOpt('NOPROGRESS',false);letseen=false;curl.setProgressCallback((_dlt,dl)=>{if(dl>0&&!seen){seen=true;console.log('body received',Date.now()-start);}return0;});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.
changed the title [-]Node.js 26: transfers stall until the ~1 s fallback timer (in-process server, READDATA fd uploads)[/-][+]Node.js 26: 'end' is not delivered until another JS callback runs[/+]on Sep 30, 2026
The guess is right, and BrightSec#41 fixes it by opening a Napi::CallbackScope in Multi::OnSocket and Multi::OnTimeout. It is published as @brightsec/node-libcurl 5.1.3 and later.
The Node.js change behind it is nodejs/node@2a672ee9e8 ("src: fast path empty native immediate drain", nodejs/node#62969, first in v26.4.0). Before it, an empty callback scope in CheckImmediate ran the microtask queue on every loop iteration, which hid the missing scope. It shows on macOS too.
Until then, Multi.addHandle() with Multi.onMessage() is not affected: it calls the callback directly instead of resolving a promise.
@JCMais would you take this fix upstream? @aborovsky it's your change in BrightSec#41; would you like to open it here? If not, I'm happy to port it with credit.
Thanks @puckey — your analysis is exactly right, and great catch pinning it to nodejs/node#62969. The empty-scope CheckImmediate microtask drain was indeed what hid the missing callback scope before v26.4.0.
Happy to bring it upstream myself — opened #455 against develop. It's the same fix as BrightSec#41: open a Napi::CallbackScope in Multi::OnSocket and Multi::OnTimeout, backed by a single long-lived Napi::AsyncContext per Multi so there's no napi_async_init per socket event. Easy::CallSocketEvent, the other libuv→JS entry point, already goes through MakeCallback with an async context, so it needed no change.
It includes a regression test that serializes requests against a local server and asserts end fires without waiting for the multi timer — covering both the notification-API drain path (libcurl >= 8.17) and the polling fallback (shouldUseNotificationsApi: false). Both legs fail on the unfixed addon (~1s/req on Node.js 26 / Linux) and pass with it.
Opened as a draft so CI can run it across the Node.js 24/25/26 matrix before review. @JCMais let me know if you'd like anything adjusted to fit upstream conventions.
Activity
[-]Node.js 26: transfers stall until the ~1 s fallback timer (in-process server, READDATA fd uploads)[/-][+]Node.js 26: 'end' is not delivered until another JS callback runs[/+]🧪 test: node 26 / work around node-libcurl completion delay (#16)
puckey commentedon Oct 23, 2026
The guess is right, and BrightSec#41 fixes it by opening a
Napi::CallbackScopeinMulti::OnSocketandMulti::OnTimeout. It is published as@brightsec/node-libcurl5.1.3 and later.The Node.js change behind it is nodejs/node@2a672ee9e8 ("src: fast path empty native immediate drain", nodejs/node#62969, first in v26.4.0). Before it, an empty callback scope in
CheckImmediateran the microtask queue on every loop iteration, which hid the missing scope. It shows on macOS too.Until then,
Multi.addHandle()withMulti.onMessage()is not affected: it calls the callback directly instead of resolving a promise.@JCMais would you take this fix upstream? @aborovsky it's your change in BrightSec#41; would you like to open it here? If not, I'm happy to port it with credit.
aborovsky commentedon Oct 23, 2026
Thanks @puckey — your analysis is exactly right, and great catch pinning it to nodejs/node#62969. The empty-scope
CheckImmediatemicrotask drain was indeed what hid the missing callback scope before v26.4.0.Happy to bring it upstream myself — opened #455 against
develop. It's the same fix as BrightSec#41: open aNapi::CallbackScopeinMulti::OnSocketandMulti::OnTimeout, backed by a single long-livedNapi::AsyncContextperMultiso there's nonapi_async_initper socket event.Easy::CallSocketEvent, the other libuv→JS entry point, already goes throughMakeCallbackwith an async context, so it needed no change.It includes a regression test that serializes requests against a local server and asserts
endfires without waiting for the multi timer — covering both the notification-API drain path (libcurl >= 8.17) and the polling fallback (shouldUseNotificationsApi: false). Both legs fail on the unfixed addon (~1s/req on Node.js 26 / Linux) and pass with it.Opened as a draft so CI can run it across the Node.js 24/25/26 matrix before review. @JCMais let me know if you'd like anything adjusted to fit upstream conventions.