Bernard Pietraga

Ruby WeakRef and running garbage collection in tests

Ruby WeakRef and running Garbage Collection in Test

Recently I have been working with the Puppet gem internals. I’ve found that each manifest run, the idea of which is to manage some properties of the system, creates a transaction object. The transaction object holds information about the desired state of the system. By extending the Puppet-provided codebase with plain Ruby, I’ve noticed a problem. There is no API to retrieve and access one of the transaction objects whose state I wanted to read.

The Ruby WeekRef and GC standard libraries came with help. In another paragraph, you can find a recreated problem with sample code and way to use WeakRef to solve polluted ObjectSpace.

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 on this object and I wanted to test it using RSpec.

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

The Ruby WeekRef and GC standard libraries came with help. In another paragraph, you can find a recreated problem with sample 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

Let’s 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