Ruby WeakRef and running garbage collection in tests

Recently I was working with Puppet gem internals and found that each manifest run which idea is to manage some properties of system we want to control, creates transaction object. By extending Puppet provided codebase using plain Ruby I noticed issue. There is no API for fetching and accessing one of the object which state I wanted to read.

I needed to access its values. One of quick ways was to use ObjectSpace to find the object I wanted without touching original codebase. Flow was dependent of this object and I wanted to test it using RSpec.

So I made 2 test cases where one flow would go with transaction object existing in object space and second without it. In real usage case object are garbage collected after puppet manifest is applied. But in RSpec framework object created inside it block are not directly garbage collected.

The Ruby stdlib WeekRef an GC came with help. In another paragraph you can find recreated problem with example code and way to use WeakRef to solve polluted ObjectSpace.

Example of using garbage collection in Ruby tests

In file called without_weakref_spec.rb we create 2 classes. One called Modifier which we left empty with default methods. Second class Communicator will behave differently depending on the presence of Modifier in Ruby object space.

At the Communicator initialization the modifier_object method is fired and Modifier object is searched inside memory via ObjectSpace using find method on enumerator ObjectSpace.each_object. The modifier?(obj) is used to check if the object class is equals to Modifier, it is important to notice that the not all objects might implement .class method so rescue returning false is added for this kind of objects. Lastly writing about this class we have call method which returns the ‘Modifier object was found’ in case of Modifier presence in object space.

At the end of the file we have 2 tests which check if the Communicator class behaves properly depending on object in memory.

require 'rspec'

class Modifier; end

class Communicator
  def initialize
    @modifier_object = modifier_object
  end

  def call
    return 'Modifier object was found' if @modifier_object
    'No modifier object found'
  end

  private

  def modifier_object
    ObjectSpace.each_object.find { |obj| modifier?(obj) }
  end

  def modifier?(obj)
    obj.class == Modifier
  rescue
    false
  end
end

RSpec.describe Communicator do
  it "modifier object in object space" do
    Modifier.new
    expect(Communicator.new.call).to eq('Modifier object was found')
  end

  it "modifier object not found" do
    expect(Communicator.new.call).to eq('No modifier object found')
  end
end

If we run test we can see.

$ rspec without_weakref_spec.rb

Failures:

  1) Communicator modifier object not found
     Failure/Error: expect(Communicator.new.call).to eq('No modifier object found')

       expected: "No modifier object found"
            got: "Modifier object was found"

       (compared using ==)
     # ./without_weakref_spec.rb:35:in `block (2 levels) in '

Finished in 0.03979 seconds (files took 0.10658 seconds to load)
2 examples, 1 failure

Failed examples:

rspec ./without_weakref_spec.rb:34 # Communicator modifier object not found

It is because at the end of the Modifier object in test is not garbage collected.

Solution

Lets copy code to new file without_weakref_spec.rb

We can solve object in memory issue by adding WeakRef from Ruby standard library at the
top of the file.

require 'weakref'

Then GC begging of test case which will run garbage collector in test run

GC.start

At the end in the test spec where we create Modifier object wrap it in WeakRef
reference allowing object to be easly garbage collected

WeakRef.new(Modifier.new)

So whole with_weakref_spec.rb file with test suite will look like this

require 'rspec'
require 'weakref'

class Modifier; end

class Communicator
  def initialize
    @modifier_object = modifier_object
  end

  def call
    return 'Modifier object was found' if @modifier_object
    'No modifier object found'
  end

  private

  def modifier_object
    ObjectSpace.each_object.find { |obj| modifier?(obj) }
  end

  def modifier?(obj)
    obj.class == Modifier
  rescue
    false
  end
end

RSpec.describe Communicator do
  it "modifier object in object space" do
    GC.start
    WeakRef.new(Modifier.new)
    expect(Communicator.new.call).to eq('Modifier object was found')
  end

  it "modifier object not found" do
    GC.start
    expect(Communicator.new.call).to eq('No modifier object found')
  end
end

 

And running test suite will give us

$ rspec with_weakref_spec.rb
..

Finished in 0.02668 seconds (files took 0.11199 seconds to load)
2 examples, 0 failures

By Bernard Pietraga

Cofounder of cré‑act, software engineer, manager and contemporary painter.