Test time-based GenServers instantly — no waiting, no flaky timing. Simulate actor systems with virtual time, then generate the same system in C++, Go, Pony, Rust, Java or OMNeT++.
This is the RxJS TestScheduler
idea applied to the BEAM: the clock is yours to advance, so a test describes the
timeline it wants instead of sleeping through it. Same purpose for GenServer
and :gen_statem, plus an actor-simulation DSL for modelling whole systems and
generators that turn those models into runnable projects in six languages.
-
Add the dependency:
def deps do [{:gen_server_virtual_time, "~> 0.6.0"}] end
-
Swap
use GenServerforuse VirtualTimeGenServer— nothing else changes. -
Point a clock at it, then jump time:
{:ok, clock} = VirtualClock.start_link() {:ok, server} = VirtualTimeGenServer.start_link(MyServer, :ok, virtual_clock: clock) VirtualClock.advance(clock, 10_000) # 10 virtual seconds, ~10ms real
-
Run the century demo — 100 years of daily backups in seconds (a minute or two on a busy machine) — source, sample run:
elixir scripts/century_backup_demo.exs
| I want to… | Go to |
|---|---|
| Test a GenServer with timers | 100 seconds in milliseconds |
| Test a state machine with timeouts | State machine example |
| Simulate actors talking to each other | Actor simulation |
| Draw a sequence diagram of the message flow | Visualize with sequence diagrams |
| Generate C++ / Go / Pony / Rust / Java / OMNeT++ | Code generators |
| Understand how the clock works | VirtualClock design |
Generate a working actor system, with build files, CI and tests, from the DSL:
| Generator | Language | Framework | Output |
|---|---|---|---|
| CAF | C++ | C++ Actor Framework | Typed actors, CMake build, Conan deps, tests |
| Phony | Go | Phony | Zero-alloc actors, Go modules, tests |
| Pony | Pony | Pony Language | Type-safe actors, Corral deps, PonyTest |
| Ractor | Rust | Ractor | Gen_server-inspired, Cargo, async/await |
| VLINGO | Java | VLINGO XOOM | Protocol actors, Maven, JUnit 5 |
| OMNeT++ | C++ | OMNeT++ | Discrete-event simulation, NED files, CMake |
Every generator emits the same five things: build config, a CI workflow, callback interfaces for your code, a test suite, and a runnable project layout.
defmodule MyServer do
use VirtualTimeGenServer # <-- Drop-in replacement for GenServer
def init(state) do
VirtualTimeGenServer.send_after(self(), :work, 1000)
{:ok, state}
end
def handle_info(:work, state) do
VirtualTimeGenServer.send_after(self(), :work, 1000)
{:noreply, %{state | count: state.count + 1}}
end
end
test "100 seconds completes instantly" do
{:ok, clock} = VirtualClock.start_link()
VirtualTimeGenServer.set_virtual_clock(clock)
{:ok, server} = MyServer.start_link(%{count: 0})
VirtualClock.advance(clock, 100_000) # 100s virtual, ~10ms real ⚡
assert GenServer.call(server, :get_count) == 100
enddefmodule SwitchStateMachine do
use VirtualTimeGenStateMachine, callback_mode: :handle_event_function
def start_link(opts) do
GenStateMachine.start_link(__MODULE__, :off, opts)
end
def init(_) do
{:ok, :off, %{flip_count: 0}}
end
def handle_event(:cast, :flip, :off, data) do
# Schedule timeout for 100ms
VirtualTimeGenStateMachine.send_after(self(), :timeout, 100)
{:next_state, :on, %{data | flip_count: data.flip_count + 1}}
end
def handle_event(:cast, :flip, :on, data) do
{:next_state, :off, data}
end
def handle_event(:info, :timeout, state, data) do
{:keep_state, %{data | timeout_fired: true}}
end
end
test "state machine transitions and timers" do
{:ok, clock} = VirtualClock.start_link()
VirtualTimeGenStateMachine.set_virtual_clock(clock)
{:ok, sm} = SwitchStateMachine.start_link([])
# Trigger transition and schedule timeout
GenStateMachine.cast(sm, :flip)
# Advance virtual time - timeout fires instantly
VirtualClock.advance(clock, 100) # ~10ms real time ⚡
# Check timeout fired
assert get_state(sm).timeout_fired == true
endimport ActorSimulation
# Pipeline: producer → consumer
simulation = new(trace: true)
|> add_actor(:producer,
send_pattern: {:rate, 100, :data}, # 100 msgs/sec
targets: [:consumer])
|> add_actor(:consumer,
on_receive: fn :data, s -> {:ok, %{s | count: s.count + 1}} end,
initial_state: %{count: 0})
|> run(duration: 10_000) # 10s virtual in milliseconds
stats = get_stats(simulation)
# producer sent ~1000, consumer received ~1000OMNeT++ Network Simulations:
simulation = ActorSimulation.new()
|> ActorSimulation.add_actor(:publisher,
send_pattern: {:periodic, 100, :event},
targets: [:sub1, :sub2, :sub3])
|> ActorSimulation.add_actor(:sub1)
|> ActorSimulation.add_actor(:sub2)
|> ActorSimulation.add_actor(:sub3)
# Generate complete OMNeT++ C++ project
{:ok, files} = ActorSimulation.OMNeTPPGenerator.generate(simulation,
network_name: "PubSub",
sim_time_limit: 10)
ActorSimulation.OMNeTPPGenerator.write_to_directory(files, "omnetpp_output/")C++ Actor Framework (CAF) with Callbacks:
# Generate CAF project with callback interfaces
{:ok, files} = ActorSimulation.CAFGenerator.generate(simulation,
project_name: "PubSubActors",
enable_callbacks: true)
ActorSimulation.CAFGenerator.write_to_directory(files, "caf_output/")VLINGO XOOM Actors (Java):
# Generate type-safe Java actor system
{:ok, files} = ActorSimulation.VlingoGenerator.generate(simulation,
project_name: "pubsub-actors",
group_id: "com.example",
enable_callbacks: true)
ActorSimulation.VlingoGenerator.write_to_directory(files, "vlingo_output/")
# Then build and test with Maven:
# cd vlingo_output && mvn testPony (Capabilities-Secure Actors):
# Generate Pony actor system
{:ok, files} = ActorSimulation.PonyGenerator.generate(simulation,
project_name: "pubsub",
enable_callbacks: true)
ActorSimulation.PonyGenerator.write_to_directory(files, "pony_output/")
# Then build and test:
# cd pony_output && make testPhony (Go Actors):
# Generate Go actor system with Phony
{:ok, files} = ActorSimulation.PhonyGenerator.generate(simulation,
project_name: "pubsub",
enable_callbacks: true)
ActorSimulation.PhonyGenerator.write_to_directory(files, "phony_output/")
# Then build and test:
# cd phony_output && go test ./...Ractor (Rust Actors):
# Generate Rust actor system with Ractor
{:ok, files} = ActorSimulation.RactorGenerator.generate(simulation,
project_name: "pubsub",
enable_callbacks: true)
ActorSimulation.RactorGenerator.write_to_directory(files, "ractor_output/")
# Then build and test:
# cd ractor_output && cargo testCustomize WITHOUT touching generated code:
// CAF: publisher_callbacks_impl.cpp
void publisher_callbacks::on_event() {
log_to_database();
send_metrics();
}// VLINGO: PublisherCallbacksImpl.java
public void onEvent() {
logger.info("Publishing event");
metrics.increment("events.published");
}// Ractor: publisher.rs
impl PublisherCallbacks for MyPublisherCallbacks {
fn on_event(&self) {
println!("Publishing event with custom handler");
// Add your custom logic here
}
}simulation = ActorSimulation.new(trace: true)
|> ActorSimulation.add_actor(:client,
send_pattern: {:periodic, 100, :ping},
targets: [:server])
|> ActorSimulation.add_actor(:server,
on_match: [{:ping, fn s -> {:reply, :pong, s} end}])
|> ActorSimulation.run(duration: 1000)
# Generate Mermaid sequence diagram
mermaid = ActorSimulation.trace_to_mermaid(simulation, enhanced: true)
File.write!("diagram.html", ActorSimulation.wrap_mermaid_html(mermaid))
# Open in browser to see message flows!simulation = ActorSimulation.new()
|> ActorSimulation.add_actor(:producer,
send_pattern: {:rate, 100, :data},
targets: [:stage1, :stage2])
|> ActorSimulation.add_actor(:stage1,
targets: [:sink], # Define targets for flowchart edges
on_receive: fn msg, s -> {:send, [{:sink, msg}], s} end)
|> ActorSimulation.add_actor(:stage2,
targets: [:sink],
on_receive: fn msg, s -> {:send, [{:sink, msg}], s} end)
|> ActorSimulation.add_actor(:sink)
|> ActorSimulation.run(duration: 5000)
# Generate flowchart with embedded statistics
html = ActorSimulation.generate_flowchart_report(simulation,
title: "Pipeline System",
layout: "TB", # Top-to-bottom (or "LR", "RL", "BT")
show_stats_on_nodes: true,
style_by_activity: true # Color-code by message activity
)
File.write!("report.html", html)
# Open in browser to see:
# • Actor topology as Mermaid flowchart
# • Message counts and rates on nodes
# • Activity-based color coding
# • Detailed statistics table
# • Virtual time speedup metrics
# 💡 Tip: For dynamic sends without targets, enable tracing:
# simulation = ActorSimulation.new(trace: true)| Problem | Real time | Virtual time |
|---|---|---|
| Test 1 hour behavior | 1 hour wait | ~10 seconds |
| Flaky timing issues | Common | None |
| Precise assertions | >= 10 |
== 10 |
| Deterministic | No | Yes |
| Speedup | 1x | 10-100x |
| Module | Use it to |
|---|---|
VirtualTimeGenServer |
Test real GenServers with timers |
VirtualTimeGenStateMachine |
Test state machines with timeouts |
ActorSimulation |
Prototype distributed systems, with stats and traces |
ActorSimulation.*Generator |
Export the same system to another language |
Both wrappers are drop-in replacements: use VirtualTimeGenServer instead of
use GenServer, use VirtualTimeGenStateMachine instead of
use GenStateMachine. Every standard callback keeps working — handle_call,
handle_cast, handle_info, handle_continue, handle_event, timeouts.
The simulation DSL gives you send patterns (:periodic, :rate, :burst),
declarative on_match handlers, process-in-the-loop (mix real GenServers with
simulated actors), and built-in statistics and tracing.
Generators ship the tests too: Catch2, PonyTest, Go tests, Cargo tests, and JUnit 5, each with a CI workflow. Prototype in Elixir at 10-100x speed, then scale in production.
# Virtual Clock
{:ok, clock} = VirtualClock.start_link()
VirtualClock.advance(clock, 5000) # Jump 5 seconds
VirtualClock.advance_to_next(clock) # Jump to next event
VirtualClock.now(clock) # Current virtual time
# Virtual Time GenServer
use VirtualTimeGenServer
VirtualTimeGenServer.set_virtual_clock(clock)
VirtualTimeGenServer.send_after(pid, msg, delay)
# Virtual Time GenStateMachine
use VirtualTimeGenStateMachine, callback_mode: :handle_event_function
VirtualTimeGenStateMachine.set_virtual_clock(clock)
VirtualTimeGenStateMachine.send_after(pid, msg, delay)
# Actor Simulation (import for clean DSL)
import ActorSimulation
new(trace: true)
|> add_actor(name, opts)
|> add_process(name, module: M, args: args) # Real GenServer!
|> run(duration: ms)
|> get_stats()
|> trace_to_mermaid()send_pattern: {:periodic, 100, :tick} # Every 100ms
send_pattern: {:rate, 50, :event} # 50 messages/second
send_pattern: {:burst, 10, 500, :batch} # 10 msgs every 500ms# Pattern matching (declarative)
on_match: [
{:ping, fn s -> {:reply, :pong, s} end},
{:get, fn s -> {:reply, s.value, s} end}
]
# Function handler (imperative)
on_receive: fn msg, state ->
case msg do
:increment -> {:ok, %{state | count: state.count + 1}}
{:set, val} -> {:send, [{:logger, :updated}], %{state | value: val}}
end
endActorSimulation.new()
|> ActorSimulation.add_actor(:client,
send_pattern: {:periodic, 100, :get_data},
targets: [:server])
|> ActorSimulation.add_actor(:server,
on_match: [
{:get_data, fn s -> {:reply, {:data, 42}, s} end},
{:save, fn s -> {:reply, :saved, %{s | saved: true}} end}
])
|> ActorSimulation.run(duration: 1000)forward = fn msg, s -> {:send, [{s.next, msg}], s} end
ActorSimulation.new()
|> ActorSimulation.add_actor(:input,
send_pattern: {:rate, 50, :data},
targets: [:stage1])
|> ActorSimulation.add_actor(:stage1,
on_receive: forward,
initial_state: %{next: :stage2})
|> ActorSimulation.add_actor(:stage2,
on_receive: forward,
initial_state: %{next: :output})
|> ActorSimulation.add_actor(:output)
|> ActorSimulation.run(duration: 10_000)defmodule MyRealServer do
use VirtualTimeGenServer
def handle_call(:get, _from, state) do
{:reply, state.requests, %{state | requests: state.requests + 1}}
end
end
# Test real GenServer alongside simulated actors
ActorSimulation.new()
|> ActorSimulation.add_process(:real_server, # ← Real GenServer
module: MyRealServer, args: nil)
|> ActorSimulation.add_actor(:client, # ← Simulated actor
send_pattern: {:periodic, 100, {:call, :get}},
targets: [:real_server])
|> ActorSimulation.run(duration: 1000)Similar to hardware-in-the-loop testing, but for processes.
-
Generate the example projects for the language you want:
mix run scripts/generate_omnetpp_examples.exs # C++ / OMNeT++ mix run scripts/generate_caf_examples.exs # C++ / CAF mix run scripts/generate_pony_examples.exs # Pony mix run scripts/generate_phony_examples.exs # Go / Phony mix run examples/ractor_demo.exs # Rust / Ractor mix run scripts/generate_vlingo_sample.exs # Java / VLINGO XOOM
-
Build the output with its own toolchain. Each generated project ships a
README.mdwith the exact commands:Generated project Toolchain examples/omnetpp_pubsubCMake + Conan examples/caf_pubsubCMake + Conan examples/pony_pubsubPony + Corral examples/phony_pubsubGo modules examples/ractor_pipelineCargo generated/vlingo_loadbalancedMaven
- Generators overview — links every generator guide (OMNeT++, CAF, Pony, Phony, Ractor, VLINGO XOOM)
- VirtualClock design — how the clock schedules and orders events
- Local clock injection — isolated, race-free tests
- Flowchart reports — topology diagrams with stats
- API reference · Contributing guide
Processing rate: ~6,000 virtual events per real second (M1 MacBook Pro)
| Simulated Time | Real Time | Speedup |
|---|---|---|
| 1 second | ~10ms | 100x |
| 10 seconds | ~100ms | 100x |
| 1 minute | ~6s | 10x |
| 10 minutes | ~60s | 10x |
| 1 hour | ~6 min | 10x |
- RxJS TestScheduler - Virtual time for reactive programming
- Don't Wait Forever for Tests - Testing philosophy
Contributions welcome! Please see the Contributing Guide.
MIT License - See the LICENSE file for details.