Packages

Deterministic replay debugging for Elixir GenServers. Record what led to a crash, then replay it exactly.

Current section

Files

Jump to
replayx lib replayx.ex
Raw

lib/replayx.ex

defmodule Replayx do
@moduledoc """
Deterministic replay debugging for Elixir processes.
Reproduce crashes. Kill Heisenbugs. Sleep better.
## Record mode
1. Start a recorder and your instrumented GenServer with the recorder pid in state.
2. Interact with the GenServer (send messages, call, cast).
3. Stop the recorder to flush the trace to a file.
Example:
Replayx.record("trace.json", fn recorder_pid ->
{:ok, pid} = MyApp.MyServer.start_link([recorder_pid])
GenServer.call(pid, :do_something)
GenServer.call(pid, :crash)
end)
## Replay mode
mix replay trace.json MyApp.MyServer
Or from code:
Replayx.replay("trace.json", MyApp.MyServer)
Your GenServer must `use Replayx.GenServer` and use `Replayx.Clock` / `Replayx.Rand`
instead of `System` / `:rand` in callbacks so that time and randomness are deterministic.
"""
@doc """
Runs the given function with a recorder started; when the function returns,
the recorder is stopped and the trace is written to the path.
The function receives the recorder pid — pass it to your GenServer's init.
You can pass either a path or a module that uses Replayx.GenServer with a `trace_file` option:
- `record(path, fun)` – use `path` as the trace file (e.g. `"trace.json"`).
- `record(module, fun)` – use the module's default trace file from `use Replayx.GenServer, trace_file: "..."`.
**Important:** Do not return from the function until the GenServer has finished
processing all messages you sent. Otherwise the recorder is stopped while the
server still tries to record (e.g. `next_seq`) and you get an EXIT. Use sync
calls or wait for the process to exit (e.g. `Process.monitor` + `assert_receive {:DOWN, ...}`)
before returning.
"""
@spec record(String.t() | module(), (pid() -> term())) :: term()
def record(path_or_module, fun) when is_function(fun, 1) do
{path, opts} = record_path_and_opts(path_or_module)
{:ok, recorder_pid} = Replayx.Recorder.start_link(path, opts)
try do
fun.(recorder_pid)
after
if Process.alive?(recorder_pid) do
try do
Replayx.Recorder.stop(recorder_pid)
catch
:exit, _ -> :ok
end
end
end
end
defp record_opts(path) when is_binary(path), do: []
defp record_opts(module) when is_atom(module) do
[
buffer_size: module.__replayx_trace_buffer_size__(),
dir: module.__replayx_trace_dir__(),
base_prefix: module.__replayx_trace_base__(),
rotation: module.__replayx_trace_rotation__()
]
end
@doc """
Replays the trace file with the given module. The module must use Replayx.GenServer
and its `init_impl/1` returns `{:ok, state}`; the library injects `replayx_replayer` when replaying.
Returns `{:ok, final_state}` or `{:error, reason}`.
You can pass either a path and module, or just the module:
- `replay(path, module)` – use `path` as the trace file.
- `replay(module)` – use the module's default trace file from `use Replayx.GenServer, trace_file: "..."`.
- `replay(path, module, opts)` – same with options (e.g. `step_fun` for step-through).
"""
@spec replay(String.t(), module()) :: {:ok, term()} | {:error, term()}
def replay(path, module) when is_binary(path) do
Replayx.Replayer.run(path, module, [])
end
@spec replay(module(), keyword()) :: {:ok, term()} | {:error, term()}
def replay(module, opts) when is_atom(module) and is_list(opts) do
path = trace_path_for_replay(module)
Replayx.Replayer.run(path, module, opts)
end
@spec replay(String.t(), module(), keyword()) :: {:ok, term()} | {:error, term()}
def replay(path, module, opts) when is_binary(path) and is_list(opts) do
Replayx.Replayer.run(path, module, opts)
end
@spec replay(module()) :: {:ok, term()} | {:error, term()}
def replay(module) when is_atom(module) do
path = trace_path_for_replay(module)
Replayx.Replayer.run(path, module, [])
end
@doc """
Returns the path that `replay(module)` would use: latest timestamped trace in the module's trace_dir, or `trace_dir/trace_file` if none.
Use this when you need the path (e.g. for `mix replay Module` to replay the last file by default).
"""
@spec trace_path_for_replay(module()) :: String.t()
def trace_path_for_replay(module) when is_atom(module) do
replay_path(module)
end
defp record_path_and_opts(path) when is_binary(path), do: {path, record_opts(path)}
defp record_path_and_opts(module) when is_atom(module) do
opts = record_opts(module)
base = module.__replayx_trace_base__()
{base, opts}
end
defp replay_path(module) when is_atom(module) do
dir = module.__replayx_trace_dir__()
base = module.__replayx_trace_base__()
case Replayx.Trace.latest_path(dir, base) do
nil -> Path.join(dir, module.__replayx_trace_file__())
path -> path
end
end
end