Testing Child Processes in Ruby

Posted by on January 6, 2021

Dog working at a computer

I was recently writing a piece of code that we wanted to act as a supervisor of child processes. We wanted to ask this supervisor the following “Hello there, would you mind running this task in a child process? Thanks!”. From here the supervisor would create a process, keep track of it so we can stop it if necessary, and run the given piece of code in it. This supervisor could run any number of tasks in child processes and would keep track of each one.

It took some time to figure out the best way to test it, but in the end the solution felt quite nice, and so I’ve written up what we came up with and the steps we took to get there.

Here’s a stripped down version of the class I was working with:

class Supervisor
  def fork_child_process
    fork do

You can see there’s a fork_child_process method that wraps the fork method. In here we could add in extra logic to keep track of the process ID, run before/after fork hooks, etc. but for now all that’s important is that we’re calling fork. This will create a child process and run the passed in block inside it.

An example of using it could be:

supervisor = Supervisor.new
supervisor.fork_child_process { p "Running in the child process!" }
=> 65538 # (this is the child process id)
"Running in the child process!"

Testing attempt 1

I’m using RSpec syntax in the following examples, but the principles aren’t specific to any testing framework and so could be written in minitest or test-unit.

To test that we run the block in the child process I initially came up with the following:

it "runs the block in the child process" do
  execution_check = nil
  supervisor.fork_child_process { execution_check = "✅" }
  expect(execution_check).to eq "✅"

The approach here felt fairly solid to me:

  • set a variable in the child process
  • assert that it was set

Sadly the test failed! 💔

Failure/Error: expect(execution_check).to eq "✅"

  expected: "✅"
       got: nil

  (compared using ==)

It failed for a couple reasons:

  1. The child process gets its own version of execution_check

The fork method wraps a system call, which makes use of something called Copy-on-Write (CoW 🐄) for managing child process memory. A child process uses the same memory[1] as the parent process until one of them modifies what’s stored in it. When this happens they get their own version. This is super handy because it means child processes aren’t unnecessarily holding duplicate data, but it’s also the main reason this test ain’t gonna work. Our child process is creating a private copy of the data when it modifies the variable and this isn’t available in the parent process (the test).

  1. There’s a timing issue

Even if the parent and child processes were both looking at the same execution_check object, there’s a chance the assertion could run before the child process has set the value of execution_check to “✅”. I really wanted to avoid having a sleep in the test, so I changed my approach.

Testing attempt 2

At this point I stubbed the fork method in the test so it would immediately run what we pass into it:

it "runs the block in the child process" do
  allow(supervisor).to receive(:fork).and_yield

  execution_check = nil
  supervisor.fork_child_process { execution_check = "✅" }

  expect(execution_check).to eq "✅"

This test asserts that whatever block we pass into the fork_child_process method is executed. It passes!

Run options: include {:locations=>{"./supervisor_spec.rb"=>[14]}}

Finished in 0.00775 seconds (files took 0.07863 seconds to load)
1 example, 0 failures

I could have left it there, but mocking the fork method didn’t give me enough confidence that my class was doing the right thing. I wanted the test to actually create a child process and assert that our code was run inside it.

Testing attempt 3

Using an IO#pipe we can create a channel the parent and child processes can use to chat.

Here’s a little helper class we came up with:

class ChildProcessMessage
  def initialize
    @read, @write = IO.pipe

  # sets a value in the child process to be communicated
  # back to the waiting parent process.
  def set(value)
    @write.write value

  # to be called in the parent process, waiting for the child
  # to set a value
  def wait

Here’s what our test looked like using it:

it "runs the block in the child process" do
  execution_check = ChildProcessMessage.new
  supervisor.fork_child_process { execution_check.set("✅") }

  expect(execution_check.wait).to eq "✅"

This does the following:

  • The child process writes “✅” to our IO#Pipe and then closes it
  • The parent process (the test) waits for the child process to write and close the pipe
  • The parent process asserts that the pipe received the expected data

The file handlers are copied across the fork, and so each end of the pipe is opened twice—once in the parent and once in the child. This is why we’re closing @write in both the set and wait methods. I haven’t closed @read in these methods for brevity, but it would make sense to do so.

Because we’re not modifying the execution_check object (unlike in attempt 1), both the parent and child process are looking at the same object.

The solution worked well for us. It did add some complexity, but in doing so we gained a higher level of confidence that our supervisor was working correctly. There are some performance implications you’d want to keep in mind between the two approaches, but that’s beyond the scope of this post. Overall I’m happy with the result, and in creating these tests I learned a little bit about interprocess communication, which I think is a good thing.

If you find yourself needing to unit test code that creates child processes, stubbing the fork method might be just what you’re after. You can gain a decent level of confidence by testing the child process code separately and using the stubbed fork method to make sure it’s called with the right code. If, however, you need something a bit more concrete, then using the ChildProcessMessage class could help. By actually creating child processes in the tests, you’re able to find issues like zombie processes much earlier.

P.S. If you’d like to learn more about some of this stuff here are some links:

[1] Interestingly, this is one of the differences between a process’s VSZ (Virtual size) and RSS (Resident set size) as reported by tools such as ps and top. The Virtual size is the address space available to the process, so includes areas that have been inherited from the parent process. Whereas the resident set size is the actual RAM occupied by that process, so excludes the inherited memory.

Tagged , ,

About the author…

Andy joined FreeAgent in 2015 and is a Senior Engineer on the Banking team. He lives in Durham where he spends his time playing with his dog and making things out of wood in his garage.

See all posts by Andy

2 thoughts on “Testing Child Processes in Ruby

    Hey Richard,

    Thanks for taking the time to read the article and for sharing those links! WaitForIt looks really interesting, I wish I’d seen it sooner! 😄

Leave a reply

Your email address will not be published. Required fields are marked *