Search code examples
rubytry-catchthrow

Is this expected behavior for throw in ruby?


I noticed this behavior while playing with trap and with catch/throw methods to better understand how they can/should be used.

In Ruby the catch and throw methods appear to be intended to be used in pairs:

catch(:ctrl_c) do
  trap("SIGINT") { throw :ctrl_c }
  (1.. ).each {|n| print "."; sleep 0.5 }
end

# SIGINT trapped -> throw called -> catch block exits
#   ruby covthrow1p.rb
#   => ........

Or with a second parameter provided to the throw method:

def stop_script
  puts 'CTRL_C seen'
  exit
end

catch(:ctrl_c) do
  trap("SIGINT") { throw :ctrl_c, stop_script }
  (1.. ).each {|n| print "."; sleep 0.5 }
end

# SIGINT trapped -> throw called -> catch executes stop_script & exits block
#   ruby covthrow2p.rb
#   => ......CTRL_C seen

If throw is used alone (naked) with only the key parameter provided, it fails:

def stop_script
  puts 'CTRL_C seen'
  exit
end

trap("SIGINT") { throw :ctrl_c }
(1.. ).each {|n| print "."; sleep 0.5 }

# SIGINT trapped -> throw called -> No catch block -> UncaughtThrowError
#   ruby nkdthrow1p.rb
#   => .......nkdthrow1p.rb:8:in `throw': uncaught throw :ctrl_c (UncaughtThrowError)

But if a naked throw is used with the second parameter provided, it succeeds!

def stop_script
  puts 'CTRL_C seen'
  exit
end

trap("SIGINT") { throw :ctrl_c, stop_script }
(1.. ).each {|n| print "."; sleep 0.5 }


# SIGINT trapped -> throw called -> No catch block -> call stop_script ???
#   ruby nkdthrow2p.rb
#   => ......CTRL_C seen

Is this intended behavior? An artifact of implementation? A bug? Seems harmless generally but could cause confusing behavior.

The examples are on Ruby 3.2.2 running on Windows 10.


Solution

  • Your interpretation of the second example is wrong. Let's comment out exit for now, so we can see the full execution:

    def stop_script
      puts 'CTRL_C seen'
      # exit
    end
    
    catch(:ctrl_c) do
      trap("SIGINT") { throw :ctrl_c, stop_script }
      (1.. ).each {|n| print "."; sleep 0.5 }
    end
    

    What actually happens:

    • catch executes its block
    • trap sets a SIGINT handler
    • each starts executing, and is interrupted by the keyboard interrupt
    • SIGINT handler executes
    • throw(:ctrl_c, stop_script) executes; since throw is a method, its arguments need to be evaluated before the method call
    • stop_script in Ruby is equivalent to self.stop_script() if such a method exists, so stop_script is executed
    • "CTRL_C seen" is printed, puts returns nil
    • script would have exit if exit wasn't commented out
    • as there is no return, stop_script returns the last statement's value, which is nil
    • throw is finally called as throw(:ctrl_c, nil)
    • catch exits with value nil

    Your fourth example is equivalent, only missing the first and last step.

    Crucially, the second parameter to throw is only the value that will be passed to catch as its return value. It has no magic "evaluate this later" power: it evaluates before throw is called, just like any argument to a method.

    If a throw is not caught by catch by the time the script reaches its natural end, you will get an exception printed. However, in your last example, given that exit is executed, the script exits then and there, the exception situation does not occur.