GenServerVirtualTime

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.

Hex.pm Documentation CI Coverage Status FOSSA Status

🎬 Live examples & reports β€’ πŸ“Š Flowchart reports

⚑ Start here

  1. Add the dependency:

    def deps do
      [{:gen_server_virtual_time, "~> 0.6.1"}]
    end
  2. Swap use GenServer for use VirtualTimeGenServer β€” nothing else changes.

  3. 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
  4. 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

🧭 Which page do I need?

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

πŸš€ Code Generators

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 build config, a CI workflow, a test suite and a runnable project layout. All but OMNeT++ also emit callback interfaces, so you can customise behaviour without editing generated files.

Show Me The Code

Test 100 seconds of behavior in milliseconds

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
end

Test state machines with virtual time

defmodule SwitchStateMachine do
  use VirtualTimeGenStateMachine, callback_mode: :handle_event_function

  def start_link(opts) do
    VirtualTimeGenStateMachine.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

  def timeout_fired?(server) do
    VirtualTimeGenStateMachine.call(server, :timeout_fired?)
  end
end

test "state machine transitions and timers" do
  {:ok, clock} = VirtualClock.start_link()

  {:ok, sm} = SwitchStateMachine.start_link(virtual_clock: clock)

  # Trigger transition and schedule timeout
  VirtualTimeGenStateMachine.cast(sm, :flip)

  # Advance virtual time - timeout fires instantly
  VirtualClock.advance(clock, 100)

  # Check timeout fired
  assert SwitchStateMachine.timeout_fired?(sm)
end

Simulate message-passing systems

import 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 ~1000

Export to production C++

OMNeT++ 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 test

Pony (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 test

Phony (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 test

Customize 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
    }
}

Visualize with sequence diagrams

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)

# The message flow as a Mermaid sequence diagram
mermaid = ActorSimulation.trace_to_mermaid(simulation, enhanced: true)
File.write!("diagram.mmd", mermaid)

# Or a standalone HTML report: topology, stats and the model source
{:ok, _path} = ActorSimulation.write_flowchart_report(simulation, "report.html",
  title: "Client / server")

Generate flowchart reports with statistics

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)

Why virtual time?

Problem Real time Virtual time
Test 1 hour behavior 1 hour wait ~33 ms (measured)
Flaky timing issues Common None: the clock is a value you advance
Precise assertions >= 10 == 10
Deterministic No Yes
Throughput 1x ~60,000 virtual events per real second

The throughput figure is measured, not estimated: run mix run benchmarks/virtual_time_speedup.exs to reproduce it on your machine.

The clock is what is deterministic: the same run produces the same message counts, the same timestamps and the same order for events at different instants. Tracing is an observation of a run rather than part of that guarantee, so when several messages fall on the same instant, a sequence diagram may record them in the order the actors happened to be scheduled.

What you get

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.

Quick API Reference

# 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 Patterns

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

Message Handlers

# 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
end

More Examples

Request-Response Pattern

ActorSimulation.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)

Pipeline Architecture

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)

Process-in-the-Loop (Mix Real & Simulated)

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 a project

  1. 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
  2. Build the output with its own toolchain. Each generated project ships a README.md with the exact commands:

    Generated project Toolchain
    examples/omnetpp_pubsub CMake + Conan
    examples/caf_pubsub CMake + Conan
    examples/pony_pubsub Pony + Corral
    examples/phony_pubsub Go modules
    examples/ractor_pipeline Cargo
    generated/vlingo_loadbalanced Maven

Documentation

Performance

A simulation ticking once per virtual second replays a virtual hour in tens of milliseconds β€” roughly 60,000 virtual events per real second on an Apple Silicon laptop. Scale it by events, not by simulated time: a 1 ms ticker produces 1000x more events for the same virtual duration.

Simulated time Real time (measured)
1 second ~1 ms
1 minute ~1 ms
10 minutes ~5 ms
1 hour ~33 ms

Reproduce with mix run benchmarks/virtual_time_speedup.exs; the numbers move with the machine, the order of magnitude does not.

Inspiration

Contributing

Contributions welcome! Please see the Contributing Guide.

License

MIT License - See the LICENSE file for details.

FOSSA Status