In Progress
Unit 1, Lesson 1
In Progress

Exit

What happens when you tell Ruby to exit from the current program? The answer to this question may surprise you! More importantly, it will give you insight into the mechanics of process termination in Ruby, and how to ensure your code handles cleanly deals with shutdown.

Video transcript & code

Let's kick off today's episode with one of my favorite example programs: a text adventure game. To program a text adventure, we need a method to execute commands the player enters at the command line.

The core of this method is a case statement which switches on the content of the command to decide what to do next. if the player enters a direction, the method delegates to an execute_direction_command method. Otherwise, the command is not recognized, and an exception is raised.

That's the essential logic of the method. but there is also some session context that's needed for the game to proceed. Specifically, commands are executed in the context of an open connection to a local database file for persistence. as well as a connection to a remote game server.

Each of these resources is managed by a method that takes a block. Let's take a look at the definitions of these methods.

First off, the with_game_db method. it opens a database file, and then yields it back to the calling method. Once the block is finished, it closes the file.

The with_game_server method is similar. It opens a TCP connection to a server, yields it, and then when the block is done, closes it.

require "socket"

def execute_game_command(command)
  with_game_db do |db|
    with_game_server do |server|
      puts "> #{command}"
      case command
      when "north", "south", "east", "west"
        execute_direction_command(command)
      else
        fail "Unknown command #{command}"
      end
    end
  end
end

def execute_direction_command(direction)
  puts "You walk to the #{direction}"
  # ...
end

def with_game_db
  puts "* Opening game db"
  db = open("game.db", "w+")
  yield db
  puts "* Closing game db"
  db.close
end

def with_game_server
  puts "* Opening game server connection"
  connection = TCPSocket.new("example.org", 80)
  yield connection
  puts "* Closing game server connection"
  connection.close
end

Let's take this code for a test drive. We'll invoke the execute_game_command method, with the valid command north.

Let's take a look at the output.

We can see that first the game database and server connection are opened. Then the player command is echoed and executed. After which, the game server connection and database handle are automatically cleaned up.

require "./game"

execute_game_command("north")

# >> * Opening game db
# >> * Opening game server connection
# >> > north
# >> You walk to the north
# >> * Closing game server connection
# >> * Closing game db

What happens when we try to execute an invalid game command?

In that case, we see that first the transcript is the same. But then the program exits with an unhandled runtime error.

require "./game"

execute_game_command("fire magic missile") # ~> RuntimeError: Unknown command fire magic missile

# >> * Opening game db
# >> * Opening game server connection
# >> > fire magic missile

# ~> RuntimeError
# ~> Unknown command fire magic missile

Conspicuously absent in this output is any shut down for the database and server connections.

If we go back to the with_game_db method definition, we can see why this is. the method is written so that the database will only be closed if the preceding yield succeeds without raising an exception.

We can fix this by putting the cleanup portion of the method into an ensure block.

While we're at it, we'll do the same to the with_game_server method.

require "./game"
require "socket"

def with_game_db
  puts "* Opening game db"
  db = open("game.db", "w+")
  yield db
ensure
  puts "* Closing game db"
  db.close
end

def with_game_server
  puts "* Opening game server connection"
  connection = TCPSocket.new("example.org", 80)
  yield connection
ensure
  puts "* Closing game server connection"
  connection.close
end

Now when we try to execute an invalid command, we can see in the transcript that even though an exception is raised which eventually results in an early termination, the server and database resources are still closed out along the way.

require "./game2"

execute_game_command("fire magic missile") # ~> RuntimeError: Unknown command fire magic missile

# >> * Opening game db
# >> * Opening game server connection
# >> > fire magic missile
# >> * Closing game server connection
# >> * Closing game db

# ~> RuntimeError
# ~> Unknown command fire magic missile

This is all basic Ruby exception handling at work. But now let's add a new twist.

In simple scripts, the execution of Ruby code is often terminated simply by reaching the end of the file. But in most fully fledged applications, it's not that straightforward. In a real game, a command execution method like this one would most likely be invoked repeatedly in some kind of infinite loop.

To make it possible for the player to exit the game, we need to add I handler for an exit command. This handler will print a farewell message, and then exit the program immediately.

