synctest.rb

[!WARNING]

  • This is still very much an experiment :)
  • It currently supports only threads—no fibers or Ractors!

If testing concurrent code feels painful, perhaps it's not you. It's the tooling.

The Synctest gem is inspired by Go's testing/synctest, a package that provides support for testing concurrent code.

It makes it easier to reason about the ordering of events in concurrent programs by providing convenient synchronization points (or, in other words, "quiescence" points). At these points, you can be sure that your test code is not interfering with any background activity, and you can observe the state of the system with confidence.

Installation

Add Synctest to the test group in your Gemfile:

group :test do
  gem "synctest"
end

Then run bundle install and load the library:

require "synctest"

Synctest has no RSpec or Minitest integration layer. Synctest.run is plain Ruby, so it can wrap an example in either framework—or any other test runner.

Assertions may be placed directly inside the block. Use the same wrapper in an RSpec example, a Minitest test method, or a plain Ruby test:

Synctest.run do
  # Start threads, use Synctest.wait, and assert.
end

Examples

Using queues

require "synctest"

Synctest.run do
  inbox = Queue.new
  events = []

  worker = Thread.new do
    events << :started
    inbox.pop
    sleep 30
    events << :finished
  end

  # Let the worker run until it is blocked on inbox. This does not move time.
  Synctest.wait
  raise "worker did not start" unless events == [:started]

  inbox << :continue
  worker.join

  # The 30-second delay took no corresponding wall-clock time.
  raise "worker did not finish" unless events == [:started, :finished]
end

Using mutexes

require "synctest"

Synctest.run do
  mutex = Mutex.new
  events = []
  mutex.lock

  worker = Thread.new do
    events << :waiting
    mutex.synchronize { events << :entered }
  end

  # The worker has reached Mutex#lock and is durably blocked.
  Synctest.wait
  raise "worker entered too early" unless events == [:waiting]

  mutex.unlock
  worker.join

  raise "worker did not enter" unless events == [:waiting, :entered]
end

Bubbles and virtual time

Synctest.run associates the calling thread, recursively created child threads, and supported synchronization objects with one bubble:

result = Synctest.run(
  start_at: Time.utc(2030, 1, 1),
  timeout: 5.0
) do
  sleep 10
  [Time.now, Process.clock_gettime(Process::CLOCK_MONOTONIC)]
end

# => [2030-01-01 00:00:10 UTC, 10.0]

The default wall-clock origin is 2000-01-01 00:00:00 UTC; the monotonic clock starts at zero. start_at must be a Time. timeout is a positive number of real seconds used to diagnose missing progress, or nil to disable that watchdog. The block's value is returned.

Virtual time advances to the earliest timer deadline only when every active bubble thread is durably blocked. It does not advance while a tracked thread can still make progress. This makes long sleeps and timeouts effectively instant while preserving their ordering.

The following clock APIs observe bubble time:

  • Time.now and argumentless Time.new, including their in: keyword;
  • Process.clock_gettime(Process::CLOCK_MONOTONIC, unit);
  • Process.clock_gettime(Process::CLOCK_REALTIME, unit).

Only CLOCK_MONOTONIC and CLOCK_REALTIME are virtualized. Other Process.clock_gettime clocks, such as process and thread CPU clocks, continue to report real OS measurements. Explicitly constructed Time values also retain normal Ruby behavior.

Virtual time stops advancing once the root block returns. Join child threads before returning; leaving a sleeping or otherwise durably blocked child behind is reported as a deadlock rather than silently advancing time during teardown.

Waiting for quiescence

Synctest.wait waits until every other bubble thread has exited or is durably blocked:

Synctest.run do
  events = []
  worker = Thread.new do
    events << :before
    sleep 60
    events << :after
  end

  Synctest.wait
  raise unless events == [:before]
  raise unless Process.clock_gettime(Process::CLOCK_MONOTONIC) == 0.0

  worker.join
  raise unless events == [:before, :after]
  raise unless Process.clock_gettime(Process::CLOCK_MONOTONIC) == 60.0
end

Unlike sleep, join, or another timed wait, Synctest.wait never advances the virtual clock. It is useful for proving that all immediately runnable work has drained and for checking state just before a timer fires.

Only one Synctest.wait may be active in a bubble. Calling it outside Synctest.run is an error.

Supported Ruby primitives

Requiring synctest installs process-wide shims, but they dispatch through thread-local bubble membership. Outside a bubble, calls immediately delegate to Ruby's original behavior.

