Get SimpleCov Coverage in your Ractors with the shmactor gem

· Miles Georgi

tl;dr You can get SimpleCov coverage of your Ractor code for now with the shmactor gem

I was goofin' about with ractors and noticed a surprise when running the test suite.

Actually, the story of the problem/solutions can be told from this git history:

$ git log --reverse --format='%cs %s %b' | grep -i simplecov
2026-04-08 Increase test coverage. Seems ractor code isn't always detected by Simplecov??
2026-04-13 Extract the ractor proc so we can test it directly for Simplecov Finally green!! 100% branch coverage!
2026-06-22 Introduce Shmactor to try to get Simplecov to cover lines in ractors

That's kind of like watching Ralph Wiggum's heart breaking but in reverse.

OK, let's dive into these three chapters of my life...

Chapter 1: SimpleCov does not cover lines inside the block passed to Ractor.new

So when I ran the test suite, I saw something like this:

Line Coverage: 89.63% (657 / 733)
Branch Coverage: 69.83% (81 / 116)
Line coverage (89.63%) is below the expected minimum coverage (100.00%).
Branch coverage (69.83%) is below the expected minimum coverage (100.00%).
SimpleCov failed with exit 2 due to a coverage related error

I went to look at the report to figure out what kinds of tests to add and saw this surprising output:

uncovered-ractor-lines.png

Whaaaa? I knew from the tests I'd already written that they couldn't possibly be green without hitting most (if not all) of these lines. The whole block was impossibly red. It seemed that SimpleCov wasn't counting any lines in this block that I was passing to Ractor.new.

I have to assume this is a temporary issue and that some day it will just work. But for now I needed to come up with some sort of workaround.

Chapter 2: A functional but non-scalable solution

After about a week, I decided to solve it by moving the block to a proc assigned to a constant called RACTOR_PROC and in the test suite adding a section that tests this proc specifically. I decided to run the proc in a thread that has a queue. You know... an actor. I called this thread ractor_like_object and gave it a ractor-like interface to handle the tidbits of the proc coupled to the interface of Ractor.

describe "RACTOR_PROC" do let(:ractor_like_class) do Class.new(Thread) do def queue = @queue ||= Queue.new def receive = queue.pop def send(message, move: false) = queue << message def close; end end end let(:ractor_like_object) do ractor_like_class.new do Thread.current.instance_exec(&described_class::RACTOR_PROC) end end # ...

This worked, and hence the excitement in the commit message. One strange thing, though, is that now I had to interact directly with this ractor_like_object, building messages and sending them to it, and interpreting the messages I get back from it. I already had code in the codebase to abstract this stuff away. In fact, that's the whole point of the gem I was working on called ractorize. So I was duplicating more and more of that logic as I added increasingly complex features to the project. Eventually, to work around some memory leaks, I needed to add two new kinds of ractors to the project.

So I had a choice to make. Should I implement both of these as procs in constants passed to Ractor.new and figure out how to refactor my ractor_like_object to be a bit more reusable? Or maybe come up with a deeper, more convenient workaround?

Chapter 3: A reusable thread-based actor in a gem called shmactor

What I decided to do was implement a thread-based actor in a different gem that adheres to the Ractor interface, or at least the parts of it I currently depend on.

To hook it in, what I decided to do was to have my ractors subclass from a base class called BaseRactor.

I can switch from SimpleCov not counting lines in my ractor blocks to counting them by swapping BaseRactor out from Ractor to Shmactor.

I need to run the suite once with BaseRactor as Ractor to verify that everything is behaviorally correct. Then I can run it with BaseRactor as Shmactor to get a correct coverage report.

I then collate the two SimpleCov reports together for the final coverage check in the build.

This is how things look today:

covered-ractor-lines.png

This works! I don't have to duplicate any complex logic for interacting with the ractor itself in my test suite to hit all of the lines correctly. And I don't need to wire up a solution every time I need to introduce a new ractor to the project. Yay!

How to use Shmactor to get line coverage in your ractor code

There's lots of ways you could wire it up. Instead of copy/pasting stuff here, I'll just link to a couple.

You can find an RSpec usage example in its README.md here: https://github.com/ractor-shack/shmactor#usage

You can also see a less simple approach by looking at how I wire it up in the ractorize gem in a parallelized-fashion. The semi-point-of-contact with the non-test code is constrained to src/ractorize/base_ractor.rb. The rest of wiring it up can be seen in spec/support/shmactor.rb, spec/support/simplecov.rb, and Rakefile.

Interested in using Shmactor?

Hit me up if this is something that might help a project of yours while we wait for non-experimental Ractor to be released! Perhaps you're using a feature of Ractor that I'm not and you need it to be implemented in Shmactor. Or maybe the usage examples aren't working for you. Or really whatever the case may be.

Cheers!

An apology

Looking at those commit messages, it dawns on me that I've been failing to capitalize the C in SimpleCov. Whoops! Apologies to the SimpleCov family. No disrespect was intended.