rubyexception

How can I exit a ruby program on strg-c if a SystemExit exception is being catched


The code which I can not interrupt by using STRG+C (CTRL+C) :

orig_std_out = STDOUT.clone
orig_std_err = STDERR.clone
STDOUT.reopen('/dev/null', 'w')
STDERR.reopen('/dev/null', 'w')

name = cookbook_name(File.join(path, 'Metadata.rb'))
error = 0

begin
  ::Chef::Knife.run(['cookbook', 'site', 'show', "#{name}"])
rescue SystemExit
  error = 1
end
.
.
.

In my understanding this behaviour would be reasonable if I would rescue Exception, but in this case I am basically catching siblings which only share their parent exception Exception.

I have already tried to rescue the exception Interrupt and SignalException explicitly.

EDIT1: In the hope of clarifying my question I added the following code which i tried:

begin
  ::Chef::Knife.run(['cookbook', 'site', 'show', "#{name}"])
rescue SystemExit => e
  msg1 = e.message
  error = 1
rescue Interrupt
  msg2 = "interrupted"
end

In both cases - SystemExit thrown by Knife.run and thrown by CTRL+C - e.message returns "exit". This does not only mean, that CTRL+C throws a SystemExit whereas I am expecting it to throw an Interrupt, but also that the error message is the same. I guess that I have got a major misunderstanding in how ruby works there, since I am not very familiar with ruby.

EDIT2: Further testing revealed that some CTRL+C interrupts are rescued by rescue Interrupt. Is it possible that the command ::Chef::Knife.run(['cookbook', 'site', 'show', "#{name}"]), which takes about 3-5 seconds to run, creates some kind of subprocess which responds to a CTRL+C, but always closes with a SystemExit and that rescue Interruptonly works when it is interrupted just the moment this subprocess is not running? If this is the case, how am I going to be able to Interrupt the whole program?

EDIT3: I initially wanted to attach all the methods which get called on calling Knife.run, however, this would have been too many LoC, although I think my guess that a subcommand is executed was right. The chef gem code can be found here. Thus, the following excerpt is only the part which is the problematic one in my opinion:

 rescue Exception => e
  raise if raise_exception || Chef::Config[:verbosity] == 2
  humanize_exception(e)
  exit 100
end

Which leads to the question: How can I catch a CTRL+C which is already rescued by a subcommand?


