diff --git a/.github/workflows/wasm-build.yaml b/.github/workflows/wasm-build.yaml index c5b75101a8..c300eb9584 100644 --- a/.github/workflows/wasm-build.yaml +++ b/.github/workflows/wasm-build.yaml @@ -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 @@ -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 @@ -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 @@ -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 @@ -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: | @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9292432487..d7d1ef0f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/doc/CMakeLists.txt b/doc/CMakeLists.txt index 4407992f19..a599d114ff 100644 --- a/doc/CMakeLists.txt +++ b/doc/CMakeLists.txt @@ -83,6 +83,7 @@ set(ERLANG_LIBS eavmlib alisp etest + avm_emscripten ) foreach(ERLANG_LIB ${ERLANG_LIBS}) diff --git a/doc/src/api-reference-documentation.rst b/doc/src/api-reference-documentation.rst index ada9404024..9a9017cce0 100644 --- a/doc/src/api-reference-documentation.rst +++ b/doc/src/api-reference-documentation.rst @@ -52,6 +52,16 @@ etest apidocs/erlang/etest/* +--------------------- +avm_emscripten +--------------------- + +.. toctree:: + :maxdepth: 1 + :glob: + + apidocs/erlang/avm_emscripten/* + ========================================= AtomVM 'C' APIs ========================================= diff --git a/doc/src/atomvm-internals.md b/doc/src/atomvm-internals.md index fefaeccb76..092e6b08ea 100644 --- a/doc/src/atomvm-internals.md +++ b/doc/src/atomvm-internals.md @@ -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. @@ -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`. diff --git a/examples/emscripten/CMakeLists.txt b/examples/emscripten/CMakeLists.txt index 349702471a..59cfbd743e 100644 --- a/examples/emscripten/CMakeLists.txt +++ b/examples/emscripten/CMakeLists.txt @@ -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) diff --git a/examples/emscripten/index.html b/examples/emscripten/index.html index 564aeb3e18..9b801db2cb 100644 --- a/examples/emscripten/index.html +++ b/examples/emscripten/index.html @@ -36,6 +36,7 @@