Unable to get info: Bad file descriptor (IO::Error)

I stumbled across a problem when STDIN is closed.

test.cr is an empty file.

crystal run test.cr <&-
Unhandled exception: Unable to get info: Bad file descriptor (IO::Error)
  from /opt/homebrew/Cellar/crystal/1.12.1_1/bin/crystal in 'raise<IO::Error>:NoReturn'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/bin/crystal in 'Crystal::System::FileDescriptor::system_info<Int32>:File::Info'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/bin/crystal in 'IO::FileDescriptor::new<Int32>:IO::FileDescriptor'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/bin/crystal in 'IO::FileDescriptor::from_stdio<Int32>:IO::FileDescriptor'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/bin/crystal in '__crystal_main'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/bin/crystal in 'main'

There’s no error when I first build test.cr:

crystal build test.cr
./test <&-

However, with some other code, I have also seen the error after building.
In that case, I managed to get rid of the error like so (that doesn’t help in the case of test.cr above though):

# fix for closed STDIN:

require "io/file_descriptor"
class IO::FileDescriptor < IO
  
  def initialize(fd, blocking = nil, *, @close_on_finalize = true)
    @volatile_fd = Atomic.new(fd)
    @closed = system_closed?
    
    # --- new --------------------- {
    if ! @closed
    # --- new --------------------- }
      
      if blocking.nil?
        blocking =
          case system_info.type
          when .pipe?, .socket?, .character_device?
            false
          else
            true
          end
      end
      
      system_blocking_init(blocking)
      
    # --- new --------------------- {
    end
    # --- new --------------------- }
  end
end

I have yet to come up with a minimal example of how to reproduce the problem in that case.

Remotely related:

This is on darwin, aarch64.

crystal run test.cr <&- is actually equivalent to crystal build test.cr <&- && ./test. The redirected stdin is piped into the compiler.

So the empty file is entirely unnecessary. A minimal reproduction is crystal build <&-.

1 Like

I think this error should be handled in Crystal’s runtime setup. It should not break if stdin is closed.

2 Likes

Tracking issue: Crystal runtime crashes on closed file descriptor for STDIN · Issue #14569 · crystal-lang/crystal · GitHub

2 Likes

Alright, there you go:

test.cr:

puts STDIN.closed?  # or STDIN.tty?, or STDIN.class, ...
crystal build --release test.cr && ./test <&-
Unhandled exception: Unable to get info: Bad file descriptor (IO::Error)
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/system_error.cr:75 in 'system_info'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/io/file_descriptor.cr:48:14 in 'new'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/io/buffered.cr:240:5 in 'from_stdio'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/kernel.cr:25:1 in '__crystal_main'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/crystal/main.cr:129:5 in 'main'
crystal build test.cr && ./test <&-
Unhandled exception: Unable to get info: Bad file descriptor (IO::Error)
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/raise.cr:219:1 in 'raise'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/crystal/system/unix/file_descriptor.cr:81:7 in 'system_info'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/crystal/system/unix/file_descriptor.cr:88:5 in 'system_info'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/io/file_descriptor.cr:48:14 in 'initialize'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/io/file_descriptor.cr:42:3 in 'new'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/crystal/system/unix/file_descriptor.cr:190:12 in 'from_stdio'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/io/file_descriptor.cr:61:5 in 'from_stdio'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/kernel.cr:8:9 in '~STDIN:const_init'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/kernel.cr:25:1 in '__crystal_main'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/crystal/main.cr:129:5 in 'main_user_code'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/crystal/main.cr:115:7 in 'main'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/crystal/main.cr:141:3 in 'main'

This problem goes away if I fix it as described above.

STDERR:

test.cr:

puts STDERR.closed?
crystal build test.cr && ./test 2>&-
false

No error for closed STDERR.
Note that STDERR.closed? reports false.

STDOUT:

test.cr empty:

crystal build test.cr && ./test >&-
Unhandled exception: Unable to get info: Bad file descriptor (IO::Error)
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/raise.cr:219:1 in 'raise'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/crystal/system/unix/file_descriptor.cr:81:7 in 'system_info'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/crystal/system/unix/file_descriptor.cr:88:5 in 'system_info'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/io/file_descriptor.cr:48:14 in 'initialize'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/io/file_descriptor.cr:42:3 in 'new'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/crystal/system/unix/file_descriptor.cr:190:12 in 'from_stdio'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/io/file_descriptor.cr:61:5 in 'from_stdio'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/kernel.cr:25:10 in '~STDOUT:const_init'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/kernel.cr:42:1 in '__crystal_main'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/crystal/main.cr:129:5 in 'main_user_code'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/crystal/main.cr:115:7 in 'main'
  from /opt/homebrew/Cellar/crystal/1.12.1_1/share/crystal/src/crystal/main.cr:141:3 in 'main'
zsh: segmentation fault  ./test >&-

in Bash:

[...]
Segmentation fault: 11

This is probably be due to the fact that the standard stream constants are not mapped to the actual file descriptors. They are cloned from the original ones. So STDERR is not file descriptor 2, but a different file descriptor (typically 4 in a normal program; you can check STDERR.fd). And the closed state apparently doesn’t pass over to the clone.

1 Like
puts STDERR.closed?
puts STDERR.fd
puts IO::FileDescriptor.new(STDERR.fd).closed?
puts IO::FileDescriptor.new(0).closed?
puts IO::FileDescriptor.new(1).closed?
puts IO::FileDescriptor.new(2).closed?
crystal build test.cr && ./test 2>&-
false
3
false
false
false
false

Weird.

Also, for STDERR closed (2>&-) .tty? still returns true, for whatever reason.
For STDERR redirected (2>>/dev/null) things seem to work ok.
I get the impression that somewhere file descriptors might get mixed up.
Note how STDERR.fd usually is 4, but if STDERR is closed, STDERR.fd is 3. Is that expected?

If you close a file descriptor, that number becomes available for the next opened file descriptor. This is of course quite brittle. So it’s usually not recommended to close standard streams because this causes confusion.

1 Like

Note that the closed? method is not invoking a system call to check the actual status but just checking an instance variable. So that creating new file descriptors will not be considered closed until that particular instance is closed from within crystal.

2 Likes

Yeah, I have seen now that that’s what LibC.open returns, so it’s the expected behavior.

Are the other issues described here the same as issue 14569?

Yes, I’ve seen that system_closed? is cached.
Is that the expected behavior in general?
Is it the expected behavior if the file descriptor was closed right from the start?

Hm yeah the semantics of FileDescriptor#closed? seem quite unexpected.
I think it should always reflect the state of the sytem file descriptor, not the wrapper object.

I just checked what Ruby does – without implying that that’s what Crystal should do:

  • .closed? does not reflect the actual state.
  • .fileno is 0, 1, 2 for STDIN, STDOUT, STDERR.

Yeah, I don’t think Ruby needs to read from the standard streams non-blocking.
The reason for this duplication is that we want to read non-blockingly so we can swap in another fiber if we’re waiting for IO. But we shouldn’t modify the original file descriptors because they’re inherited from the parent process and would reflect back. More context: Use blocking IO on a TTY if it can't be reopened by Timbus · Pull Request #6660 · crystal-lang/crystal · GitHub