A behavior module for GenServers with virtual time support.
This module wraps GenServer and provides a send_after/3 function that can
work with either real time (production) or virtual time (testing).
Example
defmodule MyTimedServer do
use VirtualTimeGenServer
def start_link(opts) do
VirtualTimeGenServer.start_link(__MODULE__, :ok, opts)
end
@impl true
def init(:ok) do
# Schedule a tick every 1000ms
schedule_tick()
{:ok, %{count: 0}}
end
@impl true
def handle_info(:tick, state) do
new_count = state.count + 1
schedule_tick()
{:noreply, %{state | count: new_count}}
end
defp schedule_tick do
VirtualTimeGenServer.send_after(self(), :tick, 1000)
end
endTesting with Virtual Time - Global Clock (Coordinated Simulation)
test "server ticks correctly" do
{:ok, clock} = VirtualClock.start_link()
VirtualTimeGenServer.set_virtual_clock(clock)
{:ok, server} = MyTimedServer.start_link([])
# Advance virtual time by 5 seconds
VirtualClock.advance(clock, 5000)
# Server will have ticked 5 times
assert get_count(server) == 5
endTesting with Virtual Time - Local Clock (Isolated Simulation)
test "isolated simulation with local clock" do
{:ok, clock} = VirtualClock.start_link()
# Pass clock directly to this specific server
{:ok, server} = VirtualTimeGenServer.start_link(MyTimedServer, :ok, virtual_clock: clock)
# Advance only this server's timeline
VirtualClock.advance(clock, 5000)
assert get_count(server) == 5
endClock Configuration Options
The virtual clock can be configured in three ways (in priority order):
Local Clock Injection - Pass
virtual_clock: clock_pidtostart_link/3:- Highest priority, overrides global settings
- Useful for isolated simulations or testing components independently
- Each server can have its own timeline
Global Clock - Use
set_virtual_clock/1:- Inherited by all child processes
- Essential for coordinated actor systems where timing relationships matter
- All actors share the same timeline
Real Time - Pass
real_time: trueor useuse_real_time/0:- Default behavior, uses
Process.send_after/3 - For production or integration tests with external systems
- Default behavior, uses
See the "Development Documentation" for a detailed explanation of when to use global vs local clocks.
Observing a server
Counting messages, tracing them or drawing diagrams of them is a different
concern from controlling time, so this library does none of it. It offers a
port instead: a module implementing VirtualTimeGenServer.Instrumentation
sees every message a server receives and sends, and snapshot/1 asks it what
it collected.
Summary
Functions
Makes a synchronous call to a server.
Cancels a timer created with send_after/3. Uses the appropriate backend based on the current process configuration.
Sends an asynchronous request to a server.
Gets the current time backend.
Instruments the calling process with instrumentation.
Sends a message to a process after a delay in milliseconds. Uses the appropriate backend based on the current process configuration.
Sends a message immediately in virtual time.
Sets the virtual clock for the current process. All child processes will inherit this setting.
Sets the virtual clock for the current process without emitting warnings.
Sleeps for the specified duration in milliseconds.
Asks an instrumented server what its observer collected.
Uses real time (default behavior).
Uses real time without emitting warnings.
Functions
Makes a synchronous call to a server.
If the calling process is instrumented (see instrument/1), the message is
reported to the observer before it leaves. Otherwise this is a direct
passthrough to GenServer.call/3.
Cancels a timer created with send_after/3. Uses the appropriate backend based on the current process configuration.
Sends an asynchronous request to a server.
If the calling process is instrumented (see instrument/1), the message is
reported to the observer before it leaves. Otherwise this is a direct
passthrough to GenServer.cast/2.
Gets the current time backend.
@spec instrument(VirtualTimeGenServer.Instrumentation.t()) :: :ok
Instruments the calling process with instrumentation.
Every message the calling process sends through call/3 or cast/2 is then
reported to the observer's on_send/2 callback. Starting a server with the
:instrumentation option instruments that server in the same way, so a server
reports both what it sends and what it receives.
instrumentation is a module implementing VirtualTimeGenServer.Instrumentation,
or {module, context} when the callbacks need context. Pass nil to stop
observing.
Example
defmodule MyServer do
use VirtualTimeGenServer
# ...
end
{:ok, clock} = VirtualClock.start_link()
VirtualTimeGenServer.instrument({MessageCounter, %{label: :test}})
{:ok, server} =
VirtualTimeGenServer.start_link(MyServer, :ok, virtual_clock: clock)
VirtualTimeGenServer.call(server, :ping)
Sends a message to a process after a delay in milliseconds. Uses the appropriate backend based on the current process configuration.
Sends a message immediately in virtual time.
With virtual time, this schedules the message for the current virtual time, ensuring it gets processed in the next event cycle. With real time, this sends the message immediately.
This is useful for triggering immediate responses or state changes within the virtual time simulation.
Examples
# Send immediate message to self
VirtualTimeGenServer.send_immediately(self(), :process_now)
# Send immediate message to another process
VirtualTimeGenServer.send_immediately(other_pid, {:urgent, data})
Sets the virtual clock for the current process. All child processes will inherit this setting.
Example
iex> {:ok, clock} = VirtualClock.start_link()
iex> VirtualTimeGenServer.set_virtual_clock(clock)
VirtualTimeBackend
iex> VirtualTimeGenServer.get_time_backend()
VirtualTimeBackend
Sets the virtual clock for the current process without emitting warnings.
Use this when you intentionally want global virtual clock behavior and understand the implications. The explanation message should describe why global clock is needed.
Example
iex> {:ok, clock} = VirtualClock.start_link()
iex> VirtualTimeGenServer.set_virtual_clock(clock, :i_know_what_i_am_doing, "coordinated simulation")
VirtualTimeBackend
Sleeps for the specified duration in milliseconds.
With virtual time, this advances the actor's position in the timeline without consuming real time. With real time, this blocks using Process.sleep/1.
This is useful for simulating work or processing delays within actors.
Examples
# In a GenServer callback - simulate 100ms of processing time
def handle_call(:compute, _from, state) do
VirtualTimeGenServer.sleep(100) # Simulated work
{:reply, :done, state}
end
# With virtual time: returns instantly but advances virtual clock
# With real time: blocks for 100ms
@spec snapshot(GenServer.server()) :: {:ok, term()} | {:error, :not_instrumented}
Asks an instrumented server what its observer collected.
The observer's snapshot/1 runs in the server process, so it can read state
the observer keeps there. The result is the observer's own term, handed back
unchanged.
Example
{:ok, clock} = VirtualClock.start_link()
{:ok, server} =
VirtualTimeGenServer.start_link(MyServer, :ok,
virtual_clock: clock,
instrumentation: MessageCounter
)
VirtualTimeGenServer.call(server, :ping)
{:ok, %{received: 1}} = VirtualTimeGenServer.snapshot(server)
See GenServer.stop/3.
Uses real time (default behavior).
Uses real time without emitting warnings.
Use this when you intentionally want global real time behavior and understand the implications. The explanation message should describe why global real time is needed.
Example
iex> VirtualTimeGenServer.use_real_time(:i_know_what_i_am_doing, "production mode")
RealTimeBackend