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
Memory leak when a stream in the pipeline gets destroyed unexpectedly #368
We are using needle in one of our projects and we have noticed a slow but steady increase in the tcp memory buffers. The linux command used to view the tcp memory pages allocation is cat /proc/net/sockstat.
Every time we killed the service, the memory consumption dropped significantly so we started digging.
Before kill:
After kill:
After plenty of tests we started noticing that the needle streams we use, do not fire the close event. Now, I am not sure where exactly the problem is, but if the close event does not fire, it means that the underlying resources have not been destroyed. If they are not getting destroyed than that is consistent with the tcp memory pages increase we have been seeing.
How to reproduce
The following sample code shows the problem:
const needle = require('needle');
const {pipeline} = require('stream');
const {createWriteStream} = require('fs');
const url = 'https://releases.ubuntu.com/21.04/ubuntu-21.04-desktop-amd64.iso.torrent';
const readStream = needle.get(url);
readStream.on('end', () => console.log('needle END event fired')); // FIRES JUST FINE
readStream.on('close', () => console.log('needle CLOSE event fired')); // DOES NOT FIRE
const writeStream = createWriteStream('ubuntu-21.04-desktop-amd64.iso.torrent');
pipeline(readStream, writeStream, (error) => {
if (error) {
console.log(error);
}
// By uncommenting the next line, the CLOSE event WILL fire, but it shouldn't be required.
// readStream.destroy();
});
Environment
node.js version: v12.16.1 needle version: v2.8.0
Please let me know if I can help in any other way to get to the bottom of this. Thank you for your time.
Hmmm, this is indeed strange. Are you using the 'close' event to trigger a specific behaviour and/or perform additional actions on the stream or connection?
No, we are not doing anything else after that. The 'close' event is not used at all in our production, I've only added it for debugging purposes to figure out what was going on with the tcp memory increase.
The flow of the actual code is very similar to the sample code I gave you above.
I am running the sample code above as-is without any changes with node main.js and I reproduce the problem. No extra connection options. Did you run it and got different results?
I was also able to reproduce it with needle v2.4.0 and needle v2.5.2 as well.
The problem is also reproducible with node.js v10.16.0 as well from a test run I just did.
Hey @tomas. Did you have a chance to look at this the other day?
I am looking at this line and I am wondering why are you calling this.destroy manually here? Shouldn't the socket close automatically as soon as it receives the FIN packet?
So, I run the test again with DEBUG=needle* node main and the following is the full debug output:
DEBUG=needle* node main.js
needle Making request #1 {
localAddress: undefined,
lookup: undefined,
protocol: 'https:',
host: 'releases.ubuntu.com',
port: 443,
path: '/21.04/ubuntu-21.04-desktop-amd64.iso.torrent',
method: 'get',
headers: {
accept: '*/*',
'user-agent': 'Needle/2.7.0 (Node.js v12.16.1; darwin x64)',
host: 'releases.ubuntu.com'
}
} +0ms
needle Got response 200 {
date: 'Thu, 05 Aug 2021 14:27:27 GMT',
server: 'Apache/2.4.29 (Ubuntu)',
'last-modified': 'Thu, 22 Apr 2021 14:43:25 GMT',
etag: '"34978-5c090b3a9db31"',
'accept-ranges': 'bytes',
'content-length': '215416',
connection: 'close',
'content-type': 'application/x-bittorrent'
} +523ms
MAIN.JS END event fired <==== This is console.log from main.js
needle on_socket_end returned: 1, this.writeable: undefined, this.destroyed true +515ms <== This is an extra debug msg I added
So the last two lines on the log above indicate that the socket END event is fired normally and the code block from here does NOT execute, which from what I understand is the correct behavior unless something really unexpected has happened.
However, I did force line block to execute (which means that this.destroy was called) and the CLOSE event still does not fire. Full debug run:
Sorry for the delay but I was trying to get to the bottom of this.
So, the memory leak is real. If the readable stream, for some reason gets destroyed before properly ending, then the following error occurs:
Error [ERR_STREAM_PREMATURE_CLOSE]: Premature close
at ServerResponse.onclose (internal/streams/end-of-stream.js:80:15)
at ServerResponse.emit (events.js:323:22)
at ServerResponse.EventEmitter.emit (domain.js:482:12)
at Socket.onServerResponseClose (_http_server.js:209:44)
at Socket.emit (events.js:323:22)
at Socket.EventEmitter.emit (domain.js:482:12)
at TCP.<anonymous> (net.js:668:12)
However, the above will not trigger an 'error' event (haven't figured out why) which means that all the streams that were piped, will be left hanging.
One solution is to use stream.pipeline instead of .pipe which handles the above error automatically, plus, proper cleanup of the whole pipeline. With the changes I did in this pull request, the tcp memory goes back to 0 even when an ERR_STREAM_PREMATURE_CLOSE occurs.
I did try to see if I can make it work without using stream.pipeline by using the IncomingMessage 'aborted' and 'error' events on the resp object like so:
varrequest=protocol.request(request_opts,function(resp){resp.on('aborted',function(){// Did not fire on ERR_STREAM_PREMATURE_CLOSE});resp.on('error',function(){// Did not fire on ERR_STREAM_PREMATURE_CLOSE});resp.on('close',function(){// Did fire so perhaps some error handling can be made here by checking this.destroyed ?});
.....code}
Based on the documentation here the above events should have fired:
In the case of a premature connection close after the response is received, the following events will be emitted in the following order:
'socket'
'response'
'data' any number of times, on the res object
(connection closed here)
'aborted' on the res object
'error' on the res object with an error with message 'Error: aborted' and code 'ECONNRESET'.
'close'
'close' on the res object
I have also searched to see if anyone else has seen a similar issue and found this and this. Unfortunately, as I said above, the event handlers did not fire so I might be missing something from within the needle code that affects the listeners or they simply do not fire because they do not need to.
Feel free to do your own tests as well and to check my pull request to see the exact changes I am referring to. Let me know how do you want to handle this so we can try and fix it!
Cheers! I will try to figure out why the hell the error event does not trigger on premature close in the meanwhile. Probably some crazy node.js streams thing.
changed the title [-]Memory leak when stream gets destroyed unexpectedly[/-][+]Memory leak when read stream gets destroyed unexpectedly[/+]on Aug 11, 2021
changed the title [-]Memory leak when read stream gets destroyed unexpectedly[/-][+]Memory leak when a stream in the pipeline gets destroyed unexpectedly[/+]on Aug 11, 2021
Issue #280 from 2019 is basically the same problem but reproduced in a different way. If a stream in the pipeline gets destroyed before ending properly, a memory leak happens since the file descriptor stays open. You can also reproduce the ERR_STREAM_PREMATURE_CLOSE if you use the following sample code:
constneedle=require('needle');const{pipeline}=require('stream');const{createWriteStream}=require('fs');varurl='SOME BIG ASS REMOTE FILE URL';constreadStream=needle.get(url);constwriteStream=createWriteStream(`./out.dat`);pipeline(readStream,writeStream,(error)=>{if(error){console.log(error);// By uncommenting the next line, the underlying request stream will be destroyed properly.// Needs to be handled internally.// readStream.request.destroy();}});setTimeout(function(){console.log("destroying write stream...");writeStream.destroy();},2000);setInterval(function(){// Keep alive},3000);
From what I understand, if you do not wish to use stream.pipeline internally - although I STRONGLY recommend that you do -, you need to attach event handlers on each stream on the pipe, since .pipe is not build to forward anything, and gracefully destroy the whole chain.
I have also updated the title of this issue because it was misleading.
Since this is a serious issue and has been affecting us severely I have pushed my branch to production after further testing yesterday and I can confirm that tcp memory usage seems to be very low now, as expected.
Looking forward to the officially fixed version ;)
Hey @alolis, I'm running the "How to reproduce" code you included in the first post, but with the merged PR the close event still doesn't fire unless calling readStream.destroy(). I'm just wondering if you had tested that or if the .pipeline change was simply supposed to handle the memleak and not the close event not being fired.
Hey @tomas! That's normal since I did not change the PassThrough stream you were using underneath and those stream types do not fire a close event as per documentation!
I see. I have another branch where I make sure all pipe destinations also emit the done event, so we can have a consistent API regardless of whatever the final stream is (Needle's Passthrough or a FS writeStream).
Activity
[-]Stream 'close' event not getting fired[/-][+]Stream 'close' event not firing[/+]tomas commentedon Jul 29, 2021
Hmmm, this is indeed strange. Are you using the 'close' event to trigger a specific behaviour and/or perform additional actions on the stream or connection?
alolis commentedon Jul 29, 2021
Hi @tomas,
No, we are not doing anything else after that. The 'close' event is not used at all in our production, I've only added it for debugging purposes to figure out what was going on with the tcp memory increase.
The flow of the actual code is very similar to the sample code I gave you above.
tomas commentedon Jul 29, 2021
What options are you using regarding the connection?
Connection: keep-alive?Also, does this also happen on earlier versions of Node or Needle?
alolis commentedon Jul 29, 2021
I am running the sample code above as-is without any changes with
node main.jsand I reproduce the problem. No extra connection options. Did you run it and got different results?I was also able to reproduce it with
needle v2.4.0andneedle v2.5.2as well.The problem is also reproducible with
node.js v10.16.0as well from a test run I just did.tomas commentedon Jul 29, 2021
I see. I'll take a look at this later today. But you certainly shouldn't have to destroy the stream manually.
I'm thinking it might have to do with this (if you want to fiddle with the code right away).
alolis commentedon Jul 29, 2021
Thanks! I will have a look at the link you provided as well.
alolis commentedon Aug 2, 2021
Hey @tomas. Did you have a chance to look at this the other day?
I am looking at this line and I am wondering why are you calling
this.destroymanually here? Shouldn't the socket close automatically as soon as it receives theFINpacket?tomas commentedon Aug 2, 2021
That is meant to handle unexpected errors in the underlying socket. Take a look at this test for an example.
Does removing that line fix the issue, by the way?
alolis commentedon Aug 5, 2021
Hey @tomas ,
Sorry for the small delay.
So, I run the test again with
DEBUG=needle* node mainand the following is the full debug output:So the last two lines on the log above indicate that the socket END event is fired normally and the code block from here does NOT execute, which from what I understand is the correct behavior unless something really unexpected has happened.
However, I did force line block to execute (which means that
this.destroywas called) and the CLOSE event still does not fire. Full debug run:Nope, I removed the whole block, and does not seem to do any difference.
I also added a
socket.once('close')handler above the line here and the socket seems to close.alolis commentedon Aug 5, 2021
Hm, I see that you are using a
PassThroughstream underneath, which is a transform stream and those do not emit acloseevent.tomas commentedon Aug 5, 2021
Ahh yes, indeed. But why would that trigger the memory increase you're seeing then?
alolis commentedon Aug 5, 2021
Oh man...goodbye sleep...I will investigate some more...
alolis commentedon Aug 5, 2021
I think I found the memory leak but I want to make 100% sure so I will update you in the morning. I need to re-run everything with a clear head!
tomas commentedon Aug 5, 2021
Sure thing!
alolis commentedon Aug 11, 2021
Hey @tomas,
Sorry for the delay but I was trying to get to the bottom of this.
So, the memory leak is real. If the readable stream, for some reason gets destroyed before properly ending, then the following error occurs:
However, the above will not trigger an 'error' event (haven't figured out why) which means that all the streams that were piped, will be left hanging.
One solution is to use
stream.pipelineinstead of.pipewhich handles the above error automatically, plus, proper cleanup of the whole pipeline. With the changes I did in this pull request, the tcp memory goes back to 0 even when anERR_STREAM_PREMATURE_CLOSEoccurs.I did try to see if I can make it work without using
stream.pipelineby using theIncomingMessage'aborted' and 'error' events on therespobject like so:Based on the documentation here the above events should have fired:
I have also searched to see if anyone else has seen a similar issue and found this and this. Unfortunately, as I said above, the event handlers did not fire so I might be missing something from within the
needlecode that affects the listeners or they simply do not fire because they do not need to.Feel free to do your own tests as well and to check my pull request to see the exact changes I am referring to. Let me know how do you want to handle this so we can try and fix it!
tomas commentedon Aug 11, 2021
Wow, you really DID get to the bottom of it! Kudos!
I'm a bit busy now but will take a look later.
alolis commentedon Aug 11, 2021
Cheers! I will try to figure out why the hell the
errorevent does not trigger on premature close in the meanwhile. Probably some crazy node.js streams thing.[-]Stream 'close' event not firing[/-][+]Memory leak when stream gets destroyed unexpectedly[/+][-]Memory leak when stream gets destroyed unexpectedly[/-][+]Memory leak when read stream gets destroyed unexpectedly[/+][-]Memory leak when read stream gets destroyed unexpectedly[/-][+]Memory leak when a stream in the pipeline gets destroyed unexpectedly[/+]alolis commentedon Aug 11, 2021
Issue #280 from 2019 is basically the same problem but reproduced in a different way. If a stream in the pipeline gets destroyed before ending properly, a memory leak happens since the file descriptor stays open. You can also reproduce the
ERR_STREAM_PREMATURE_CLOSEif you use the following sample code:From what I understand, if you do not wish to use
stream.pipelineinternally - although I STRONGLY recommend that you do -, you need to attach event handlers on each stream on the pipe, since.pipeis not build to forward anything, and gracefully destroy the whole chain.I have also updated the title of this issue because it was misleading.
alolis commentedon Aug 12, 2021
Since this is a serious issue and has been affecting us severely I have pushed my branch to production after further testing yesterday and I can confirm that tcp memory usage seems to be very low now, as expected.
Looking forward to the officially fixed version ;)
tomas commentedon Aug 25, 2021
Hey @alolis, I'm running the "How to reproduce" code you included in the first post, but with the merged PR the
closeevent still doesn't fire unless callingreadStream.destroy(). I'm just wondering if you had tested that or if the.pipelinechange was simply supposed to handle the memleak and not thecloseevent not being fired.alolis commentedon Aug 26, 2021
Hey @tomas! That's normal since I did not change the
PassThroughstream you were using underneath and those stream types do not fire acloseevent as per documentation!tomas commentedon Aug 26, 2021
I see. I have another branch where I make sure all pipe destinations also emit the
doneevent, so we can have a consistent API regardless of whatever the final stream is (Needle's Passthrough or a FS writeStream).alolis commentedon Aug 26, 2021
If you are asking how to do this with
stream.pipelinethen you can do it in the pipeline callback:Is this what you want?
tomas commentedon Aug 26, 2021
Nope, not really. Thanks anyway :)