Inside a bubble, Synctest tracks:

  • Thread.new, Thread.start, and Thread.fork (the thread factory, not a process fork), including recursively created descendants;
  • Thread.stop, Thread#wakeup, Thread#run, Thread#kill, Thread#terminate, Thread#exit, and Thread#raise;
  • Thread#join(timeout) and Thread#value;
  • Kernel#sleep;
  • Mutex locking, #synchronize, #try_lock, and timed #sleep;
  • ConditionVariable#wait, #signal, and #broadcast;
  • Queue operations, including blocking pops, pop timeouts, pushes, closing, and clearing;
  • SizedQueue operations, including blocking pops and pushes, timeouts, closing, clearing, and capacity changes;
  • Monitor reentrant synchronization and condition variables;
  • Timeout.timeout.

Timeouts on sleep, join, condition waits, queue operations, and Timeout.timeout all use the virtual clock. Thread exceptions are propagated through Thread#join and Thread#value; a descendant failure that is never observed through either method is propagated from the bubble itself.

This is behavioral instrumentation, not a deterministic thread scheduler. The order among simultaneously runnable threads is still MRI's choice. Use bubble-owned queues or locks to express required phase ordering, and use Synctest.wait when the assertion is about quiescence.

Ownership and isolation

Create threads and synchronization primitives inside Synctest.run. Supported objects created there belong to that bubble. Their instrumented blocking and mutating operations can be used only by threads in the same bubble while it is active; using those operations after the run finishes or from another bubble raises Synctest::IsolationError. Native read-only observers that need no scheduling—such as Queue#length—remain ordinary Ruby calls.

Objects created outside a bubble are not retroactively associated with it. Their methods retain ordinary Ruby behavior, and a blocking call on one is invisible to Synctest. Ordinary data objects can cross bubble boundaries. Independent bubbles may run in parallel, but bubbles cannot be nested.

These rules are what make a supported wait durable: only another thread in the same bubble can release it.

Failures and diagnostics

Synctest distinguishes among failures that would otherwise tend to leave tests hanging:

  • Synctest::DeadlockError means every active tracked thread is durably blocked and no virtual timer can make progress.
  • Synctest::StalledError means no tracked progress occurred during a coordinator wait or bubble teardown for timeout real seconds. Unsupported I/O, native code, or an unassociated primitive is a common cause.
  • Synctest::IsolationError prevents a bubble-owned object from escaping its owner.
  • Synctest::NestedBubbleError and Synctest::ConcurrentWaitError reject ambiguous bubble lifecycles.
  • Synctest::NotInBubbleError reports bubble-only API calls made outside a run.
  • Synctest::UnsupportedOperationError reports explicitly unsupported concurrency mechanisms.

Deadlock and stall messages include virtual time, active thread states, the blocking operation when known, and abbreviated backtraces. The real-time stall watchdog is not a VM-level preemptive timeout: if the root thread calls an unsupported blocking operation directly, ordinary Ruby blocking behavior can still apply.

Current limitations

Synctest is pure Ruby and observes patched Ruby methods rather than instrumenting the VM. MRI 3.4+ is the supported target; other Ruby engines are currently unverified.

The bubble cannot classify a wait as durable when one of its possible wakers is not a tracked thread. In particular:

  • real file, pipe, socket, and subprocess I/O is not tracked;
  • waits performed wholly inside native extensions are not tracked;
  • fibers are not tracked as independent actors, and Fiber schedulers are explicitly rejected inside a bubble;
  • Ractors and process forks are outside the bubble model;
  • a child cannot call Thread#value on the bubble's root thread because the root cannot acquire its final native value until bubble teardown has waited for the child; use a timed Thread#join or an explicit result queue instead.

For networked code, split transport integration from the concurrent state machine. A test fake backed by bubble-owned Queues lets Synctest account for both sides of every wait; a real socket, including Socket.pair, does not. Keep focused real-time integration tests for the actual transport.

API

Synctest.run(start_at: Synctest::DEFAULT_START_TIME,
             timeout: Synctest::DEFAULT_TIMEOUT) { ... }
Synctest.wait
Synctest.active?
Synctest::VERSION

Synctest.active? reports whether the calling thread belongs to a live bubble.

Development

bin/setup                 # install dependencies
bundle exec rake          # run the specs and Standard
bundle exec rake spec     # run only the specs
bundle exec standardrb    # run only the formatter/linter
bin/console               # open Pry with Synctest loaded
bundle exec rake build    # build the gem

License

Synctest is available under the terms of the MIT License.