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.nowand argumentlessTime.new, including theirin: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, andThread.fork(the thread factory, not a process fork), including recursively created descendants;Thread.stop,Thread#wakeup,Thread#run,Thread#kill,Thread#terminate,Thread#exit, andThread#raise;Thread#join(timeout)andThread#value;Kernel#sleep;Mutexlocking,#synchronize,#try_lock, and timed#sleep;ConditionVariable#wait,#signal, and#broadcast;Queueoperations, including blocking pops, pop timeouts, pushes, closing, and clearing;SizedQueueoperations, including blocking pops and pushes, timeouts, closing, clearing, and capacity changes;Monitorreentrant 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::DeadlockErrormeans every active tracked thread is durably blocked and no virtual timer can make progress.Synctest::StalledErrormeans no tracked progress occurred during a coordinator wait or bubble teardown fortimeoutreal seconds. Unsupported I/O, native code, or an unassociated primitive is a common cause.Synctest::IsolationErrorprevents a bubble-owned object from escaping its owner.Synctest::NestedBubbleErrorandSynctest::ConcurrentWaitErrorreject ambiguous bubble lifecycles.Synctest::NotInBubbleErrorreports bubble-only API calls made outside a run.Synctest::UnsupportedOperationErrorreports 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#valueon the bubble's root thread because the root cannot acquire its final native value until bubble teardown has waited for the child; use a timedThread#joinor 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.