Solution

  • I have done gem install chef. Now I try another solution, replacing only run_with_pretty_exceptions, but don't know which require to put in the script. I did this :

    require 'chef'
    $:.unshift('Users/b/.rvm/gems/ruby-2.3.3/gems/chef-13-6-4/lib')
    require 'chef/knife'
    

    But then :

    $ ruby chef_knife.rb 
    WARNING: No knife configuration file found
    ERROR: Error connecting to https://supermarket.chef.io/api/v1/cookbooks/xyz, retry 1/5
    ...
    

    So, without the whole infrastructure, I can't test the following solution. The idea is that in Ruby you can reopen an existing class and replace a method defined elsewhere. I have to leave you check it :

    # necessary require of chef and knife ...
    
    class Chef::Knife # reopen the Knife class and replace this method
        def run_with_pretty_exceptions(raise_exception = false)
          unless respond_to?(:run)
            ui.error "You need to add a #run method to your knife command before you can use it"
          end
          enforce_path_sanity
          maybe_setup_fips
          Chef::LocalMode.with_server_connectivity do
            run
          end
        rescue Exception => e
          raise if e.class == Interrupt # <---------- added ********************
          raise if raise_exception || Chef::Config[:verbosity] == 2
          humanize_exception(e)
          exit 100
        end
    end
    
    name = cookbook_name(File.join(path, 'Metadata.rb'))
    error = 0
    
    begin
      ::Chef::Knife.run(['cookbook', 'site', 'show', "#{name}"])
    rescue SystemExit => e
      puts "in rescue SystemExit e=#{e.inspect}"
      error = 1
    rescue Interrupt
      puts 'in rescue Interrupt'
    end
    

    raise if e.class == Interrupt will re-raise Interrupt if it is one.

    Normally I run ruby -w to display diagnostics, which would be like this :

    $ ruby -w ck.rb 
    ck.rb:9: warning: method redefined; discarding old run_with_pretty_exceptions
    ck.rb:4: warning: previous definition of run_with_pretty_exceptions was here
    

    Unfortunately there are so many uninitialized variables and circular require warnings in this gem that this option produces un unmanageable output.

    The drawback of this solution is that you have to keep a documentation track of this change, and in case of Chef's release change, somebody has to verify if the code of run_with_pretty_exceptions has changed.

    Please give me a feedback.


    ===== UPDATE =====

    There is a less intrusive solution, which consists in defining an exit method in Chef::Knife.

    When you see exit 100, i.e. a message without receiver, the implicit receiver is self, it is equivalent to self.exit 100. In our case, self is the object created by instance = subcommand_class.new(args), and which is the receiver in instance.run_with_pretty_exceptions.

    When a message is sent to an object, the message search mechanism starts looking in the class of this object. If there is no method with this name in the class, the search mechanism looks in included modules, then the superclass, etc until it reaches Object, the default superclass of Chef::Knife. Here it finds Object#exit and executes it.

    After defining an exit method in Chef::Knife, the message search mechanism, when it encounters exit 100 with an instance of Chef::Knife as implicit receiver, will first find this local method and execute it. By previously aliasing the original Object#exit, it is still possible to call the original Ruby method which initiates the termination of the Ruby script. This way the local exit method can decide either to call the original Object#exit or take other action.

    Following is a complete example which demonstrates how it works.

    # ***** Emulation of the gem *****
    
    class Chef end
    
    class Chef::Knife
        def self.run(x)
            puts 'in original run'
            self.new.run_with_pretty_exceptions
        end
    
        def run_with_pretty_exceptions
            print 'Press Ctrl_C > '
            gets
            rescue Exception => e
                puts
                puts "in run_with_pretty...'s Exception e=#{e.inspect} #{e.class}"
                raise if false # if raise_exception || Chef::Config[:verbosity] == 2
    #            humanize_exception(e)
                puts "now $!=#{$!.inspect}"
                puts "about to exit,                     self=#{self}"
                exit 100
        end
    end
    
    # ***** End of gem emulation *****
    
    #----------------------------------------------------------------------
    
    # ***** This is what you put into your script. *****
    
    class Chef::Knife # reopen the Knife class and define one's own exit
        alias_method :object_exit, :exit
    
        def exit(p)
            puts "in my own exit with parameter #{p}, self=#{self}"
            puts "$!=#{$!.inspect}"
    
            if Interrupt === $!
                puts 'then about to raise Interrupt'
                raise # re-raise Interrupt
            else
                puts 'else about to call Object#exit'
                object_exit(p)
            end
        end
    end
    
    begin
      ::Chef::Knife.run([])
    rescue SystemExit => e
      puts "in script's rescue SystemExit e=#{e.inspect}"
    rescue Interrupt
      puts "in script's rescue Interrupt"
    end
    

    Execution. First test with Ctrl-C :

    $ ruby -w simul_chef.rb 
    in original run
    Press Ctrl_C > ^C
    in run_with_pretty...'s Exception e=Interrupt Interrupt
    now $!=Interrupt
    about to exit,                     self=#<Chef::Knife:0x007fb2361c7038>
    in my own exit with parameter 100, self=#<Chef::Knife:0x007fb2361c7038>
    $!=Interrupt
    then about to raise Interrupt
    in script's rescue Interrupt
    

    Second test with a hard interrupt.

    In one terminal window :

    $ ruby -w simul_chef.rb 
    in original run
    Press Ctrl_C > 
    

    In another terminal window :

    $ ps -ef
      UID   PID  PPID   C STIME   TTY           TIME CMD
        0     1     0   0 Fri01PM ??         0:52.65 /sbin/launchd
    ...
        0   363   282   0 Fri01PM ttys000    0:00.02 login -pfl b /bin/bash -c exec -la bash /bin/bash
      501   364   363   0 Fri01PM ttys000    0:00.95 -bash
      501  3175   364   0  9:51PM ttys000    0:00.06 ruby -w simul_chef.rb
    ...
    $ kill 3175
    

    Back in the first terminal :

    in run_with_pretty...'s Exception e=#<SignalException: SIGTERM> SignalException
    now $!=#<SignalException: SIGTERM>
    about to exit,                     self=#<Chef::Knife:0x007fc5a79d70a0>
    in my own exit with parameter 100, self=#<Chef::Knife:0x007fc5a79d70a0>
    $!=#<SignalException: SIGTERM>
    else about to call Object#exit
    in script's rescue SystemExit e=#<SystemExit: exit>
    

    Considering the code you originally posted, all you have to do is inserting at the beginning, but after the necessary require :

    class Chef::Knife # reopen the Knife class and define one's own exit
        alias_method :object_exit, :exit
    
        def exit(p)
            if Interrupt === $!
                raise # re-raise Interrupt
            else
                object_exit(p)
            end
        end
    end
    

    So there is no need to touch the original gem.