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 yield end end end
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 "✅" end
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:
- 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).
- 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 "✅" end
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 end # sets a value in the child process to be communicated # back to the waiting parent process. def set(value) @write.write value @write.close end # to be called in the parent process, waiting for the child # to set a value def wait @write.close @read.read end end
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 "✅" end
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.
Great post! I do a lot of testing with forks.
I made a library for waiting on processes to boot easier:
– https://github.com/zombocom/wait_for_it
Also if you’re mucking with env vars and don’t want to accidentally polute your parent process I added what I think is a neat before hook on some tests
– https://github.com/heroku/heroku-buildpack-ruby-experimental/blob/378f42a32cd355e949840e599dab154c068e4700/spec/spec_helper.rb#L31-L66
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! 😄