require "./game2"

def execute_game_command(command)
  with_game_db do |db|
    with_game_server do |server|
      puts "> #{command}"
      case command
      when "north", "south", "east", "west"
        execute_direction_command(command)
      when "exit"
        puts "Goodbye!"
        exit
      else
        fail "Unknown command #{command}"
      end
    end
  end
end

Now, what does this mean for the server and database connections? Once we invoke the exit command, will those resources just be left dangling for the operating system to (hopefully) clean up? Let's find out.

We'll execute the exit command, but before we run it we'll also add a marker to see if execution proceeds after this method invocation.

Looking at the transcript, we can see that first the game database and server connection are opened, then the command is executed and the farewell message printed, and then the resource connections are cleanly closed.

require "./game3"

execute_game_command("exit")
puts "--- Should never get here ---"

# >> * Opening game db
# >> * Opening game server connection
# >> > exit
# >> Goodbye!
# >> * Closing game server connection
# >> * Closing game db

We can see from the fact that our "should never get here" text was never printed, that the program did indeed exit at the point we told to. But we can see from the last two entries in the transcript that before it exited, both of our ensure blocks were executed.

This is comforting, because it means we can safely exit from a Ruby program at any point, without worrying that important resources are not being cleanly closed out.

But it's also thought-provoking. Because it suggests that Ruby handles a program exit similarly to the way it handles an exception. And this suggests a little experiment we might run.

What if we added some logging to show the current exception, if any, in each of our ensure blocks?

Let's start with the with_game_db method. We'll add another line of logging to the ensure block. this line will print the current exception being handled by referencing the $! variable, otherwise known as the "error info" variable. This variable always contains the current active exception object, if any.

We'll do the same in the with_game_server method.

require "./game"
require "./game3"

def with_game_db
  puts "* Opening game db"
  db = open("game.db", "w+")
  yield db
ensure
  puts "* Closing game db"
  puts "* (Current exception: #{$!.inspect})"
  db.close
end

def with_game_server
  puts "* Opening game server connection"
  connection = TCPSocket.new("example.org", 80)
  yield connection
ensure
  puts "* Closing game server connection"
  puts "* (Current exception: #{$!.inspect})"
  connection.close
end

Let's first go back to running an unrecognized command.

We can see in the resulting transcript that the ensure blocks were invoked in the context of a runtime error.

require "./game4"

execute_game_command("xyzzy") # ~> RuntimeError: Unknown command xyzzy

# >> * Opening game db
# >> * Opening game server connection
# >> > xyzzy
# >> * Closing game server connection
# >> * (Current exception: #<RuntimeError: Unknown command xyzzy>)
# >> * Closing game db
# >> * (Current exception: #<RuntimeError: Unknown command xyzzy>)

# ~> RuntimeError
# ~> Unknown command xyzzy

OK, now let's execute the exit command. Remember, this will invoke Ruby's built-in exit method to terminate the current program.

When we check out the transcript, we see something very interesting indeed: once again, the ensure blocks were invoked in the context of an exception! Specifically, a SystemExit exception.

require "./game4"

execute_game_command("exit")

# >> * Opening game db
# >> * Opening game server connection
# >> > exit
# >> Goodbye!
# >> * Closing game server connection
# >> * (Current exception: #<SystemExit: exit>)
# >> * Closing game db
# >> * (Current exception: #<SystemExit: exit>)

What this tells us is that Ruby doesn't just handle program exits similarly to exceptions. It actually implements program exit by raising an exception! In fact the only difference between how it treats a system exit versus other exceptions, is that for other exceptions Ruby prints out a backtrace once the exception bubbles up to the top level of the program. Whereas with SystemExit, Ruby just quietly ends the process.

What if we don't want Ruby to do an orderly shutdown? What if we want to terminate the current process in a way that does not pass go and does not collect $200?

In that case, we must invoke the "bang" version of exit.

require "./game4"

def execute_game_command(command)
  with_game_db do |db|
    with_game_server do |server|
      puts "> #{command}"
      case command
      when "north", "south", "east", "west"
        execute_direction_command(command)
      when "exit"
        puts "Goodbye!"
        exit!
      else
        fail "Unknown command #{command}"
      end
    end
  end
