Repository navigation
Cannot properly close/cleanup character device ReadStream (process hangs) #15439
Description
Issue observed on two distinct systems:
-
System 1. Linux laptop
- Version: 8.4.0
- Platform: Arch Linux 4.11.9 (x86_64)
-
System 2. Beaglebone Black
- Version: 6.10.3
- Platform: Linux 4.9.41 (armv71)
I'm using fs.ReadStream to continuously read from a character device, e.g. /dev/input/event0. Receiving events works great, but on attempting to shut down the process does not exit as expected. Googling has turned up similar-sounding issues all the way back to node-0.x, but I have been unable to find a solution.
Code to reproduce (behaves the same on both aforementioned systems):
const fs = require('fs')
const stream = fs.createReadStream('/dev/input/event0')
stream
.on('end', () => { console.log('End') })
.on('open', fd => { console.log(`Opened (fd=${fd})`) })
.on('close', () => { console.log('Closed') })
.on('error', err => { console.log(`Error: ${err}`) })
.on('data', data => { console.log(data) })
setTimeout(() => {
console.log('Attempting to close stream')
stream.on('close', () => {
console.log(`Stream fd is now ${stream.fd}`)
process.exit(0) // Hangs
})
stream.close()
}, 1000)Output:
Opened (fd=9)
Attempting to close stream
Closed
Stream fd is now null
(Hung here, ^C or kill process to exit)
A few interesting (?) observations:
Explicitly obtaining the file descriptor we get identical results, unless if the .on('data', ...) listener is not attached, in which case we can exit as expected. Removing the listener in the shutdown process does not work, however, nor do we get this behavior if the fd is opened by fs.createReadStream (i.e. simply commenting out the .on('data', ...) from the first sample. Adjusted sample:
const fs = require('fs')
const fd = fs.openSync('/dev/input/event0', 'r')
const stream = fs.createReadStream('ignored', { fd: fd })
stream
.on('end', () => { console.log('End') })
//.on('open', fd => { console.log(`Opened (fd=${fd})`) }) // No event, as expected
.on('close', () => { console.log('Closed') })
.on('error', err => { console.log(`Error: ${err}`) })
//.on('data', data => { console.log(data) }) // Exit works if commented out
setTimeout(() => {
console.log('Attempting to close stream')
//stream.removeAllListeners('data') // No result
stream.on('close', () => {
console.log(`Stream fd is now ${stream.fd}`)
process.exit(0)
})
stream.close()
//fs.closeSync(fd) // Does not trigger the `close` event
}, 1000)This is probably not related to the problem, but I get the same result if I "trigger" an end-event by doing the following in my shutdown routine as such:
// `.push(null)` and `.read(0)` together trigger an `end` event
stream.push(null)
stream.read(0)Is this a bug, or am I simply missing something? Cheers!
Activity
lfk commentedon Mar 27, 2018
Some additional information in case somebody else is having similar problems.
I'm fairly confident that this happens because the data event handler remains in the node event loop (even after calling
.destroy()or even explicitly unsetting all listeners). I'm by no means an expert on internals though, so please take that statement with a grain of salt.Output from a simple test using Why Is Node Running?:
Potential workaround
If an event is sent to the stream after close/destroy has been called, the process will end successfully without any errors. Thus, if you are able to simultaneously read and write from/to your stream source, you could ensure shutdown by artificially injecting some event in the
.on('close')-handler. Might be a workaround for some.A simple way to test the above is to use your keyboard or mouse as an input source, e.g.
/dev/input/by-id/usb-my-mouse:gireeshpunathil commentedon Apr 28, 2018
unable to reproduce this in macos, and with some other character devices in alpine linux too so looks like platform specific. Unable to access the said device in my Linux box, as it looks like that needs root permission.
Can you run with
strace -fand see the last few lines? that will give some clues as to why the process hangs.lfk commentedon Apr 30, 2018
Certainly. Here's a log as requested, starting just where we do the
stream.on('close')logging:Thanks for looking into this!
gireeshpunathil commentedon May 18, 2018
thanks @lfk for the data, and sorry! actually I missed your updates among the github notification flood.
minimal reproduce:
$ cat 15439.js
I have tried with
/dev/random, as I don't have access to the said device in my ststem.with
/dev/random, the code comes out after random intervals (apparently when the device reeives some random data at random intervals)My theory: As the file system work is carried out by internal worker thread, and it has initiated a blocking read from the device, and the data won't come, the main thread is unable to move ahead - it is held up in the polling for an async event that has to come from the said worker.
If I adjust (reduce) the timeout I observe race condition - process exits sometimes, else hangs.
I believe the race is between the read thread and the close thread. If the close happens first, read is abandoned. If the read is initiated first, nothing else matters.
/cc @nodejs/fs @nodejs/libuv
gireeshpunathil commentedon May 19, 2018
A C reproduce, I wasn't successful with a libuv recreate.
$ cat 15439.c
/dev/random. If you replace it with/dev/input/event0then only one read is required./dev/input/event0is operable only by root.sleep(1)holds the key. If you adjust that, race will be seen.The behavior is obvious, as the reader thread has entered the kernel and is oblivious to the closing of fd in main thread.
Fixing this at its core will be bit involving, as it would need detecting such cases and interrupting worker threads.
Given that it is a corner case, I recommend to document this as a limitation.
bnoordhuis commentedon May 22, 2018
This is not something libuv would (or could) fix but documenting it is a good way forward. Labels updated.
Aside:
fs.createReadStream()is the wrong tool for this. Unless it's a /dev device that doesn't support polling you should usenew net.Socket({ fd: ... }).lfk commentedon May 28, 2018
Thank you again @gireeshpunathil, really appreciate that you took the time to look into this and provide such a detailed breakdown.
Just for the sake of completeness, in my case the
net.Socket({ fd: ... })approach doesn't work, it yields aTypeError: Unsupported fd type: FILE. But as mentioned earlier, I've got a viable workaround.gireeshpunathil commentedon May 29, 2018
@lfk - sure. Do you want to attempt a documentation change to this effect and make it a PR? I guess you can best explain the limitation being the user who experienced it. Let me know!
doc: warn against streaming from character devices
doc: warn against streaming from character devices
doc: warn against streaming from character devices
doc: warn against streaming from character devices
kuba-orlik commentedon Mar 18, 2019
The workaround in the docs did not help me, but I managed to get it to work with:
doc: warn against streaming from character devices