Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I get the PTY.spawn child exit code?

Tags:

linux

io

ssh

ruby

I'm trying to manage a SSH connection to a network device via the PTY module, with a code similar to this:

cmd_line = "ssh [email protected]"
begin
  PTY.spawn(cmd_line) do |r_f,w_f,pid|
  ...
rescue PTY::ChildExited => cended
  ...
end

The whole I/O works pretty well, however I don't know how to get the exit status of the child process.

For instance, if the connection is broken or simply times out, the spawned process will terminate with an error code, but this code does not seem to be returned in the $? special variable.

like image 363
devlearn Avatar asked Oct 06 '10 16:10

devlearn


2 Answers

TLDR

Use 1.9.2 and wait on the PTY process to correctly set $?

PTY.spawn(command) do |r,w,pid|
  # ...
  Process.wait(pid)
end

Full Story

On 1.9.2 you can capture the exit status for PTY by calling wait on the PTY pid. This works out almost all the time (AFAIK). The only exceptions I know of are with edge cases like exiting immediately or issuing an empty string for a command (see http://redmine.ruby-lang.org/issues/5253).

For example:

require 'pty'
require 'test/unit'

class PTYTest < Test::Unit::TestCase
  def setup
    system "true"
    assert_equal 0, $?.exitstatus
  end

  def pty(cmd, &block)
    PTY.spawn(cmd, &block)
    $?.exitstatus
  end

  def test_pty_with_wait_correctly_sets_exit_status_for_master_slave_io
    status = pty("printf 'abc'; exit 8") do |r,w,pid|
      while !r.eof?
        r.getc
      end
      Process.wait(pid)
    end
    assert_equal 8, status
  end

  def test_pty_with_wait_correctly_sets_exit_status_for_basic_commands
    status = pty("true") do |r,w,pid|
      Process.wait(pid)
    end
    assert_equal 0, status

    status = pty("false") do |r,w,pid|
      Process.wait(pid)
    end
    assert_equal 1, status
  end

  def test_pty_with_wait_sets_exit_status_1_for_immediate_exit
    status = pty("exit 8") do |r,w,pid|
      Process.wait(pid)
    end
    assert_equal 1, status
  end

  def test_pty_with_kill
    status = pty("sleep 10") do |r,w,pid|
      Process.kill(9, pid)
      Process.wait(pid)
    end

    assert_equal nil, status
  end
end

Now run the test:

$ ruby -v
ruby 1.9.2p290 (2011-07-09 revision 32553) [x86_64-darwin10.8.0]
$ ruby example.rb
Loaded suite example
Started
....
Finished in 1.093349 seconds.

4 tests, 9 assertions, 0 failures, 0 errors, 0 skips

Test run options: --seed 31924

On 1.8.7 you need to do a bit more. In older rubies PTY would often exit with PTY::ChildExited errors, even when you wait for the PTY process to finish. As a result, if you run the tests as written you get this:

$ ruby -v
ruby 1.8.7 (2010-08-16 patchlevel 302) [i686-darwin10.4.0]
$ ruby example.rb
Loaded suite example
Started
EE.E
Finished in 1.170357 seconds.

  1) Error:
test_pty_with_kill(PTYTest):
PTY::ChildExited: pty - exited: 35196
    example.rb:11:in `test_pty_with_kill'
    example.rb:11:in `spawn'
    example.rb:11:in `pty'
    example.rb:45:in `test_pty_with_kill'

  2) Error:
test_pty_with_wait_correctly_sets_exit_status_for_basic_commands(PTYTest):
PTY::ChildExited: pty - exited: 35198
    example.rb:11:in `test_pty_with_wait_correctly_sets_exit_status_for_basic_commands'
    example.rb:11:in `spawn'
    example.rb:11:in `pty'
    example.rb:26:in `test_pty_with_wait_correctly_sets_exit_status_for_basic_commands'

  3) Error:
test_pty_with_wait_sets_exit_status_1_for_immediate_exit(PTYTest):
PTY::ChildExited: pty - exited: 35202
    example.rb:11:in `test_pty_with_wait_sets_exit_status_1_for_immediate_exit'
    example.rb:11:in `spawn'
    example.rb:11:in `pty'
    example.rb:38:in `test_pty_with_wait_sets_exit_status_1_for_immediate_exit'

4 tests, 5 assertions, 0 failures, 3 errors

Notice ALMOST all the tests bomb with a ChildExited error, but one (incidentally the one representing the most realistic use of PTY) succeeds as expected. Surely this erratic behavior is a bug and, as already shown, it has been fixed in 1.9.2.

There is a partial workaround, however. You can specifically handle the ChildExited errors using something like this:

def pty(cmd, &block)
  begin
    PTY.spawn(cmd, &block)
    $?.exitstatus
  rescue PTY::ChildExited
    $!.status.exitstatus
  end
end

Insert that, run the tests again, and you get results consistent with 1.9.2, with the BIG caveat that $? will not be set correctly (unlike 1.9.2). Specifically if you were to add this test:

def test_setting_of_process_status
  system "true"
  assert_equal 0, $?.exitstatus

  begin
    PTY.spawn("false") do |r,w,pid|
      Process.wait(pid)
    end
  rescue PTY::ChildExited
  end
  assert_equal 1, $?.exitstatus
end

You get success on 1.9.2 and you get failure on 1.8.7. In the 1.8.7 case the PTY completes via the ChildExited error -- the Process.wait never gets called and thus never sets $?. Instead the $? from the 'system "true"' persists and you get 0 instead of 1 as the exit status.

The behavior of $? is hard to follow and has more caveats that I won't get into (ie sometimes the PTY will complete via the Process.wait).

like image 106
Simon Chiang Avatar answered Nov 14 '22 07:11

Simon Chiang


Ok, here are some possible solutions for this problem :

  • use ruby 1.9.2 PTY.check() method

  • wrap the command line in a script

Unfortunately I can't use the latest version of ruby as so I used the wrapper solution, that echoes $? to a file at the end of the wrapper script. The exit code is read when the spawned child exits.

Of course if something interrupts the execution of the wrapper script itself, then we'll never get the result file ...

But at least this workaround can be used for 1.8.7/1.9.1 versions of Ruby

like image 45
devlearn Avatar answered Nov 14 '22 05:11

devlearn