end

With this change, when we execute the exit command, we see that nothing happens past the point of the exit! call.

require "./game5"

execute_game_command("exit")

# >> * Opening game db
# >> * Opening game server connection
# >> > exit
# >> Goodbye!

Let's revert that change.

Knowing that Ruby treats a process exit exactly the same as an exception means we can rest assured that we can rely on Ruby's standard resource management idioms even in code which uses the exit method. For instance, we could rewrite the with_game_db method to use an idiomatic open block. Since the block form of open is written to safely close the file in case of an exception, we know that this will be robust in the face of premature exits as well.

def with_game_db
  open("game.db", "w+") do |db|
    yield db
  end
end

An interesting implication of the fact that Ruby implements process exits in terms of an exception is that we can actually intercept a process exit if we want to.

For instance, we could make a change to the execute_game_command method, adding a Boolean flag to tell it to suppress exits. Then we could add a rescue block which matches on the SystemExit exception class. Inside, we could check the suppress_exit flag, and if it is set, we could simply do nothing and thus ignore the exit. If the flag isn't set, we simply reraise the system exit exception to allow it to continue as usual.

require "./game4"

def execute_game_command(command, suppress_exit: false)
  with_game_db do |db|
    with_game_server do |server|
      puts "> #{command}"
      case command
      when "north", "south", "east", "west"
        execute_direction_command(command)
      when "exit"
        puts "Goodbye!"
        exit
      else
        fail "Unknown command #{command}"
      end
    end
  end
rescue SystemExit
  if suppress_exit
    puts "* Exit suppressed, continuing"
  else
    raise
  end
end

Running the exit game command as before, we see familiar output. the program exits early after an orderly shutdown, and our final checkpoint puts is never executed.

require "./game6"

execute_game_command("exit")
puts "--- Should never get here ---"

# >> * Opening game db
# >> * Opening game server connection
# >> > exit
# >> Goodbye!
# >> * Closing game server connection
# >> * (Current exception: #<SystemExit: exit>)
# >> * Closing game db
# >> * (Current exception: #<SystemExit: exit>)

On the other hand, when we set the suppress_exit flag, and re-execute, We see that this time execution proceeded onwards from the execute_game_command call, even though we invoked the program exit method.by rescuing and throwing away the system exit exception, we prevented the exit from occurring.

require "./game6"

execute_game_command("exit", suppress_exit: true)
puts "--- Should never get here ---"

# >> * Opening game db
# >> * Opening game server connection
# >> > exit
# >> Goodbye!
# >> * Closing game server connection
# >> * (Current exception: #<SystemExit: exit>)
# >> * Closing game db
# >> * (Current exception: #<SystemExit: exit>)
# >> * Exit suppressed, continuing
# >> --- Should never get here ---

Once again, this can be overridden by using the bang version of exit.

require "./game4"

def execute_game_command(command, suppress_exit: false)
  with_game_db do |db|
    with_game_server do |server|
      puts "> #{command}"
      case command
      when "north", "south", "east", "west"
        execute_direction_command(command)
      when "exit"
        puts "Goodbye!"
        exit!
      else
        fail "Unknown command #{command}"
      end
    end
  end
rescue SystemExit
  if suppress_exit
    puts "* Exit suppressed, continuing"
  else
    raise
  end
end

Executing the code now, we see that our exit suppression no longer works. That's because the bang version doesn't raise an exception. It simply terminates the program instantly.

require "./game7"

execute_game_command("exit", suppress_exit: true)
puts "--- Should never get here ---"

# >> * Opening game db
# >> * Opening game server connection
# >> > exit
# >> Goodbye!

What we've seen in today's episode is that early exits to Ruby programs aren't just handled similarly to exceptions. They are exceptions; just special ones. Like other exceptions, they can be rescued, re-raised, and even ignored. We've also seen that we can forego exception-style exit handling and terminate a program immediately using exit!.

This may not be knowledge that you use every day, but hopefully it has deepened your understanding of how Ruby handles program shutdown. Happy hacking!

Responses