Packages

Run WebAssembly from Elixir. Load WASM modules in Rust, Go, C — call them like native functions.

Current section

Files

Jump to
firebird lib firebird hot_reload.ex
Raw

lib/firebird/hot_reload.ex

defmodule Firebird.HotReload do
@moduledoc """
Hot-reload WASM modules during development.
Watches WASM files for changes and automatically reloads affected
instances. Useful during development when iterating on WASM modules.
## Usage
# Start watching a file
{:ok, watcher} = Firebird.HotReload.start_link(
path: "priv/wasm/math.wasm",
callback: fn new_instance ->
IO.puts("Reloaded! New instance: \#{inspect(new_instance)}")
end
)
# Or use with a module-style callback
{:ok, watcher} = Firebird.HotReload.start_link(
path: "priv/wasm/math.wasm",
interval: 1000, # check every 1s (default: 2000ms)
opts: [wasi: true]
)
# Get current instance
{:ok, instance} = Firebird.HotReload.instance(watcher)
# Call functions (automatically uses latest instance)
{:ok, [8]} = Firebird.HotReload.call(watcher, :add, [5, 3])
# Stop watching
Firebird.HotReload.stop(watcher)
"""
use GenServer
require Logger
defstruct [:path, :opts, :callback, :instance, :last_modified, :interval]
# 2 seconds
@default_interval 2_000
@doc """
Start a hot-reload watcher for a WASM file.
## Options
- `:path` - Path to WASM file (required)
- `:opts` - Options passed to Firebird.load/2 (e.g., `[wasi: true]`)
- `:callback` - Function called with new instance pid after reload
- `:interval` - File check interval in ms (default: 2000)
- `:name` - Optional GenServer name
"""
def start_link(opts) do
path = Keyword.fetch!(opts, :path)
unless File.exists?(path) do
raise ArgumentError, "WASM file not found: #{path}"
end
{name, opts} = Keyword.pop(opts, :name)
gen_opts = if name, do: [name: name], else: []
GenServer.start_link(__MODULE__, opts, gen_opts)
end
@doc """
Get the current WASM instance.
"""
@spec instance(GenServer.server()) :: {:ok, pid()} | {:error, :not_loaded}
def instance(server) do
GenServer.call(server, :instance)
end
@doc """
Call a function on the current instance.
"""
@spec call(GenServer.server(), atom() | String.t(), list()) :: {:ok, list()} | {:error, term()}
def call(server, function, args) do
case instance(server) do
{:ok, pid} -> Firebird.call(pid, function, args)
error -> error
end
end
@doc """
Force a reload of the WASM module.
"""
@spec reload(GenServer.server()) :: :ok | {:error, term()}
def reload(server) do
GenServer.call(server, :reload)
end
@doc """
Get reload statistics.
"""
@spec stats(GenServer.server()) :: map()
def stats(server) do
GenServer.call(server, :stats)
end
@doc """
Stop the hot-reload watcher.
"""
@spec stop(GenServer.server()) :: :ok
def stop(server) do
GenServer.stop(server, :normal)
end
# GenServer callbacks
@impl true
def init(opts) do
path = Keyword.fetch!(opts, :path)
load_opts = Keyword.get(opts, :opts, [])
callback = Keyword.get(opts, :callback)
interval = Keyword.get(opts, :interval, @default_interval)
case load_wasm(path, load_opts) do
{:ok, instance, mtime} ->
schedule_check(interval)
{:ok,
%__MODULE__{
path: path,
opts: load_opts,
callback: callback,
instance: instance,
last_modified: mtime,
interval: interval
}}
{:error, reason} ->
{:stop, reason}
end
end
@impl true
def handle_call(:instance, _from, state) do
if state.instance && Process.alive?(state.instance) do
{:reply, {:ok, state.instance}, state}
else
{:reply, {:error, :not_loaded}, state}
end
end
def handle_call(:reload, _from, state) do
case do_reload(state) do
{:ok, new_state} -> {:reply, :ok, new_state}
{:error, reason} -> {:reply, {:error, reason}, state}
end
end
def handle_call(:stats, _from, state) do
stats = %{
path: state.path,
last_modified: state.last_modified,
instance_alive: state.instance != nil && Process.alive?(state.instance),
interval: state.interval
}
{:reply, stats, state}
end
@impl true
def handle_info(:check_file, state) do
new_state = check_and_reload(state)
schedule_check(state.interval)
{:noreply, new_state}
end
def handle_info({:DOWN, _ref, :process, pid, _reason}, %{instance: pid} = state) do
Logger.warning("[Firebird.HotReload] WASM instance crashed, reloading...")
case do_reload(state) do
{:ok, new_state} -> {:noreply, new_state}
{:error, _} -> {:noreply, %{state | instance: nil}}
end
end
def handle_info(_msg, state), do: {:noreply, state}
@impl true
def terminate(_reason, state) do
if state.instance && Process.alive?(state.instance) do
Firebird.stop(state.instance)
end
:ok
end
# Private helpers
defp load_wasm(path, opts) do
mtime = File.stat!(path).mtime
case Firebird.load(path, opts) do
{:ok, pid} ->
Process.monitor(pid)
{:ok, pid, mtime}
{:error, reason} ->
{:error, reason}
end
end
defp check_and_reload(state) do
case File.stat(state.path) do
{:ok, %{mtime: mtime}} when mtime != state.last_modified ->
Logger.info("[Firebird.HotReload] #{state.path} changed, reloading...")
case do_reload(state) do
{:ok, new_state} ->
new_state
{:error, reason} ->
Logger.error("[Firebird.HotReload] Reload failed: #{inspect(reason)}")
state
end
_ ->
state
end
end
defp do_reload(state) do
# Stop old instance
if state.instance && Process.alive?(state.instance) do
Firebird.stop(state.instance)
end
# Load new instance
case load_wasm(state.path, state.opts) do
{:ok, new_pid, mtime} ->
if state.callback, do: state.callback.(new_pid)
{:ok, %{state | instance: new_pid, last_modified: mtime}}
{:error, reason} ->
{:error, reason}
end
end
defp schedule_check(interval) do
Process.send_after(self(), :check_file, interval)
end
end