Faking progress in long-running command-line scripts

Today I came across a problem that I’ve encountered a few times before. I needed to spawn a process in a subshell and wait for it to finish; in this case, I was using the mysql command-line utility to import a large database dump.

If we do this in the standard way:

system "mysql some_database < dump.sql"

…then the user of our program receives no feedback; just a blank prompt. It’s impossible to distinguish whether the program is in an importing state or in a hanged one — that is, to impossible to know whether to sit and wait for it to finish, or to interrupt it because something’s gone wrong.

Of course, we have no way of actually displaying accurate progress updates to the user, since we don’t know what point the mysql command has reached. But if we want to be kinder to our user than just showing them a blank prompt, we can still offer them some visual feedback to let them know that the script is importing.

Let’s look at how we can do this.


The first thing we need to do is to recognise that system is a blocking call; whatever code we have following it will only execute once the system call has finished. So our first step is to move our system call into another process, so we can get on with the business of displaying progress at the same time as the system call is running:

import = fork do
  system "mysql some_database < dump.sql"
end

Then, we can start another process to display progress. In my case, I used Jeff Felchner’s excellent ruby-progressbar library:

progress = fork do
  progressbar = ProgressBar.create(title: "Importing database dump", total: nil)

  trap "INT" do
    progressbar.total = 100
    progressbar.finish
    exit
  end

  loop do
    progressbar.increment
    sleep 0.5
  end
end

Here we spawn another process and create a new progress bar. We tell Ruby that we want to respond to the SIGINT Unix signal, and that when we’re sent that signal we want to complete the progress bar and exit. Finally, we start an infinite loop that increments the progress bar twice a second.

The final step is to wait for our import to finish:

Process.wait(import)
Process.kill(2, progress)

Here we wait for the import process to finish (this becomes the new point where the script blocks; without this, it would exit immediately, which isn’t what we want). But while we’re waiting, the progress bar process will also be doing its magic and giving the user some feedback.

Once the wait call has finished, we sent SIGINT to the progress bar process; this allows it to exit cleanly, and stops us from having an runaway progress bar executing forever.

That’s it! Here’s the full code:

import = fork do
  system "mysql some_database < dump.sql"
end

progress = fork do
  progressbar = ProgressBar.create(title: "Importing database dump", total: nil)

  trap "INT" do
    progressbar.total = 100
    progressbar.finish
    exit
  end

  loop do
    progressbar.increment
    sleep 0.5
  end
end

Process.wait(import)
Process.kill(2, progress)

Ta-da! With some basic Unix process-wrangling, we’ve made our long-running script a little bit nicer for its users.

Taking it further

This pattern could trivially be extended into a method that takes a block and passes it to the first fork call, allowing you to display this kind of indeterminate progress update for any code in just a single line. Here’s what that might look like:

require "ruby-progressbar"

module Kernel
  def with_progress(title, &block)
    import = fork(&block)

    progress = fork do
      progressbar = ProgressBar.create(title: title, total: nil)

      trap "INT" do
        progressbar.total = 100
        progressbar.finish
        exit
      end

      loop do
        progressbar.increment
        sleep 0.5
      end
    end

    Process.wait(import)
    Process.kill(2, progress)
  end
end

Now we can call our first example as:

with_progress("Importing database dump") do
  system "mysql some_database < dump.sql"
end

Making it this easy to display progress — even if it isn’t really informing the user, it’s extending them a little courtesy — makes it easy to avoid those black screens of uncertainty that otherwise crop up regularly. It’s a pattern I think I’ll go back to.