dev_wasm.erl - WebAssembly Execution Device
Overview
Purpose: Execute WebAssembly images on messages using Memory-64 preview standard
Module: dev_wasm
Device Name: wasm-64@1.0
Backend: BEAMR (Erlang wrapper for WAMR - WebAssembly Micro Runtime)
This device executes WASM images on HyperBEAM messages using the Memory-64 preview standard. It manages WASM instance lifecycle, handles imports/exports, provides state serialization/deserialization, and integrates with the HyperBEAM device stack for complex execution workflows.
Key Features
- WASM Execution: Run WebAssembly modules with Memory-64 support
- Import Resolution: Dynamic import resolution via AO-Core
- State Management: Serialize/deserialize WASM memory state
- Standard Library: Extensible stdlib system
- AOT Support: Ahead-of-time compilation (when enabled)
- Stack Integration: Works seamlessly with dev_stack
Dependencies
- HyperBEAM:
hb_beamr,hb_beamr_io,hb_ao,hb_private,hb_cache,hb_maps - Stack Device:
dev_stack - Message Device:
dev_message - Includes:
include/hb.hrl - Testing:
eunit
Public Functions Overview
%% Device Info
-spec info(Msg1, Opts) -> InfoMap.
%% Lifecycle
-spec init(M1, M2, Opts) -> {ok, InitializedMsg}.
-spec compute(M1, M2, Opts) -> {ok, ResultMsg}.
-spec terminate(M1, M2, Opts) -> {ok, CleanedMsg}.
%% State Management
-spec snapshot(M1, M2, Opts) -> {ok, SerializedState}.
-spec normalize(M1, M2, Opts) -> {ok, NormalizedMsg}.
%% Import Handling
-spec import(Msg1, Msg2, Opts) -> {ok, Result}.
%% API for Other Devices
-spec instance(M1, M2, Opts) -> Instance | not_found.
%% Test API
-spec cache_wasm_image(File) -> MessageWithImage.
-spec cache_wasm_image(File, Opts) -> MessageWithImage.Public Functions
1. info/2
-spec info(Msg1, Opts) -> InfoMap
when
Msg1 :: map(),
Opts :: map(),
InfoMap :: map().Description: Returns device information. Excludes the instance/3 function from AO-Core resolution (only for internal use by other devices).
-module(dev_wasm_info_test).
-include_lib("eunit/include/eunit.hrl").
info_excludes_instance_test() ->
Info = dev_wasm:info(#{}, #{}),
?assertEqual([instance], maps:get(excludes, Info)).2. init/3
-spec init(M1, M2, Opts) -> {ok, InitializedMsg}
when
M1 :: map(),
M2 :: map(),
Opts :: map(),
InitializedMsg :: map().Description: Boot a WASM image stated in the message. Loads the image, starts WASM executor, and sets up instance, import resolver, and I/O functions in private message space.
Image Source Resolution:[InPrefix]/image- ID or binarybody- Direct binary- Throws error if not found
wasm- Standard WASM execution (default)aot- Ahead-of-time compiled (requireswasm_allow_aotoption)
[Prefix]/instance- WASM executor instance[Prefix]/write- Write function for strings[Prefix]/read- Read function for strings[Prefix]/import-resolver- Import resolution function
-module(dev_wasm_init_test).
-include_lib("eunit/include/eunit.hrl").
init_test() ->
application:ensure_all_started(hb),
hb:init(),
Msg = dev_wasm:cache_wasm_image("test/test.wasm"),
{ok, Msg1} = hb_ao:resolve(Msg, <<"init">>, #{}),
Priv = hb_private:from_message(Msg1),
?assertMatch({ok, Instance} when is_pid(Instance),
hb_ao:resolve(Priv, <<"instance">>, #{})),
?assertMatch({ok, Fun} when is_function(Fun),
hb_ao:resolve(Priv, <<"import-resolver">>, #{})).
init_with_prefix_test() ->
application:ensure_all_started(hb),
hb:init(),
#{ <<"image">> := ImageID } = dev_wasm:cache_wasm_image("test/test.wasm"),
Msg = #{
<<"device">> => <<"wasm-64@1.0">>,
<<"input-prefix">> => <<"process">>,
<<"output-prefix">> => <<"wasm">>,
<<"process">> => #{ <<"image">> => ImageID }
},
{ok, InitMsg} = hb_ao:resolve(Msg, <<"init">>, #{}),
Priv = hb_private:from_message(InitMsg),
?assertMatch({ok, Instance} when is_pid(Instance),
hb_ao:resolve(Priv, <<"wasm/instance">>, #{})).3. compute/3
-spec compute(M1, M2, Opts) -> {ok, ResultMsg}
when
M1 :: map(),
M2 :: map(),
Opts :: map(),
ResultMsg :: map().Description: Execute WASM function with prepared message. Calls WASM executor with specified function and parameters, using import resolver for external functions.
Function Resolution Order:M2/body/functionM2/functionM1/function
M2/body/parametersM2/parametersM1/parameters
#{
<<"results/[Prefix]/type">> => ResultType,
<<"results/[Prefix]/output">> => Results
}- Only executes on pass 1 or when pass is not found
- Returns unchanged message on subsequent passes
-module(dev_wasm_compute_test).
-include_lib("eunit/include/eunit.hrl").
basic_execution_test() ->
application:ensure_all_started(hb),
hb:init(),
Msg0 = dev_wasm:cache_wasm_image("test/test.wasm"),
{ok, Msg1} = hb_ao:resolve(Msg0, <<"init">>, #{}),
Msg2 = Msg1#{
<<"function">> => <<"fac">>,
<<"parameters">> => [5.0]
},
{ok, Result} = hb_ao:resolve(Msg2, <<"compute">>, #{}),
?assertEqual({ok, [120.0]}, hb_ao:resolve(Result, <<"results/output">>, #{})).
compute_with_imports_test() ->
application:ensure_all_started(hb),
hb:init(),
Msg0 = dev_wasm:cache_wasm_image("test/pow_calculator.wasm"),
{ok, Msg1} = hb_ao:resolve(Msg0, <<"init">>, #{}),
Msg2 = hb_maps:merge(
Msg1,
hb_ao:set(
#{
<<"function">> => <<"pow">>,
<<"parameters">> => [2, 5]
},
#{
<<"stdlib/my_lib">> => #{ <<"device">> => <<"test-device@1.0">> }
},
#{ hashpath => ignore }
),
#{}
),
{ok, Result} = hb_ao:resolve(Msg2, <<"compute">>, #{}),
?assertEqual({ok, [32]}, hb_ao:resolve(Result, <<"results/output">>, #{})).4. snapshot/3
-spec snapshot(M1, M2, Opts) -> {ok, SerializedState}
when
M1 :: map(),
M2 :: map(),
Opts :: map(),
SerializedState :: map().Description: Serialize the WASM state to binary. Captures complete memory state for later restoration.
Return Structure:#{
<<"body">> => SerializedBinary
}-module(dev_wasm_snapshot_test).
-include_lib("eunit/include/eunit.hrl").
snapshot_and_restore_test() ->
application:ensure_all_started(hb),
hb:init(),
Msg0 = dev_wasm:cache_wasm_image("test/pow_calculator.wasm"),
{ok, Msg1} = hb_ao:resolve(Msg0, <<"init">>, #{}),
Msg2 = hb_maps:merge(
Msg1,
hb_ao:set(
#{
<<"function">> => <<"pow">>,
<<"parameters">> => [2, 3]
},
#{
<<"stdlib/my_lib">> => #{ <<"device">> => <<"test-device@1.0">> }
},
#{ hashpath => ignore }
),
#{}
),
{ok, Msg3} = hb_ao:resolve(Msg2, <<"compute">>, #{}),
?assertEqual({ok, [8]}, hb_ao:resolve(Msg3, <<"results/output">>, #{})),
{ok, Snapshot} = hb_ao:resolve(Msg3, <<"snapshot">>, #{}),
?assert(maps:is_key(<<"body">>, Snapshot)).5. normalize/3
-spec normalize(M1, M2, Opts) -> {ok, NormalizedMsg}
when
M1 :: map(),
M2 :: map(),
Opts :: map(),
NormalizedMsg :: map().Description: Normalize message to have an open WASM instance without literal snapshot key. If no instance exists, attempts to deserialize from snapshot. Ensures HashPath remains unchanged during normalization.
- Check for existing instance
- If not found, load snapshot
- Deserialize state into instance
- Remove snapshot key from message
-module(dev_wasm_normalize_test).
-include_lib("eunit/include/eunit.hrl").
normalize_with_instance_test() ->
application:ensure_all_started(hb),
hb:init(),
Msg0 = dev_wasm:cache_wasm_image("test/test.wasm"),
{ok, Msg1} = hb_ao:resolve(Msg0, <<"init">>, #{}),
% Normalize should succeed when instance exists
{ok, Normalized} = dev_wasm:normalize(Msg1, #{}, #{}),
% Instance should still be available after normalize
?assert(is_pid(dev_wasm:instance(Normalized, #{}, #{}))).
normalize_from_snapshot_test() ->
application:ensure_all_started(hb),
hb:init(),
Msg0 = dev_wasm:cache_wasm_image("test/test.wasm"),
{ok, Msg1} = hb_ao:resolve(Msg0, <<"init">>, #{}),
{ok, Snapshot} = hb_ao:resolve(Msg1, <<"snapshot">>, #{}),
% Terminate to remove instance
{ok, Terminated} = hb_ao:resolve(Msg1, <<"terminate">>, #{}),
% Add snapshot to terminated message
MsgWithSnapshot = Terminated#{ <<"snapshot">> => Snapshot },
{ok, Normalized} = dev_wasm:normalize(MsgWithSnapshot, #{}, #{}),
% Instance should be restored from snapshot
?assert(is_pid(dev_wasm:instance(Normalized, #{}, #{}))).6. terminate/3
-spec terminate(M1, M2, Opts) -> {ok, CleanedMsg}
when
M1 :: map(),
M2 :: map(),
Opts :: map(),
CleanedMsg :: map().Description: Tear down the WASM executor. Stops the instance and removes it from private message space.
Test Code:-module(dev_wasm_terminate_test).
-include_lib("eunit/include/eunit.hrl").
terminate_test() ->
application:ensure_all_started(hb),
hb:init(),
Msg0 = dev_wasm:cache_wasm_image("test/test.wasm"),
{ok, Msg1} = hb_ao:resolve(Msg0, <<"init">>, #{}),
% Verify instance exists before terminate
?assert(is_pid(dev_wasm:instance(Msg1, #{}, #{}))),
{ok, Terminated} = hb_ao:resolve(Msg1, <<"terminate">>, #{}),
?assertEqual(not_found, dev_wasm:instance(Terminated, #{}, #{})).7. import/3
-spec import(Msg1, Msg2, Opts) -> {ok, Result}
when
Msg1 :: map(),
Msg2 :: map(),
Opts :: map(),
Result :: map().Description: Handle standard library calls by resolving imports via AO-Core. Adjusts path to stdlib, adds state to message, and resolves. Falls back to stub handler if not found.
Import Resolution:- Adjust path:
[Prefix]/stdlib/[Module]/[Function] - Add state at:
[Prefix]/stdlib/[Module]/state - Resolve adjusted path
- Return result or call stub
- Logs unimplemented call
- Records call in
state/results/[Prefix]/undefined-calls - Returns success with errno 0
-module(dev_wasm_import_test).
-include_lib("eunit/include/eunit.hrl").
imported_function_test() ->
application:ensure_all_started(hb),
hb:init(),
Msg0 = dev_wasm:cache_wasm_image("test/pow_calculator.wasm"),
{ok, Msg1} = hb_ao:resolve(Msg0, <<"init">>, #{}),
Msg2 = hb_maps:merge(
Msg1,
hb_ao:set(
#{
<<"function">> => <<"pow">>,
<<"parameters">> => [2, 5]
},
#{
<<"stdlib/my_lib">> => #{ <<"device">> => <<"test-device@1.0">> }
},
#{ hashpath => ignore }
),
#{}
),
{ok, Result} = hb_ao:resolve(Msg2, <<"compute">>, #{}),
?assertEqual({ok, [32]}, hb_ao:resolve(Result, <<"results/output">>, #{})).8. instance/3
-spec instance(M1, M2, Opts) -> Instance | not_found
when
M1 :: map(),
M2 :: map(),
Opts :: map(),
Instance :: pid().Description: Get WASM instance from message. Exported for use by other devices but excluded from AO-Core resolution. Returns PID of WASM executor instance.
Test Code:-module(dev_wasm_instance_test).
-include_lib("eunit/include/eunit.hrl").
instance_after_init_test() ->
application:ensure_all_started(hb),
hb:init(),
Msg0 = dev_wasm:cache_wasm_image("test/test.wasm"),
{ok, Msg1} = hb_ao:resolve(Msg0, <<"init">>, #{}),
Instance = dev_wasm:instance(Msg1, #{}, #{}),
?assert(is_pid(Instance)).
instance_not_found_test() ->
Msg = #{ <<"device">> => <<"wasm-64@1.0">> },
?assertEqual(not_found, dev_wasm:instance(Msg, #{}, #{})).Message Interface
Init Phase
Assumes:M1/[Prefix]/image- WASM binary or IDM1/process- Optional process context
/priv/[Prefix]/instance- WASM executor PID/priv/[Prefix]/import-resolver- Import handler function/priv/[Prefix]/write- String write function/priv/[Prefix]/read- String read function
- Creates WASM executor in HyperBEAM node memory
Compute Phase
Assumes:M1/priv/[Prefix]/instance- Active instanceM1/priv/[Prefix]/import-resolver- Import handlerM2/message/functionORM1/function- Function nameM2/message/parametersORM1/parameters- Function params
/results/[Prefix]/type- Result type/results/[Prefix]/output- Function return values
- Executes WASM function
- May call import functions
Prefix System
The device integrates with dev_stack prefix system:
%% Input prefix: where to read configuration
<<"input-prefix">> => <<"process">>
%% Output prefix: where to write results
<<"output-prefix">> => <<"wasm">>
%% Reads from:
%% process/image
%%
%% Writes to:
%% priv/wasm/instance
%% results/wasm/outputCommon Patterns
%% Basic WASM execution
Msg0 = dev_wasm:cache_wasm_image("program.wasm"),
{ok, Msg1} = hb_ao:resolve(Msg0, <<"init">>, #{}),
Msg2 = Msg1#{
<<"function">> => <<"main">>,
<<"parameters">> => [42]
},
{ok, Result} = hb_ao:resolve(Msg2, <<"compute">>, #{}),
Output = hb_ao:get(<<"results/output">>, Result, #{}).
%% With standard library
Msg = #{
<<"device">> => <<"wasm-64@1.0">>,
<<"image">> => ImageID,
<<"stdlib/math">> => #{ <<"device">> => <<"math@1.0">> },
<<"stdlib/io">> => #{ <<"device">> => <<"wasi@1.0">> }
}.
%% With stack integration
StackMsg = #{
<<"device">> => <<"stack@1.0">>,
<<"device-stack">> => [<<"wasi@1.0">>, <<"wasm-64@1.0">>],
<<"stack-keys">> => [<<"init">>, <<"compute">>],
<<"input-prefix">> => <<"process">>,
<<"output-prefix">> => <<"wasm">>,
<<"process">> => #{ <<"image">> => ImageID }
},
{ok, InitMsg} = hb_ao:resolve(StackMsg, <<"init">>, #{}),
{ok, ComputeMsg} = hb_ao:resolve(InitMsg, <<"compute">>, #{}).
%% State persistence
{ok, Snapshot} = hb_ao:resolve(Msg, <<"snapshot">>, #{}),
% Later restore
RestoredMsg = OriginalMsg#{ <<"snapshot">> => Snapshot },
{ok, Result} = hb_ao:resolve(RestoredMsg, <<"compute">>, #{}).
%% Memory I/O helpers
Instance = dev_wasm:instance(Msg, #{}, #{}),
Writer = hb_private:get(<<"write">>, Msg, #{}),
Reader = hb_private:get(<<"read">>, Msg, #{}),
{ok, Ptr} = Writer(<<"Hello">>),
{ok, Data} = Reader(Ptr).Import Resolution
Default Import Resolver
default_import_resolver(Msg1, Msg2, Opts) ->
% BEAMR provides:
#{
instance := WASM,
module := Module,
func := Func,
args := Args,
func_sig := Signature
} = Msg2,
% Resolve via AO-Core:
Path = <<Prefix/binary, "/stdlib/", Module/binary, "/", Func/binary>>,
{ok, Result} = hb_ao:resolve(Msg1, #{ <<"path">> => Path }, Opts),
% Return state and results:
{ok, Results, NextState}.Undefined Import Stub
Unimplemented imports are logged and recorded:
undefined_import_stub(Msg1, Msg2, Opts) ->
% Log the call
?event({unimplemented_dev_wasm_call, Msg2}),
% Record in undefined-calls
UndefinedCalls = [Msg2 | PreviousCalls],
% Return success
{ok, #{ state => UpdatedMsg, results => [0] }}.State Serialization
Snapshot Format
Snapshots contain complete WASM memory state:
#{
<<"body">> => <<...binary state...>>
}Serialization Process
1. Get WASM instance
2. Call hb_beamr:serialize(Instance)
3. Return binary state in bodyDeserialization Process
1. Extract snapshot body
2. Initialize new WASM instance
3. Call hb_beamr:deserialize(Instance, State)
4. Instance restored to previous stateAOT Mode
Ahead-of-time compilation support (requires configuration):
NodeOpts = #{
wasm_allow_aot => true
}
Msg = #{
<<"device">> => <<"wasm-64@1.0">>,
<<"Mode">> => <<"AOT">>,
<<"image">> => CompiledImageID
}References
- BEAMR -
hb_beamr.erl,hb_beamr_io.erl - Stack Device -
dev_stack.erl - WASI Device -
dev_wasi.erl - AO Resolution -
hb_ao.erl - WAMR - WebAssembly Micro Runtime
Notes
- Memory-64 Support: Full Memory-64 preview standard support
- Instance Lifecycle: Init → Compute → Snapshot/Terminate
- State Portability: Snapshots fully portable across executions
- Import System: Dynamic import resolution via AO-Core
- Prefix Aware: Full integration with dev_stack prefix system
- HashPath Preservation: Normalization doesn't change HashPath
- Pass Handling: Only executes on first pass
- AOT Optional: AOT mode requires explicit enablement
- Stub Logging: Undefined imports logged for debugging
- Cache Integration: Images cached by ID for reuse