-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Description
If a standard file descriptor is a TTY, we're duplicating it at the start of the Crystal process in order to set the duplicate to non-blocking without affecting the original file descriptor. See #6518 for a part of the background story.
crystal/src/crystal/system/unix/file_descriptor.cr
Lines 258 to 275 in 6d151b8
| def self.from_stdio(fd) | |
| # If we have a TTY for stdin/out/err, it is possibly a shared terminal. | |
| # We need to reopen it to use O_NONBLOCK without causing other programs to break | |
| # Figure out the terminal TTY name. If ttyname fails we have a non-tty, or something strange. | |
| # For non-tty we set flush_on_newline to true for reasons described in STDOUT and STDERR docs. | |
| path = uninitialized UInt8[256] | |
| ret = LibC.ttyname_r(fd, path, 256) | |
| return IO::FileDescriptor.new(fd).tap(&.flush_on_newline=(true)) unless ret == 0 | |
| clone_fd = LibC.open(path, LibC::O_RDWR | LibC::O_CLOEXEC) | |
| return IO::FileDescriptor.new(fd).tap(&.flush_on_newline=(true)) if clone_fd == -1 | |
| # We don't buffer output for TTY devices to see their output right away | |
| io = IO::FileDescriptor.new(clone_fd) | |
| io.sync = true | |
| io | |
| end |
When executing a new process, we must revert this trickery before exec so that the new process image sees the standard file descriptors at the original numbers. The motivation for this is that the process might have altered the duplicated file descriptors which would not have had any effect on the original ones.
crystal/src/crystal/system/unix/process.cr
Lines 472 to 486 in 6d151b8
| private def self.reopen_io(src_io : IO::FileDescriptor, dst_io : IO::FileDescriptor) | |
| if src_io.closed? | |
| Crystal::EventLoop.remove(dst_io) | |
| dst_io.file_descriptor_close | |
| else | |
| src_io = to_real_fd(src_io) | |
| # dst_io.reopen(src_io) | |
| ret = LibC.dup2(src_io.fd, dst_io.fd) | |
| raise IO::Error.from_errno("dup2") if ret == -1 | |
| FileDescriptor.set_blocking(dst_io.fd, true) | |
| dst_io.close_on_exec = false | |
| end | |
| end |
The major steps are:
- Duping the duplicate back into the original. This is entirely fine.
- Disabling
O_CLOEXECon the original should be safe: it operates on the file descriptor which is exclusive to the child process. - Disabling
O_NONBLOCKhas unintended effects: it operates on the underlying file description, which is shared between parent and child process. Setting the child's stdio to blocking changes the parent's blocking status as well.
Demonstration:
# stdin is not a TTY
IO::FileDescriptor.get_blocking(STDIN.fd) # => false
Process.run("true", input: :inherit)
IO::FileDescriptor.get_blocking(STDIN.fd) # => trueThe major problem with this code is that it applies to all IOs, even those that were never duped in the first place. It sets all file descriptors to blocking, regardless of their original configuration. 🤦
This may not have drastic practical effects, which probably explains why nobody has noticed before. But it does not behave as expected with potentially breaking semantics.
I'm not sure if this is fixable while preserving behaviour. There might be some means to work around this issue by adding even more complexity on top.
Perhaps the duplicated standard IOs could be made aware of their special function and directly forward changes to the original file descriptors so we wouldn't have to dup them back.
Even better would be if we could remove the need for duplicating the standard streams in the first place. It's a lot of complexity and unexpected behaviour (see #14580 for example).
I'm not even sure why exactly we need this. It has been in the language since practically forever and has changed shape over time a lot. Maybe with the recent enhancements in event loop and detachable schedulers (#15871) we could avoid this?
Related: #15652
Add a 👍 reaction to issues you find important.