Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 36 additions & 11 deletions .github/workflows/wasm-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ jobs:
cd build
cmake .. -G Ninja -DAVM_WARNINGS_ARE_ERRORS=ON
# test_eavmlib does not work with wasm due to http + ssl test
ninja AtomVM atomvmlib atomvmlib-emscripten erlang_test_modules test_etest test_alisp test_estdlib hello_world run_script call_cast html5_events wasm_webserver
ninja AtomVM atomvmlib atomvmlib-emscripten erlang_test_modules test_etest test_alisp test_estdlib hello_world run_script call_cast html5_events tracked_values wasm_webserver

- name: "Perform CodeQL Analysis"
uses: github/codeql-action/analyze@v4
Expand Down Expand Up @@ -244,8 +244,13 @@ jobs:
strategy:
fail-fast: false
matrix:
jit: ["", "-DAVM_DISABLE_JIT=OFF"]
language: ["javascript-typescript"]
include:
- jit: ""
flavor: ""
language: "javascript-typescript"
- jit: "-DAVM_DISABLE_JIT=OFF"
flavor: "-jit"
language: "javascript-typescript"

steps:
- name: Checkout repo
Expand Down Expand Up @@ -277,18 +282,29 @@ jobs:
uses: github/codeql-action/analyze@v3

- name: Upload wasm build for web
if: matrix.jit == ''
uses: actions/upload-artifact@v4
with:
name: atomvm-js-web
name: atomvm-js-web${{ matrix.flavor }}
path: |
src/platforms/emscripten/build/**/*.wasm
src/platforms/emscripten/build/**/*.mjs
retention-days: 1

wasm_test_web:
needs: [compile_tests, wasm_build_web]
needs: [compile_tests, compile_tests_jit, wasm_build_web]
runs-on: ubuntu-24.04

strategy:
fail-fast: false
matrix:
include:
- flavor: ""
# a JIT build has no interpreter, so it can only run the specs
# whose pages load the bundle carrying the wasm32 backend
cypress_args: ""
- flavor: "-jit"
cypress_args: "--env JIT=1 --spec cypress/e2e/run_script_tracked.spec.cy.js,cypress/e2e/tracked_hook_overrides.spec.cy.js"

steps:
- name: Checkout repo
uses: actions/checkout@v4
Expand All @@ -299,10 +315,19 @@ jobs:
name: atomvm-and-test-modules
path: build

# only source of atomvmlib-emscripten-wasm32.avm; unpacked over the non-JIT
# set, which carries the host AtomVM and wasm_webserver.avm
- name: Download AtomVM and test modules (JIT)
if: matrix.flavor != ''
uses: actions/download-artifact@v4
with:
name: atomvm-and-test-modules-jit
path: build

- name: Download wasm build for web
uses: actions/download-artifact@v4
with:
name: atomvm-js-web
name: atomvm-js-web${{ matrix.flavor }}
path: src/platforms/emscripten/build

- name: Download emscripten test modules
Expand All @@ -324,20 +349,20 @@ jobs:
chmod +x ./src/AtomVM
./src/AtomVM examples/emscripten/wasm_webserver.avm &
cd ../src/platforms/emscripten/tests/
docker run --network host -v $PWD:/mnt -w /mnt -e CYPRESS_CI=true cypress/included:12.17.1 --browser chrome
docker run --network host -v $PWD:/mnt -w /mnt -e CYPRESS_CI=true cypress/included:12.17.1 --browser chrome ${{ matrix.cypress_args }}
killall AtomVM

- name: "Publish screenshots of failures"
if: failure()
uses: actions/upload-artifact@v4
with:
name: cypress-screenshots
name: cypress-screenshots${{ matrix.flavor }}
path: |
src/platforms/emscripten/tests/cypress/screenshots/**/*.png
retention-days: 7

- name: "Rename and write sha256sum (web)"
if: startsWith(github.ref, 'refs/tags/')
if: startsWith(github.ref, 'refs/tags/') && matrix.flavor == ''
shell: bash
working-directory: src/platforms/emscripten/build/src
run: |
Expand All @@ -350,7 +375,7 @@ jobs:

- name: "Release (web)"
uses: softprops/action-gh-release@v3.0.0
if: startsWith(github.ref, 'refs/tags/')
if: startsWith(github.ref, 'refs/tags/') && matrix.flavor == ''
with:
draft: true
fail_on_unmatched_files: true
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support for `process_info/1` and `process_info/2` with list argument
- Added `erlang:term_to_binary/2`, `erlang:is_builtin/3` and `erlang:bitstring_to_list/1`
- Added `lists:mapfoldr/3`
- Added `emscripten:run_script_tracked/1` and `emscripten:get_tracked/2` to hold handles to
JavaScript values from Erlang, tying the JavaScript value lifetime to the Erlang term lifetime.
The emscripten module object gained `trackedObjectsMap`, `nextTrackedObjectKey()` and the
`onRunTrackedJs`, `onGetTrackedObjects` and `onTrackedObjectDelete` hooks, which embedders may
override to customize what tracking means

### Changed
- `erlang:process_info/2` now accepts only pids of local processes, as Erlang/OTP does:
Expand Down
1 change: 1 addition & 0 deletions doc/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ set(ERLANG_LIBS
eavmlib
alisp
etest
avm_emscripten
)

foreach(ERLANG_LIB ${ERLANG_LIBS})
Expand Down
10 changes: 10 additions & 0 deletions doc/src/api-reference-documentation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ etest

apidocs/erlang/etest/*

---------------------
avm_emscripten
---------------------

.. toctree::
:maxdepth: 1
:glob:

apidocs/erlang/avm_emscripten/*

=========================================
AtomVM 'C' APIs
=========================================
Expand Down
75 changes: 73 additions & 2 deletions doc/src/atomvm-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ The Web environment build of this port is slightly more complex.

Regarding files, `main` function can load modules (beam or AVM packages) using FetchAPI, which means they can be served by the same HTTP server. This is a fallback and users can preload files using Emscripten `file_packager` tool.

The port also uses Emscripten's proxy-to-pthread feature which means AtomVM's `main` function is run in a web worker. The rationale is the browser thread (or main thread) with WebAssembly cannot run a loop such as AtomVM's schedulers. Web workers typically cannot manipulate the DOM and do other things that only the browser's main thread can do. For this purpose, Erlang processes can call [`emscripten:run_script/2`](./apidocs/erlang/eavmlib/emscripten.md#run_script2) function which dispatches the Javascript to execute to the main thread, waiting for completion (with `[main_thread]`) or not waiting for completion (with `[main_thread, async]`). Waiting for completion of a script on the main thread does not block the Erlang scheduler, other Erlang processes can be scheduled. Execution of Javascript on the worker thread, however, does block the scheduler.
The port also uses Emscripten's proxy-to-pthread feature which means AtomVM's `main` function is run in a web worker. The rationale is the browser thread (or main thread) with WebAssembly cannot run a loop such as AtomVM's schedulers. Web workers typically cannot manipulate the DOM and do other things that only the browser's main thread can do. For this purpose, Erlang processes can call [`emscripten:run_script/2`](./apidocs/erlang/avm_emscripten/emscripten.md#run_script2) function which dispatches the Javascript to execute to the main thread, waiting for completion (with `[main_thread]`) or not waiting for completion (with `[main_thread, async]`). Waiting for completion of a script on the main thread does not block the Erlang scheduler, other Erlang processes can be scheduled. Execution of Javascript on the worker thread, however, does block the scheduler.

Javascript code can also send messages to Erlang processes using `call` and `cast` functions from `main.c`. These functions are actually wrapped in `atomvm.pre.js`. Usage is demonstrated by `call_cast.html` example.

Expand All @@ -457,10 +457,81 @@ Call allows Javascript code to wait for the result and is based on Javascript pr
1. C code returns the handle of the promise (actually the index in the map) to Javascript Module.call wrapper.
1. The `Module.call` wrapper converts the handle into a Promise object and returns it, so Javascript code can await on the promise.
1. A scheduler dequeues the message with the resource, looks up the target process and sends it the resource as a term
1. The target process eventually calls [`emscripten:promise_resolve/1,2`](./apidocs/erlang/eavmlib/emscripten.md#promise_resolve2) or [`emscripten:promise_reject/1,2`](./apidocs/erlang/eavmlib/emscripten.md#promise_reject2) to resolve or reject the promise.
1. The target process eventually calls [`emscripten:promise_resolve/1,2`](./apidocs/erlang/avm_emscripten/emscripten.md#promise_resolve2) or [`emscripten:promise_reject/1,2`](./apidocs/erlang/avm_emscripten/emscripten.md#promise_reject2) to resolve or reject the promise.
1. The `emscripten:promise_resolve/1,2` and `emscripten:promise_reject/1,2` nifs dispatch a message in the browser's main thread.
1. The dispatched function retrieves the promise from its index, resolves or rejects it, with the value passed to `emscripten:promise_resolve/2` or `emscripten:promise_reject/2` and destroys it.

Values currently can only be integers or strings.

If the scheduler cannot find the target process, the promise is rejected with "noproc" as a value. As the promise is encapsulated into an Erlang resource, if the resource object's reference count reaches 0, the promise is rejected with "noproc" as the value.

### Tracked JavaScript values

Erlang processes can hold handles to arbitrary JavaScript values using
[`emscripten:run_script_tracked/1`](./apidocs/erlang/avm_emscripten/emscripten.md#run_script_tracked1).
Like `emscripten:run_script/2` with `[main_thread]`, the script is executed on the browser's main
thread and the caller waits for completion, but the script is expected to evaluate to a JavaScript
array, and each element of the array is retained: the default hooks store every element in the
`Module.trackedObjectsMap` map under a fresh integer key, and the caller gets
`{ok, TrackedObjects}` with one opaque handle (an Erlang resource) per element, in the same order.
A script evaluating to `null` or `undefined` yields `{ok, []}`; a script that throws or evaluates
to anything else yields `{error, badarg}`.

The retained JavaScript value lives as long as the Erlang handle: when a handle is garbage
collected by the VM, the resource destructor dispatches a call to
`Module.onTrackedObjectDelete(key)` on the main thread, and the default hook removes the entry
from `Module.trackedObjectsMap`. This makes it possible to tie non-serializable JavaScript
values, such as DOM nodes or callback functions, to the lifetime of Erlang terms.

Returning a key hands it over to the VM until `Module.onTrackedObjectDelete` is called for it,
so a hook must return each key once and must not return a key that a live handle still owns;
the VM rejects a key repeated within one result, but cannot detect reuse across calls.

Whenever a call fails after the hook returned keys, whether it ran out of memory before every
key got a handle or the keys themselves violated the contract, every returned key is deleted
again through the same hook, each distinct key once, so a failed call leaves nothing tracked
behind. Values that a hook tracks under keys it does not return are invisible to the VM and
stay its own responsibility.

[`emscripten:get_tracked/2`](./apidocs/erlang/avm_emscripten/emscripten.md#get_tracked2) maps a
list of handles back to JavaScript: with `key`, it returns the integer keys
identifying the entries of `Module.trackedObjectsMap`, without involving the main thread; with
`value`, it fetches the current values on the main thread, waiting for completion. Fetched
values must be JavaScript strings: each element of the result is `{ok, Binary}` with the UTF-8
bytes of the string, `{error, badvalue}` if the stored value is not a string, or
`{error, badkey}` if the key is no longer in the map (with the default hooks a stored
`undefined` value is indistinguishable from a missing key). Non-string values are typically
serialized to JSON, either by the tracked script itself or by an overridden hook. An empty
list of handles yields an empty list of results, so the `{ok, []}` of a script that tracked
nothing can be passed on unguarded.

The JavaScript side is implemented by the following members of the emscripten module object,
defined in `atomvm.pre.js`, which embedders may override to customize behavior without patching
AtomVM:

| Member | Default behavior | Contract for overrides |
|--------|------------------|------------------------|
| `Module.trackedObjectsMap` | a `Map` from integer keys to values | storage used by the default hooks |
| `Module.nextTrackedObjectKey()` | returns a fresh key from a VM-wide counter | must return an unused key in the range 0 to 4294967294; 4294967295 is reserved and means that the key space is exhausted |
| `Module.onRunTrackedJs(script, isDebug)` | evaluates `script` with an indirect `eval` and tracks each element of the resulting array | must return an array of keys in the range above, each of them unique within the array and not owned by a live handle, or `null` on error; must not throw (a throw, a repeated key, or a key outside the range is treated as an evaluation error) |
| `Module.onGetTrackedObjects(keys)` | returns `keys.map((k) => Module.trackedObjectsMap.get(k))` | must return exactly one entry per key: a string, or `undefined` for an unknown key (any other value maps to `{error, badvalue}`); must not throw (a throw or a wrong-length result makes every element of the result `{error, badvalue}`) |
| `Module.onTrackedObjectDelete(key)` | deletes the key from `Module.trackedObjectsMap` | called on the main thread when a handle is garbage collected; must not throw (a throw is logged and swallowed) |

Hook overrides must be assigned on the module instance returned by the factory
(`const module = await AtomVM(...); module.onRunTrackedJs = ...;`): `atomvm.pre.js` assigns the
defaults unconditionally when the factory runs, so hooks passed in the factory configuration
object are overwritten. They must also be assigned **synchronously** once the promise resolves,
with nothing awaited in between: `main` already runs on its worker by then, and every `await`
gives the browser main thread a chance to run a tracked call it has already dispatched, which
would still find the defaults installed. `Module.nextTrackedObjectKey()` and the underlying
`_next_tracked_object_key` C export read VM state; before AtomVM's `main` has started they
report the exhausted key rather than a usable one (the pre-existing `_cast` and `_call` exports
have no such guard and must not be called that early).

A script is a sequence of bytes rather than text, so it has to be UTF-8 encoded and free of NUL
bytes: a binary reaches JavaScript unchanged and is cut at its first NUL, while a character list
is written one byte per element, so an element above 255 raises `badarg` and one between 128 and
255 becomes a single byte that decodes as U+FFFD.

`isDebug` is true in builds without `NDEBUG`; the default hooks only use it to log diagnostics
with `console.error`.
1 change: 1 addition & 0 deletions examples/emscripten/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ include(BuildErlang)
pack_runnable(run_script run_script estdlib eavmlib avm_emscripten)
pack_runnable(call_cast call_cast eavmlib avm_emscripten)
pack_runnable(html5_events html5_events estdlib eavmlib avm_emscripten)
pack_runnable(tracked_values tracked_values estdlib eavmlib avm_emscripten)
pack_runnable(echo_websocket echo_websocket estdlib eavmlib avm_emscripten)
pack_runnable(wasm_webserver wasm_webserver estdlib eavmlib avm_network avm_emscripten)
1 change: 1 addition & 0 deletions examples/emscripten/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ <h1>AtomVM emscripten examples</h1>
<li><a href="run_script.html">Run script</a></li>
<li><a href="call_cast.html">Call &amp; cast</a></li>
<li><a href="html5_events.html">HTML5 Events</a></li>
<li><a href="tracked_values.html">Tracked values</a></li>
<li><a href="echo_websocket.html">Echo websocket</a></li>
</ul>
</body>
Expand Down
91 changes: 91 additions & 0 deletions examples/emscripten/tracked_values.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
%
% This file is part of AtomVM.
%
% Copyright 2026 Davide Bettio <davide@uninstall.it>
%
% Licensed under the Apache License, Version 2.0 (the "License");
% you may not use this file except in compliance with the License.
% You may obtain a copy of the License at
%
% http://www.apache.org/licenses/LICENSE-2.0
%
% Unless required by applicable law or agreed to in writing, software
% distributed under the License is distributed on an "AS IS" BASIS,
% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
% See the License for the specific language governing permissions and
% limitations under the License.
%
% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later
%

-module(tracked_values).
-export([start/0]).

start() ->
% A DOM node cannot be sent to Erlang, but a handle to it can: the script
% evaluates to an array, so this tracks one value and gets one handle.
{ok, [Box]} = emscripten:run_script_tracked(
<<"[window.document.getElementById('demo-box')]">>
),

% The handle addresses the node in later scripts through the key it
% carries, without ever serializing the node itself.
ok = paint(Box, <<"lightgreen">>),
ok = show(<<"tracked the box and painted it green">>),

% Values come back as strings, so read a property rather than the node.
% Trimmed, because it goes back into a script as a string literal.
{ok, [Text]} = emscripten:run_script_tracked(
<<"[window.document.getElementById('demo-box').textContent.trim()]">>
),
[{ok, Content}] = emscripten:get_tracked([Text], value),
ok = show([<<"the box says: ">>, Content]),

% The JavaScript value lives exactly as long as the handle: dropping this
% one and collecting leaves nothing behind on the JavaScript side.
ok = drop_a_handle(),
erlang:garbage_collect(),
ok = show(<<"a dropped handle took its JavaScript value with it">>),

% Box is still reachable here, so its node is still tracked.
ok = paint(Box, <<"lightblue">>),
ok = show(<<"the box is still tracked, and now blue">>),
loop(Box).

paint(Handle, Color) ->
[Key] = emscripten:get_tracked([Handle], key),
ok = emscripten:run_script(
[
<<"window.Module.trackedObjectsMap.get(">>,
integer_to_list(Key),
<<").style.backgroundColor = '">>,
Color,
<<"';">>
],
[main_thread]
),
ok.

% The handle must go out of scope before the collection, so it is made in a
% frame of its own and dropped on return.
drop_a_handle() ->
{ok, [_]} = emscripten:run_script_tracked(<<"['collected soon']">>),
ok.

show(Message) ->
ok = emscripten:run_script(
[
<<"window.document.getElementById('demo-log').innerHTML += '<li>">>,
Message,
<<"</li>';">>
],
[main_thread]
),
ok.

% Returning from start/0 would tear the runtime down, and with it every
% tracked value.
loop(Handle) ->
receive
_Any -> loop(Handle)
end.
Loading
Loading