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 @@

AtomVM emscripten examples

  • Run script
  • Call & cast
  • HTML5 Events
  • +
  • Tracked values
  • Echo websocket
  • diff --git a/examples/emscripten/tracked_values.erl b/examples/emscripten/tracked_values.erl new file mode 100644 index 0000000000..5b4936893a --- /dev/null +++ b/examples/emscripten/tracked_values.erl @@ -0,0 +1,91 @@ +% +% This file is part of AtomVM. +% +% Copyright 2026 Davide Bettio +% +% 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 += '
  • ">>, + Message, + <<"
  • ';">> + ], + [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. diff --git a/examples/emscripten/tracked_values.html b/examples/emscripten/tracked_values.html new file mode 100644 index 0000000000..2ff1191eed --- /dev/null +++ b/examples/emscripten/tracked_values.html @@ -0,0 +1,57 @@ + + + + + + AtomVM tracked values example + + +

    Tracked values example

    +

    + This example demonstrates + emscripten:run_script_tracked/1 and + emscripten:get_tracked/2, which let Erlang hold handles to + JavaScript values that cannot be serialized, such as the DOM node below. +

    + +

    + The JavaScript value stays alive exactly as long as the Erlang handle + does: when the handle is garbage collected, AtomVM drops the value from + Module.trackedObjectsMap. +

    + +
    + A box, tracked from Erlang +
    + + + + + + diff --git a/libs/avm_emscripten/src/emscripten.erl b/libs/avm_emscripten/src/emscripten.erl index f0dfadab3c..5283ae836e 100644 --- a/libs/avm_emscripten/src/emscripten.erl +++ b/libs/avm_emscripten/src/emscripten.erl @@ -43,12 +43,20 @@ %% Promise should be passed to `promise_resolve/1,2' or `promise_reject/1,2' as %% documented below. %% +%% JavaScript values can also be tracked: `run_script_tracked/1' evaluates a +%% script on the main thread and returns opaque handles to the values it +%% evaluates to, keeping those JavaScript values alive until the handles are +%% garbage collected by the Erlang VM. `get_tracked/2' maps handles back to +%% their keys or current values. +%% %% @end %%----------------------------------------------------------------------------- -module(emscripten). -export([ run_script/1, run_script/2, + run_script_tracked/1, + get_tracked/2, promise_resolve/1, promise_resolve/2, promise_reject/1, @@ -157,6 +165,8 @@ promise/0, html5_target/0, listener_handle/0, + tracked_object/0, + get_tracked_result/0, keyboard_event/0, mouse_event/0, wheel_event/0, @@ -170,6 +180,8 @@ -opaque promise() :: binary(). -type html5_target() :: window | document | screen | iodata(). -opaque listener_handle() :: binary(). +-opaque tracked_object() :: reference(). +-type get_tracked_result() :: {ok, binary()} | {error, badkey} | {error, badvalue}. -type register_option() :: {use_capture, boolean()} | {prevent_default, boolean()}. -type register_options() :: boolean() | [register_option()]. -type register_error_reason() :: @@ -302,6 +314,105 @@ run_script(_Script) -> run_script(_Script, _Options) -> erlang:nif_error(undefined). +%% @doc Run a script and track the JavaScript values it evaluates to. +%% +%% The script is always run on the main thread and the caller waits for +%% completion. +%% +%% The script is evaluated by the `Module.onRunTrackedJs' JavaScript hook. +%% The default hook evaluates the script with an indirect `eval' and expects +%% it to evaluate to a JavaScript array. Each element of the array is stored +%% in `Module.trackedObjectsMap' under a fresh integer key, and the function +%% returns `{ok, TrackedObjects}' with one opaque handle per element, in the +%% same order. +%% +%% The JavaScript values are kept alive as long as the returned handles are +%% alive on the Erlang side: when a handle is garbage collected, the +%% `Module.onTrackedObjectDelete' hook is eventually called with the +%% corresponding key on the main thread, and the default hook then drops the +%% value from `Module.trackedObjectsMap'. This ties the lifetime of otherwise +%% non-serializable JavaScript values (such as DOM nodes) to Erlang terms. +%% +%% With the default hook, a script that evaluates to `null' or `undefined' +%% yields `{ok, []}'; a script that throws or evaluates to anything else +%% yields `{error, badarg}'. +%% +%% The script is a sequence of bytes, not text: it must be UTF-8 encoded +%% and free of NUL bytes. A binary is passed through unchanged, so a NUL in +%% it truncates the script there, while a character list is written one +%% byte per element, so an element above 255 raises `badarg' and one in the +%% 128 to 255 range becomes a single byte that JavaScript decodes as U+FFFD +%% rather than the character it stood for. +%% +%% Embedders may override the hooks on the resolved emscripten module +%% instance, after the module factory promise resolves (hooks passed in the +%% factory configuration are overwritten by the defaults). The overrides +%% must be assigned synchronously once the promise resolves: awaiting +%% anything in between lets the browser run a tracked call the VM already +%% dispatched, which would still reach the defaults. An overridden +%% `Module.onRunTrackedJs' must return an array of unsigned 32 bit integer +%% keys (0 to 4294967294, the last value of the range being reserved), or +%% `null' on error, and must not throw; a throw, a key outside that range, +%% or a key repeated within the array, is treated as an evaluation error +%% and yields `{error, badarg}'. Returning a key hands it over to the VM +%% until `Module.onTrackedObjectDelete' is called for it, so a key that a +%% live handle still owns must not be returned again: the VM cannot detect +%% that across calls, and the two handles would then share one value, the +%% first of them collected deleting it for both. +%% +%% Raises `badarg' if `_Script' is not iodata, and `out_of_memory' if the +%% VM runs out of memory while tracking the values or building the result. +%% Whenever the call fails after the hook returned keys, the values it +%% tracked under them are dropped again, as if their handles had been +%% garbage collected. +%% @param _Script Script to run, as iodata +%% @returns `{ok, TrackedObjects}' or `{error, badarg}' +-spec run_script_tracked(_Script :: iodata()) -> {ok, [tracked_object()]} | {error, badarg}. +run_script_tracked(_Script) -> + erlang:nif_error(undefined). + +%% @doc Get the keys or the current values of tracked objects. +%% +%% With `key', returns the integer key of each handle, in the same order as +%% the input list. Keys identify entries of `Module.trackedObjectsMap' and +%% are only meaningful inside the current VM instance. This call does not +%% involve the main thread. +%% +%% With `value', fetches the current JavaScript value of each handle by +%% calling the `Module.onGetTrackedObjects' hook on the main thread; the +%% caller waits for completion. Values must be JavaScript strings: the +%% default hook returns the stored values unchanged, so only string values +%% can be fetched. Embedders typically serialize values to JSON, either in +%% the tracked script itself or in an overridden hook. 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 not (or no longer) in the map (with the +%% default hooks a stored `undefined' value is indistinguishable from a +%% missing key). Results are in the same order as the input list. +%% +%% An empty list yields an empty list, on either type, so the `{ok, []}' of +%% a `run_script_tracked/1' call that tracked nothing can be passed on +%% unguarded. With `value' this answers without reaching the main thread. +%% +%% Raises `badarg' if the first argument is not a proper list of tracked +%% objects, or if the second argument is neither `key' nor `value', and +%% `out_of_memory' if the VM runs out of memory while building the result, +%% on either side of the JavaScript boundary. +%% +%% With `value', a `Module.onGetTrackedObjects' hook that violates its +%% contract (throws, or does not return one entry per requested key) tells +%% the VM nothing about any single key, so every element of the result is +%% `{error, badvalue}'. +%% @param _TrackedObjects Proper list of tracked object handles +%% @param _Type `key' or `value' +%% @returns A list of integer keys, or a list of `{ok, Value}' / +%% `{error, badkey}' / `{error, badvalue}' tuples +-spec get_tracked + (_TrackedObjects :: [tracked_object()], key) -> [non_neg_integer()]; + (_TrackedObjects :: [tracked_object()], value) -> [get_tracked_result()]. +get_tracked(_TrackedObjects, _Type) -> + erlang:nif_error(undefined). + %% @equiv promise_resolve(_Promise, 0) -spec promise_resolve(promise()) -> ok. promise_resolve(_Promise) -> diff --git a/src/platforms/emscripten/src/CMakeLists.txt b/src/platforms/emscripten/src/CMakeLists.txt index a57e0b3f52..34e85d950b 100644 --- a/src/platforms/emscripten/src/CMakeLists.txt +++ b/src/platforms/emscripten/src/CMakeLists.txt @@ -41,7 +41,7 @@ if (NOT AVM_DISABLE_JIT) else() set(JIT_LINK_FLAGS "-sEXPORTED_RUNTIME_METHODS=['ccall','ENV']") endif() -target_link_options(AtomVM PRIVATE ${JIT_LINK_FLAGS} -sEXPORT_ES6 -sUSE_ZLIB=1 -O3 -pthread -sFETCH -lwebsocket.js --pre-js ${CMAKE_CURRENT_SOURCE_DIR}/atomvm.pre.js --extern-post-js ${CMAKE_CURRENT_SOURCE_DIR}/atomvm.extern-post.js -sINITIAL_MEMORY=67108864 -sALLOW_MEMORY_GROWTH) +target_link_options(AtomVM PRIVATE ${JIT_LINK_FLAGS} -sEXPORTED_FUNCTIONS=_malloc,_free,_cast,_call,_next_tracked_object_key,_main -sEXPORT_ES6 -sUSE_ZLIB=1 -O3 -pthread -sFETCH -lwebsocket.js --pre-js ${CMAKE_CURRENT_SOURCE_DIR}/atomvm.pre.js --extern-post-js ${CMAKE_CURRENT_SOURCE_DIR}/atomvm.extern-post.js -sINITIAL_MEMORY=67108864 -sALLOW_MEMORY_GROWTH) if (CMAKE_BUILD_TYPE STREQUAL "Debug") target_link_options(AtomVM PRIVATE -sASSERTIONS=2 -sSAFE_HEAP -sSTACK_OVERFLOW_CHECK) diff --git a/src/platforms/emscripten/src/atomvm.pre.js b/src/platforms/emscripten/src/atomvm.pre.js index 73ac9f647a..6e6144c609 100644 --- a/src/platforms/emscripten/src/atomvm.pre.js +++ b/src/platforms/emscripten/src/atomvm.pre.js @@ -17,10 +17,79 @@ * * SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later */ -Module['cast'] = function(name, message) { - ccall("cast", 'void', ['string', 'string'], [name, message]); +Module["cast"] = function (name, message) { + ccall("cast", "void", ["string", "string"], [name, message]); }; -Module['call'] = async function(name, message) { - const promiseId = ccall("call", 'integer', ['string', 'string'], [name, message]); - return promiseMap.get(promiseId).promise; +Module["call"] = async function (name, message) { + const promiseId = ccall( + "call", + "integer", + ["string", "string"], + [name, message], + ); + return promiseMap.get(promiseId).promise; +}; +Module["nextTrackedObjectKey"] = function () { + // the raw i32 from ccall is signed; keys are unsigned + return ccall("next_tracked_object_key", "integer", [], []) >>> 0; +}; +Module["trackedObjectsMap"] = new Map(); +Module["onTrackedObjectDelete"] = (key) => { + Module["trackedObjectsMap"].delete(key); +}; +Module["onGetTrackedObjects"] = (keys) => { + const getTrackedObject = (key) => Module["trackedObjectsMap"].get(key); + return keys.map(getTrackedObject); +}; +// mirrors TRACKED_OBJECT_KEY_EXHAUSTED in emscripten_sys.h +const trackedObjectKeyExhausted = 4294967295; +Module["onRunTrackedJs"] = (scriptString, isDebug) => { + const trackedKeys = []; + const trackValue = (value) => { + const key = Module["nextTrackedObjectKey"](); + if (key === trackedObjectKeyExhausted) { + throw new Error("tracked object key space is exhausted"); + } + Module["trackedObjectsMap"].set(key, value); + trackedKeys[trackedKeys.length] = key; + return key; + }; + + let result; + try { + const indirectEval = eval; + result = indirectEval(scriptString); + } catch (e) { + isDebug && console.error("onRunTrackedJs: evaluated script threw", e); + return null; + } + if (result === null || result === undefined) { + return []; + } + if (!Array.isArray(result)) { + isDebug && + console.error( + "onRunTrackedJs: script must evaluate to an array, null or undefined; got", + result, + ); + return null; + } + try { + // Array.from maps holes in sparse arrays; result.map would leave them, + // and a hole reads back as key 0 + return Array.from(result, trackValue); + } catch (e) { + // Reading the array can throw halfway through (an exotic iterator, an + // element getter, or trackValue on an exhausted key space), leaving + // values tracked under keys the caller never gets: give them up. + isDebug && console.error("onRunTrackedJs: tracking the result threw", e); + for (let i = 0; i < trackedKeys.length; ++i) { + try { + Module["onTrackedObjectDelete"](trackedKeys[i]); + } catch (ignored) { + // keep dropping the remaining keys + } + } + return null; + } }; diff --git a/src/platforms/emscripten/src/lib/emscripten_sys.h b/src/platforms/emscripten/src/lib/emscripten_sys.h index 1889b2ab97..51fb10f35b 100644 --- a/src/platforms/emscripten/src/lib/emscripten_sys.h +++ b/src/platforms/emscripten/src/lib/emscripten_sys.h @@ -22,6 +22,8 @@ #define _EMSCRIPTEN_SYS_H_ #include +#include +#include #include #include @@ -69,6 +71,7 @@ enum EmscriptenMessageType Call, HTMLEvent, UnregisterHTMLEvent, + TrackedAnswer, Signal }; @@ -108,14 +111,35 @@ struct EmscriptenMessageUnregisterHTMLEvent struct HTMLEventUserDataResource *rsrc; }; +struct EmscriptenMessageTrackedAnswer +{ + struct EmscriptenMessageBase base; + int32_t target_pid; + // an invalid term makes the trapped caller raise out_of_memory instead + term answer; + // NULL when the answer needed none + HeapFragment *answer_heap; +}; + +// Reserved key: sys_get_next_tracked_object_key returns it once every other +// key has been handed out, and it never identifies a tracked object. +#define TRACKED_OBJECT_KEY_EXHAUSTED UINT32_MAX + +struct TrackedObjectResource +{ + uint32_t key; +}; + struct EmscriptenPlatformData { pthread_mutex_t poll_mutex; pthread_cond_t poll_cond; struct ListHead messages; + _Atomic uint32_t next_tracked_object_key; ErlNifResourceType *promise_resource_type; ErlNifResourceType *htmlevent_user_data_resource_type; ErlNifResourceType *websocket_resource_type; + ErlNifResourceType *tracked_object_resource_type; #ifndef AVM_NO_SMP Mutex *entropy_mutex; @@ -134,6 +158,9 @@ void sys_enqueue_emscripten_cast_message(GlobalContext *glb, const char *target, em_promise_t sys_enqueue_emscripten_call_message(GlobalContext *glb, const char *target, const char *message); void sys_enqueue_emscripten_htmlevent_message(GlobalContext *glb, int32_t target_pid, term message, term user_data, HeapFragment *heap); void sys_enqueue_emscripten_unregister_htmlevent_message(GlobalContext *glb, struct HTMLEventUserDataResource *rsrc); +void sys_enqueue_emscripten_tracked_answer_message(GlobalContext *glb, int32_t target_pid, term answer, HeapFragment *heap); +uint32_t sys_get_next_tracked_object_key(GlobalContext *glb); +void sys_remove_tracked_object(uint32_t key); void sys_promise_resolve_int_and_destroy(em_promise_t promise, em_promise_result_t result, int value); void sys_promise_resolve_str_and_destroy(em_promise_t promise, em_promise_result_t result, int value); diff --git a/src/platforms/emscripten/src/lib/platform_defaultatoms.def b/src/platforms/emscripten/src/lib/platform_defaultatoms.def index 5ffa6a5bcb..9c4c0cb904 100644 --- a/src/platforms/emscripten/src/lib/platform_defaultatoms.def +++ b/src/platforms/emscripten/src/lib/platform_defaultatoms.def @@ -24,3 +24,4 @@ X(WEBSOCKET_ATOM, "\x9", "websocket") X(WEBSOCKET_OPEN_ATOM, "\xE", "websocket_open") X(WEBSOCKET_CLOSE_ATOM, "\xF", "websocket_close") X(WEBSOCKET_ERROR_ATOM, "\xF", "websocket_error") +X(BADVALUE_ATOM, "\x8", "badvalue") diff --git a/src/platforms/emscripten/src/lib/platform_nifs.c b/src/platforms/emscripten/src/lib/platform_nifs.c index 803c8e5138..2fec61f571 100644 --- a/src/platforms/emscripten/src/lib/platform_nifs.c +++ b/src/platforms/emscripten/src/lib/platform_nifs.c @@ -18,6 +18,7 @@ * SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later */ +#include #include #include #include @@ -26,6 +27,7 @@ #include #include #include +#include #include #include @@ -160,6 +162,531 @@ static term nif_emscripten_promise_reject(Context *ctx, int argc, term argv[]) return nif_emscripten_promise_resolve_reject(ctx, argc, argv, EM_PROMISE_REJECT); } +static term term_tracked_object_from_key(GlobalContext *global, uint32_t key, Heap *heap) +{ + struct EmscriptenPlatformData *platform = global->platform_data; + struct TrackedObjectResource *rsrc_obj = enif_alloc_resource(platform->tracked_object_resource_type, sizeof(struct TrackedObjectResource)); + if (IS_NULL_PTR(rsrc_obj)) { + return term_invalid_term(); + } + rsrc_obj->key = key; + term obj = term_from_resource(rsrc_obj, heap); + enif_release_resource(rsrc_obj); + return obj; +} + +// The tracked calls below run on the browser main thread, which is not a +// scheduler thread: the web flavor proxies main() to a worker. Posting the +// answer signal and destroying the heap it was built in are both scheduler +// only, so they are handed over to sys_poll_events instead. +// +// The answer is built in a private heap rather than in the caller's: the +// process table read lock keeps the caller from being destroyed, but not from +// being scheduled and mutating its own heap concurrently. + +static void send_tracked_trap_oom(GlobalContext *global, int32_t process_id) +{ + sys_enqueue_emscripten_tracked_answer_message(global, process_id, term_invalid_term(), NULL); +} + +// Outcome of a call dispatched to the main thread, telling a JavaScript side +// failure apart from an allocation failure. EM_JS bodies cannot see C +// declarations: the constants mirroring this enum below must be kept in sync. +enum TrackedCallStatus +{ + TRACKED_CALL_OK = 0, + TRACKED_CALL_ERROR = 1, + TRACKED_CALL_OOM = 2 +}; + +// clang-format off +EM_JS(uint32_t *, js_tracked_eval, (const char *code, uint32_t *size, uint8_t *status, bool debug), { + // mirror of enum TrackedCallStatus + const OK = 0; + const ERROR = 1; + const OOM = 2; + + const setOutcome = (outcome, keysN) => { + HEAPU8[status / HEAPU8.BYTES_PER_ELEMENT] = outcome; + HEAPU32[size / HEAPU32.BYTES_PER_ELEMENT] = keysN; + }; + // an evaluated script can replace console: a diagnostic must not decide the outcome + const logError = (message, detail) => { + try { + console.error(message, detail); + } catch (e) { + } + }; + // Drops the values the hook tracked that no resource owns yet. A deletion + // hook is not required to be idempotent, so a repeated key is dropped + // once, the way one resource per key would have done it. + const dropKeys = (keys) => { + for (let i = 0; i < keys.length; ++i) { + const key = keys[i]; + let dropped = false; + for (let j = 0; j < i && !dropped; ++j) { + // SameValueZero, the identity a Map gives its keys + dropped = keys[j] === key || (keys[j] !== keys[j] && key !== key); + } + if (dropped) { + continue; + } + try { + Module['onTrackedObjectDelete'](key); + } catch (e) { + logError("onTrackedObjectDelete threw", e); + } + } + }; + + // The scripts this API evaluates share this realm and may replace any + // global the body reaches, so it is wrapped as a whole: whatever throws, + // an outcome is still set and the caller is never left trapped. _malloc + // and _free are module bindings, not the replaceable Module properties. + let keys = null; + let ptr = 0; + try { + try { + // Array.from copies an exotic array into a plain one, so nothing + // below can run hook code again through an iterator or a getter + const result = Module['onRunTrackedJs'](UTF8ToString(code), debug); + keys = Array.isArray(result) ? Array.from(result) : null; + } catch (e) { + logError("onRunTrackedJs threw", e); + keys = null; + } + if (keys === null) { + setOutcome(ERROR, 0); + return 0; + } + // keys are unsigned 32 bit and their last value is reserved: anything + // else is coerced silently by HEAPU32 (-1 to 4294967295, 2 ** 32 to 0, + // a fraction to its truncation) and aliases an unrelated entry, and a + // repeated key gets one handle each, the first collected deleting the + // value the others still address. Operators only below: a replaced + // intrinsic would break every later call, not just this one. + const isKey = (key) => + typeof key === "number" && key % 1 === 0 && key >= 0 && key <= 0xfffffffe; + let invalid = false; + for (let i = 0; i < keys.length && !invalid; ++i) { + invalid = !isKey(keys[i]); + for (let j = 0; j < i && !invalid; ++j) { + invalid = keys[j] === keys[i]; + } + } + if (invalid) { + logError("onRunTrackedJs returned invalid keys", keys); + // every returned key is an ownership claim, malformed ones + // included: the hook may have stored a value under them too + dropKeys(keys); + setOutcome(ERROR, 0); + return 0; + } + if (keys.length === 0) { + // malloc(0) may return null, which C cannot tell from a failure + setOutcome(OK, 0); + return 0; + } + + ptr = _malloc(keys.length * HEAPU32.BYTES_PER_ELEMENT); + if (ptr === 0) { + dropKeys(keys); + setOutcome(OOM, 0); + return 0; + } + // indexed writes: HEAPU32.set is a replaceable prototype method + const offset = ptr / HEAPU32.BYTES_PER_ELEMENT; + for (let i = 0; i < keys.length; ++i) { + HEAPU32[offset + i] = keys[i]; + } + setOutcome(OK, keys.length); + return ptr; + } catch (e) { + logError("tracked evaluation failed", e); + _free(ptr); + if (keys !== null) { + dropKeys(keys); + } + setOutcome(ERROR, 0); + return 0; + } +}) +// clang-format on + +// Only the keys the hook returned can be dropped: values it tracked under +// keys it kept to itself are not visible from here. +static void delete_unowned_tracked_objects(const uint32_t *keys, size_t keys_n) +{ + for (size_t i = 0; i < keys_n; ++i) { + sys_remove_tracked_object(keys[i]); + } +} + +static void do_run_script_tracked(const char *script, int32_t process_id, GlobalContext *global) +{ +#ifdef NDEBUG + bool debug = false; +#else + bool debug = true; +#endif + uint32_t keys_n = 0; + uint8_t eval_status = TRACKED_CALL_ERROR; + uint32_t *keys = js_tracked_eval(script, &keys_n, &eval_status, debug); + + if (UNLIKELY(eval_status == TRACKED_CALL_OOM)) { + send_tracked_trap_oom(global, process_id); + return; + } + + Heap heap; + if (UNLIKELY(memory_init_heap(&heap, TUPLE_SIZE(2) + LIST_SIZE(keys_n, TERM_BOXED_REFERENCE_RESOURCE_SIZE)) != MEMORY_GC_OK)) { + delete_unowned_tracked_objects(keys, keys_n); + free(keys); + send_tracked_trap_oom(global, process_id); + return; + } + + term result; + if (eval_status == TRACKED_CALL_ERROR) { + // FIXME: badarg conflates JavaScript evaluation errors with argument + // errors, but existing users match {error, badarg}, so changing it is + // an incompatible change. + result = term_alloc_tuple(2, &heap); + term_put_tuple_element(result, 0, ERROR_ATOM); + term_put_tuple_element(result, 1, BADARG_ATOM); + } else { + term refs = term_nil(); + for (long i = keys_n - 1; i >= 0; --i) { + term tracked_object = term_tracked_object_from_key(global, keys[i], &heap); + if (UNLIKELY(term_is_invalid_term(tracked_object))) { + // the keys that already got a resource are dropped with the + // heap, whose destructors delete their JS-side values + delete_unowned_tracked_objects(keys, i + 1); + free(keys); + sys_enqueue_emscripten_tracked_answer_message(global, process_id, term_invalid_term(), heap.root); + return; + } + refs = term_list_prepend(tracked_object, refs, &heap); + } + result = term_alloc_tuple(2, &heap); + term_put_tuple_element(result, 0, OK_ATOM); + term_put_tuple_element(result, 1, refs); + } + free(keys); + + sys_enqueue_emscripten_tracked_answer_message(global, process_id, result, heap.root); +} + +static term nif_emscripten_run_script_tracked(Context *ctx, int argc, term argv[]) +{ + UNUSED(argc); + term script_term = argv[0]; + + int ok; + char *script = interop_term_to_string(script_term, &ok); + if (UNLIKELY(!ok)) { + RAISE_ERROR(BADARG_ATOM); + } + // Trap caller waiting for completion + context_update_flags(ctx, ~NoFlags, Trap); + // script will be freed as it's passed as satellite + emscripten_dispatch_to_thread(emscripten_main_runtime_thread_id(), EM_FUNC_SIG_VIII, do_run_script_tracked, script, script, ctx->process_id, ctx->global); + return term_invalid_term(); +} + +// Per-object status of a fetch. EM_JS bodies cannot see C declarations: the +// constants mirroring this enum below must be kept in sync. +enum TrackedObjectStatus +{ + TRACKED_OBJECT_OK = 0, + TRACKED_OBJECT_BAD_KEY = 1, + TRACKED_OBJECT_NOT_STRING = 2 +}; + +// clang-format off +EM_JS(char *, js_get_tracked_objects, (uint32_t *keys_ptr, uint32_t keys_n, uint32_t **sizes, uint8_t **statuses, uint32_t *objects_n, uint32_t *all_byte_size, uint8_t *get_status), { + // mirror of enum TrackedObjectStatus + const OK = 0; + const BAD_KEY = 1; + const NOT_STRING = 2; + // mirror of enum TrackedCallStatus + const CALL_OK = 0; + const CALL_ERROR = 1; + const CALL_OOM = 2; + + const setOutcome = (outcome) => { + HEAPU8[get_status / HEAPU8.BYTES_PER_ELEMENT] = outcome; + }; + // an evaluated script can replace console: a diagnostic must not decide the outcome + const logError = (message, detail) => { + try { + console.error(message, detail); + } catch (e) { + } + }; + + // A script evaluated by an earlier call may have replaced any global the + // body reaches, so it is wrapped as a whole: whatever throws, an outcome + // is still set and the caller is never left trapped. _malloc and _free + // are module bindings, not the replaceable Module properties. + let sizesPtr = 0; + let statusPtr = 0; + let stringsPtr = 0; + try { + // indexed reads: the typed array iterator is replaceable + const keysOffset = keys_ptr / HEAPU32.BYTES_PER_ELEMENT; + const keys = []; + for (let i = 0; i < keys_n; ++i) { + keys[i] = HEAPU32[keysOffset + i]; + } + + let objects; + try { + // Array.from copies an exotic array into a plain one, so nothing + // below can run hook code again through a getter + const result = Module['onGetTrackedObjects'](keys); + objects = Array.isArray(result) ? Array.from(result) : null; + } catch (e) { + logError("onGetTrackedObjects threw", e); + objects = null; + } + if (objects === null || objects.length !== keys_n) { + setOutcome(CALL_ERROR); + return 0; + } + const n = objects.length; + + sizesPtr = _malloc(n * HEAPU32.BYTES_PER_ELEMENT); + statusPtr = _malloc(n * HEAPU8.BYTES_PER_ELEMENT); + if (sizesPtr === 0 || statusPtr === 0) { + _free(sizesPtr); + _free(statusPtr); + setOutcome(CALL_OOM); + return 0; + } + let allByteSize = 0; + + for (let i = 0; i < n; ++i) { + let status = OK; + let byteSize = 0; + const object = objects[i]; + if (object === undefined) { + status = BAD_KEY; + } else if (typeof object !== "string") { + status = NOT_STRING; + } else { + byteSize = lengthBytesUTF8(object); + } + HEAPU8[statusPtr / HEAPU8.BYTES_PER_ELEMENT + i] = status; + HEAPU32[sizesPtr / HEAPU32.BYTES_PER_ELEMENT + i] = byteSize; + allByteSize += byteSize; + } + // _malloc truncates its argument to 32 bits: a larger total would + // allocate a small buffer and then write the strings past its end + if (allByteSize >= 0xffffffff) { + _free(sizesPtr); + _free(statusPtr); + setOutcome(CALL_OOM); + return 0; + } + + // one extra byte: stringToUTF8 writes a trailing NUL after the last string + stringsPtr = _malloc(allByteSize * HEAPU8.BYTES_PER_ELEMENT + 1); + if (stringsPtr === 0) { + _free(sizesPtr); + _free(statusPtr); + setOutcome(CALL_OOM); + return 0; + } + let currentStringsPtr = stringsPtr; + for (let i = 0; i < n; ++i) { + const status = HEAPU8[statusPtr / HEAPU8.BYTES_PER_ELEMENT + i]; + if (status !== OK) { + continue; + } + const string = objects[i]; + const size = HEAPU32[sizesPtr / HEAPU32.BYTES_PER_ELEMENT + i]; + // stringToUTF8 includes null byte which we don't need + stringToUTF8(string, currentStringsPtr, size+1); + currentStringsPtr += size; + } + + HEAPU32[statuses / HEAPU32.BYTES_PER_ELEMENT] = statusPtr; + HEAPU32[sizes / HEAPU32.BYTES_PER_ELEMENT] = sizesPtr; + HEAPU32[objects_n / HEAPU32.BYTES_PER_ELEMENT] = n; + HEAPU32[all_byte_size / HEAPU32.BYTES_PER_ELEMENT] = allByteSize; + setOutcome(CALL_OK); + + return stringsPtr; + } catch (e) { + logError("tracked fetch failed", e); + _free(sizesPtr); + _free(statusPtr); + _free(stringsPtr); + setOutcome(CALL_ERROR); + return 0; + } +}) +// clang-format on + +static void do_get_tracked_objects(uint32_t *ref_keys, size_t keys_n, int32_t process_id, GlobalContext *global) +{ + uint32_t objects_n = 0; + uint32_t all_byte_size = 0; + uint32_t *sizes = NULL; + uint8_t *statuses = NULL; + uint8_t get_status = TRACKED_CALL_ERROR; + + char *strings = js_get_tracked_objects(ref_keys, keys_n, &sizes, &statuses, &objects_n, &all_byte_size, &get_status); + + if (IS_NULL_PTR(strings)) { + free(sizes); + free(statuses); + if (UNLIKELY(get_status == TRACKED_CALL_OOM)) { + send_tracked_trap_oom(global, process_id); + return; + } + // the hook broke its contract and told us nothing about any single + // key: every element fails, so callers always get a list + Heap error_heap; + if (UNLIKELY(memory_init_heap(&error_heap, LIST_SIZE(keys_n, TUPLE_SIZE(2))) != MEMORY_GC_OK)) { + send_tracked_trap_oom(global, process_id); + return; + } + term errors = term_nil(); + for (size_t i = 0; i < keys_n; ++i) { + term tuple = term_alloc_tuple(2, &error_heap); + term_put_tuple_element(tuple, 0, ERROR_ATOM); + term_put_tuple_element(tuple, 1, BADVALUE_ATOM); + errors = term_list_prepend(tuple, errors, &error_heap); + } + sys_enqueue_emscripten_tracked_answer_message(global, process_id, errors, error_heap.root); + return; + } + + Heap heap; + // the sizes come from the hook, so this capacity is bounded like every + // other one computed from a size a caller can influence + size_t heap_size = LIST_SIZE(objects_n, TUPLE_SIZE(2)); + bool heap_size_overflow = false; + for (uint32_t i = 0; i < objects_n && !heap_size_overflow; ++i) { + if (statuses[i] == TRACKED_OBJECT_OK) { + size_t value_size = term_binary_heap_size(sizes[i]); + heap_size_overflow = value_size > MEMORY_HEAP_MAX_TERMS - heap_size; + heap_size += value_size; + } + } + if (UNLIKELY(heap_size_overflow || memory_init_heap(&heap, heap_size) != MEMORY_GC_OK)) { + free(sizes); + free(statuses); + free(strings); + send_tracked_trap_oom(global, process_id); + return; + } + + // move pointer to one byte past buffer + const char *current_string = strings + all_byte_size; + term result = term_nil(); + for (long i = objects_n - 1; i >= 0; --i) { + enum TrackedObjectStatus status = (enum TrackedObjectStatus) statuses[i]; + + term tuple = term_alloc_tuple(2, &heap); + if (status == TRACKED_OBJECT_OK) { + size_t size = sizes[i]; + current_string -= size; + + term binary = term_create_uninitialized_binary(size, &heap, global); + if (UNLIKELY(term_is_invalid_term(binary))) { + // a large binary is a refc binary and its buffer alloc can fail + free(sizes); + free(statuses); + free(strings); + sys_enqueue_emscripten_tracked_answer_message(global, process_id, term_invalid_term(), heap.root); + return; + } + char *data = (char *) term_binary_data(binary); + memcpy(data, current_string, size); + + term_put_tuple_element(tuple, 0, OK_ATOM); + term_put_tuple_element(tuple, 1, binary); + } else if (status == TRACKED_OBJECT_NOT_STRING) { + term_put_tuple_element(tuple, 0, ERROR_ATOM); + term_put_tuple_element(tuple, 1, BADVALUE_ATOM); + } else /* TRACKED_OBJECT_BAD_KEY */ { + term_put_tuple_element(tuple, 0, ERROR_ATOM); + term_put_tuple_element(tuple, 1, BADKEY_ATOM); + } + result = term_list_prepend(tuple, result, &heap); + } + free(sizes); + free(statuses); + free(strings); + + sys_enqueue_emscripten_tracked_answer_message(global, process_id, result, heap.root); +} + +static term nif_emscripten_get_tracked(Context *ctx, int argc, term argv[]) +{ + UNUSED(argc); + term refs = argv[0]; + term type = argv[1]; + struct EmscriptenPlatformData *platform = ctx->global->platform_data; + ErlNifEnv *env = erl_nif_env_from_context(ctx); + + VALIDATE_VALUE(refs, term_is_list); + if (UNLIKELY(type != KEY_ATOM && type != VALUE_ATOM)) { + RAISE_ERROR(BADARG_ATOM); + } + int proper; + size_t n = term_list_length(refs, &proper); + if (UNLIKELY(!proper)) { + RAISE_ERROR(BADARG_ATOM); + } + + // {ok, []} from run_script_tracked/1 must be usable here. Answering now + // also keeps the value arm from a round trip and from malloc(0) + if (n == 0) { + return term_nil(); + } + + uint32_t *ref_keys = malloc(n * sizeof(uint32_t)); + if (IS_NULL_PTR(ref_keys)) { + RAISE_ERROR(OUT_OF_MEMORY_ATOM); + } + + for (size_t i = 0; i < n; ++i) { + term ref = term_get_list_head(refs); + void *obj; + if (UNLIKELY(!enif_get_resource(env, ref, platform->tracked_object_resource_type, &obj))) { + free(ref_keys); + RAISE_ERROR(BADARG_ATOM); + } + struct TrackedObjectResource *tracked_object_rsrc = (struct TrackedObjectResource *) obj; + ref_keys[i] = tracked_object_rsrc->key; + refs = term_get_list_tail(refs); + } + + if (type == KEY_ATOM) { + if (UNLIKELY(memory_ensure_free_opt(ctx, LIST_SIZE(n, BOXED_INT64_SIZE), MEMORY_CAN_SHRINK) != MEMORY_GC_OK)) { + free(ref_keys); + RAISE_ERROR(OUT_OF_MEMORY_ATOM); + } + + term keys = term_nil(); + for (long i = n - 1; i >= 0; --i) { + keys = term_list_prepend(term_make_maybe_boxed_int64(ref_keys[i], &ctx->heap), keys, &ctx->heap); + } + free(ref_keys); + return keys; + } + assert(type == VALUE_ATOM); + // Trap caller waiting for completion + context_update_flags(ctx, ~NoFlags, Trap); + // ref_keys will be freed as it's passed as satellite + emscripten_dispatch_to_thread(emscripten_main_runtime_thread_id(), EM_FUNC_SIG_VIIII, do_get_tracked_objects, ref_keys, ref_keys, n, ctx->process_id, ctx->global); + return term_invalid_term(); +} + static const struct Nif atomvm_platform_nif = { .base.type = NIFFunctionType, .nif_ptr = nif_atomvm_platform @@ -180,6 +707,14 @@ static const struct Nif emscripten_promise_reject_nif = { .base.type = NIFFunctionType, .nif_ptr = nif_emscripten_promise_reject }; +static const struct Nif emscripten_run_script_tracked = { + .base.type = NIFFunctionType, + .nif_ptr = nif_emscripten_run_script_tracked +}; +static const struct Nif emscripten_get_tracked = { + .base.type = NIFFunctionType, + .nif_ptr = nif_emscripten_get_tracked, +}; static bool get_callback_target(Context *ctx, term t, const char **target, char **str) { @@ -795,6 +1330,12 @@ const struct Nif *platform_nifs_get_nif(const char *nifname) if (strcmp("run_script/2", nifname) == 0) { return &emscripten_run_script_nif; } + if (strcmp("run_script_tracked/1", nifname) == 0) { + return &emscripten_run_script_tracked; + } + if (strcmp("get_tracked/2", nifname) == 0) { + return &emscripten_get_tracked; + } if (strcmp("promise_resolve/1", nifname) == 0) { return &emscripten_promise_resolve_nif; } diff --git a/src/platforms/emscripten/src/lib/sys.c b/src/platforms/emscripten/src/lib/sys.c index 1a234c9cee..0c7a6c9777 100644 --- a/src/platforms/emscripten/src/lib/sys.c +++ b/src/platforms/emscripten/src/lib/sys.c @@ -48,6 +48,20 @@ #include "platform_defaultatoms.h" #include "websocket_nifs.h" +uint32_t sys_get_next_tracked_object_key(GlobalContext *glb) +{ + struct EmscriptenPlatformData *platform = glb->platform_data; + // saturate rather than wrap: a wrapped counter hands out a key that a + // live handle still owns, and both then share the same tracked value + uint32_t key = atomic_load(&platform->next_tracked_object_key); + while (key != TRACKED_OBJECT_KEY_EXHAUSTED) { + if (atomic_compare_exchange_weak(&platform->next_tracked_object_key, &key, key + 1)) { + return key; + } + } + return TRACKED_OBJECT_KEY_EXHAUSTED; +} + /** * @brief resolve a promise with an int value and destroy it * @details called on the main thread using `emscripten_dispatch_to_thread` @@ -130,6 +144,32 @@ static void htmlevent_user_data_down(ErlNifEnv *caller_env, void *obj, ErlNifPid } } +void sys_remove_tracked_object(uint32_t key) +{ + // clang-format off + EM_ASM({ + try { + Module['onTrackedObjectDelete']($0); + } catch (e) { + // hooks must not throw; a throw escaping here would land in the + // proxied call queue + try { + console.error("onTrackedObjectDelete threw", e); + } catch (ignored) { + } + } + }, key); + // clang-format on +} + +static void tracked_object_dtor(ErlNifEnv *caller_env, void *obj) +{ + UNUSED(caller_env); + + struct TrackedObjectResource *tracked_object_rsrc = (struct TrackedObjectResource *) obj; + emscripten_dispatch_to_thread(emscripten_main_runtime_thread_id(), EM_FUNC_SIG_VI, sys_remove_tracked_object, NULL, tracked_object_rsrc->key); +} + static const ErlNifResourceTypeInit promise_resource_type_init = { .members = 1, .dtor = promise_dtor, @@ -142,6 +182,11 @@ static const ErlNifResourceTypeInit htmlevent_user_data_resource_type_init = { .down = htmlevent_user_data_down, }; +static const ErlNifResourceTypeInit tracked_object_resource_type_init = { + .members = 1, + .dtor = tracked_object_dtor +}; + void sys_init_platform(GlobalContext *glb) { struct EmscriptenPlatformData *platform = malloc(sizeof(struct EmscriptenPlatformData)); @@ -158,6 +203,7 @@ void sys_init_platform(GlobalContext *glb) AVM_ABORT(); } list_init(&platform->messages); + platform->next_tracked_object_key = 0; ErlNifEnv env; erl_nif_env_partial_init_from_globalcontext(&env, glb); platform->promise_resource_type = enif_init_resource_type(&env, "promise", &promise_resource_type_init, ERL_NIF_RT_CREATE, NULL); @@ -176,6 +222,12 @@ void sys_init_platform(GlobalContext *glb) AVM_ABORT(); } + platform->tracked_object_resource_type = enif_init_resource_type(&env, "tracked_object", &tracked_object_resource_type_init, ERL_NIF_RT_CREATE, NULL); + if (IS_NULL_PTR(platform->tracked_object_resource_type)) { + fprintf(stderr, "Cannot initialize tracked_object resource type"); + AVM_ABORT(); + } + #ifndef AVM_NO_SMP platform->entropy_mutex = smp_mutex_create(); if (IS_NULL_PTR(platform->entropy_mutex)) { @@ -307,6 +359,29 @@ void sys_enqueue_emscripten_unregister_htmlevent_message(GlobalContext *glb, str sys_enqueue_emscripten_message(glb, &message->base); } +/** + * @brief Enqueue the answer of a tracked call + * @details Posting the answer signal and destroying the heap it was built in + * are scheduler only, while tracked calls run on the browser main thread. + * @param glb the global context + * @param target_pid the trapped caller waiting for the answer + * @param answer the answer term, or an invalid term to raise `out_of_memory` + * @param heap the heap the answer was built in, or NULL if it needed none + */ +void sys_enqueue_emscripten_tracked_answer_message(GlobalContext *glb, int32_t target_pid, term answer, HeapFragment *heap) +{ + struct EmscriptenMessageTrackedAnswer *message = malloc(sizeof(struct EmscriptenMessageTrackedAnswer)); + if (IS_NULL_PTR(message)) { + fprintf(stderr, "Cannot allocate message"); + AVM_ABORT(); + } + message->base.message_type = TrackedAnswer; + message->target_pid = target_pid; + message->answer = answer; + message->answer_heap = heap; + sys_enqueue_emscripten_message(glb, &message->base); +} + /** * @brief Unregister HTML Event handler * @details Actual call to unregister handler must be done on main thread which @@ -527,6 +602,31 @@ static void sys_process_emscripten_message(GlobalContext *glb, struct Emscripten emscripten_dispatch_to_thread(emscripten_main_runtime_thread_id(), EM_FUNC_SIG_VI, sys_unregister_htmlevent_handler, NULL, message_unregister->rsrc); } break; + case TrackedAnswer: { + struct EmscriptenMessageTrackedAnswer *message_answer = (struct EmscriptenMessageTrackedAnswer *) message; + Context *target_ctx = globalcontext_get_process_lock(glb, message_answer->target_pid); + if (target_ctx) { + MailboxMessage *signal = NULL; + if (LIKELY(!term_is_invalid_term(message_answer->answer))) { + // not mailbox_send_term_signal: copying the answer into + // the signal allocates and it does not report a failure + signal = mailbox_message_create_from_term(TrapAnswerSignal, message_answer->answer); + } + if (signal) { + mailbox_post_message(target_ctx, signal); + } else { + mailbox_send_immediate_signal(target_ctx, TrapExceptionSignal, OUT_OF_MEMORY_ATOM); + } + globalcontext_get_process_unlock(glb, target_ctx); + } // else: caller died, nobody is waiting + if (message_answer->answer_heap) { + // if the caller died, this drops the resources the answer + // holds and their destructors delete the JS-side values + memory_sweep_mso_list(message_answer->answer_heap->mso_list, glb, false); + memory_destroy_heap_fragment(message_answer->answer_heap); + } + } break; + case Signal: break; } diff --git a/src/platforms/emscripten/src/main.c b/src/platforms/emscripten/src/main.c index 756bfb43fa..b3a96428e5 100644 --- a/src/platforms/emscripten/src/main.c +++ b/src/platforms/emscripten/src/main.c @@ -138,6 +138,20 @@ em_promise_t call(const char *name, const char *message) return sys_enqueue_emscripten_call_message(global, name, message); } +/** + * @brief Gets a number representing TrackedObject identity. + * @return a TrackedObject id, or TRACKED_OBJECT_KEY_EXHAUSTED if every key + * has been handed out already or the VM has not started yet. + */ +EMSCRIPTEN_KEEPALIVE +uint32_t next_tracked_object_key(void) +{ + if (UNLIKELY(IS_NULL_PTR(global))) { + return TRACKED_OBJECT_KEY_EXHAUSTED; + } + return sys_get_next_tracked_object_key(global); +} + /** * @brief Emscripten entry point * @details For node builds, this function is run in the main thread. For web diff --git a/src/platforms/emscripten/tests/cypress/e2e/run_script_tracked.spec.cy.js b/src/platforms/emscripten/tests/cypress/e2e/run_script_tracked.spec.cy.js new file mode 100644 index 0000000000..c43a16954f --- /dev/null +++ b/src/platforms/emscripten/tests/cypress/e2e/run_script_tracked.spec.cy.js @@ -0,0 +1,41 @@ +/* + * This file is part of AtomVM. + * + * Copyright 2026 Davide Bettio + * + * 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 + */ +describe("run_script_tracked", () => { + beforeEach(() => { + cy.visit( + "/tests/src/test_run_script_tracked.html" + + (Cypress.env("JIT") ? "#jit" : ""), + ); + }); + + it("should track, fetch and garbage collect JS values", () => { + // The Erlang module asserts the NIF contract and reports here. + cy.get("#result", { timeout: 60000 }).should("contain", "Test success"); + // Only the final state can be asserted: the VM may collect dropped handles + // at any time, and the size may even drop below the baseline if a handle + // dropped earlier outlived the snapshot in a stale root. + cy.window({ timeout: 10000 }).should((win) => { + expect(win.Module.trackedObjectsMap.size).to.be.at.most(win.gcBaseline); + const values = [...win.Module.trackedObjectsMap.values()]; + expect(values).to.include.members(["atom", "vm", "keep"]); + expect(values).to.not.include.members(["g1", "g2", "g3"]); + }); + }); +}); diff --git a/src/platforms/emscripten/tests/cypress/e2e/tracked_hook_overrides.spec.cy.js b/src/platforms/emscripten/tests/cypress/e2e/tracked_hook_overrides.spec.cy.js new file mode 100644 index 0000000000..f3cd93fc2e --- /dev/null +++ b/src/platforms/emscripten/tests/cypress/e2e/tracked_hook_overrides.spec.cy.js @@ -0,0 +1,41 @@ +/* + * This file is part of AtomVM. + * + * Copyright 2026 Davide Bettio + * + * 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 + */ +describe("tracked hook overrides", () => { + beforeEach(() => { + cy.visit( + "/tests/src/test_tracked_hook_overrides.html" + + (Cypress.env("JIT") ? "#jit" : ""), + ); + }); + + it("should drive the tracked API through overridden hooks", () => { + // The Erlang module asserts the contract and reports here. + cy.get("#result", { timeout: 60000 }).should("contain", "Test success"); + // Asserted here because they are JavaScript side effects the Erlang module + // cannot observe. + cy.window({ timeout: 10000 }).should((win) => { + expect(win.cleanupRan).to.equal(1); + const values = [...win.Module.trackedObjectsMap.values()]; + expect(values).to.include("kept"); + expect(values).to.not.include("collectable"); + expect(win.Module.cleanupFunctions.size).to.equal(0); + }); + }); +}); diff --git a/src/platforms/emscripten/tests/src/CMakeLists.txt b/src/platforms/emscripten/tests/src/CMakeLists.txt index 1965f8779f..2e9bdbd6ce 100644 --- a/src/platforms/emscripten/tests/src/CMakeLists.txt +++ b/src/platforms/emscripten/tests/src/CMakeLists.txt @@ -32,11 +32,15 @@ endfunction() compile_erlang(test_atomvm) compile_erlang(test_call) compile_erlang(test_html5) +compile_erlang(test_run_script_tracked) +compile_erlang(test_tracked_hook_overrides) compile_erlang(test_websockets) add_custom_target(emscripten_erlang_test_modules DEPENDS test_atomvm.beam test_call.beam test_html5.beam + test_run_script_tracked.beam + test_tracked_hook_overrides.beam test_websockets.beam ) diff --git a/src/platforms/emscripten/tests/src/test_run_script_tracked.erl b/src/platforms/emscripten/tests/src/test_run_script_tracked.erl new file mode 100644 index 0000000000..cd93d4f127 --- /dev/null +++ b/src/platforms/emscripten/tests/src/test_run_script_tracked.erl @@ -0,0 +1,639 @@ +% +% This file is part of AtomVM. +% +% Copyright 2026 Davide Bettio +% +% 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(test_run_script_tracked). +-export([start/0]). + +start() -> + try + {R1, R2} = test_array_script_yields_one_handle_per_element(), + ok = test_keys_are_distinct_nonneg_integers(R1, R2), + ok = test_values_are_fetched_in_input_order(R1, R2), + ok = test_iodata_script_is_accepted(), + ok = test_empty_array_yields_empty_list(), + ok = test_null_yields_empty_list(), + ok = test_throwing_script_is_an_error(), + ok = test_non_array_script_is_an_error(), + ok = test_non_iodata_script_raises(), + ok = test_non_string_value_is_badvalue(), + ok = test_multibyte_utf8_value_round_trips(), + ok = test_large_value_round_trips(), + ok = test_empty_string_value_round_trips(), + ok = test_many_small_values_round_trip(), + ok = test_many_large_values_round_trip(), + Ra = test_deleted_key_is_badkey(), + ok = test_invalid_get_tracked_args_raise(Ra), + ok = test_empty_handle_list_yields_empty_list(), + ok = test_throwing_get_hook_is_whole_call_error(Ra), + ok = test_wrong_length_get_hook_is_whole_call_error(Ra), + ok = test_getter_throwing_get_hook_is_whole_call_error(Ra), + ok = test_invalid_hook_keys_are_an_error(), + ok = test_duplicate_hook_keys_are_an_error(), + ok = test_throwing_result_iterator_drops_its_values(), + ok = test_script_replacing_a_global_still_answers(), + ok = test_exhausted_key_space_is_an_error(), + ok = test_wrong_handle_type_raises(), + ok = test_repeated_handles_answer_once_each(), + ok = test_many_values_in_one_call(), + ok = test_large_script_is_accepted(), + ok = test_undefined_yields_empty_list(), + ok = test_key_survives_value_deletion(), + ok = test_handle_is_usable_from_another_process(), + ok = test_handle_outlives_its_creating_process(), + ok = test_concurrent_callers_get_their_own_values(), + ok = test_killed_caller_drops_its_tracked_values(), + % must stay last: it snapshots the map size cypress compares against + ok = test_garbage_collection_deletes_values(), + ok = report_success(), + % Keep R1, R2 and Ra alive: cypress asserts their values are still + % tracked, and returning from start/0 would tear the runtime down. + loop([R1, R2, Ra]) + catch + T:V:S -> + report_failure(T, V, S) + end. + +test_array_script_yields_one_handle_per_element() -> + {ok, [R1, R2]} = emscripten:run_script_tracked(<<"['atom', 'vm']">>), + {R1, R2}. + +test_keys_are_distinct_nonneg_integers(R1, R2) -> + [K1, K2] = emscripten:get_tracked([R1, R2], key), + true = is_integer(K1) andalso K1 >= 0, + true = is_integer(K2) andalso K2 >= 0, + true = K1 =/= K2, + ok. + +test_values_are_fetched_in_input_order(R1, R2) -> + [{ok, <<"atom">>}, {ok, <<"vm">>}] = emscripten:get_tracked([R1, R2], value), + ok. + +test_iodata_script_is_accepted() -> + {ok, [_, _]} = emscripten:run_script_tracked(["['io'", ", 'data']"]), + ok. + +test_empty_array_yields_empty_list() -> + {ok, []} = emscripten:run_script_tracked(<<"[]">>), + ok. + +test_null_yields_empty_list() -> + {ok, []} = emscripten:run_script_tracked(<<"null">>), + ok. + +test_throwing_script_is_an_error() -> + {error, badarg} = emscripten:run_script_tracked(<<"throw new Error('boom');">>), + ok. + +test_non_array_script_is_an_error() -> + {error, badarg} = emscripten:run_script_tracked(<<"42">>), + ok. + +test_non_iodata_script_raises() -> + expect_badarg(fun() -> emscripten:run_script_tracked(42) end). + +test_non_string_value_is_badvalue() -> + {ok, [RNum]} = emscripten:run_script_tracked(<<"[42]">>), + [{error, badvalue}] = emscripten:get_tracked([RNum], value), + ok. + +% U+00E9 (2 UTF-8 bytes), U+20AC (3 bytes) and U+1F389 (a surrogate pair in +% the script, 4 UTF-8 bytes) exercise the multibyte paths of the value fetch. +test_multibyte_utf8_value_round_trips() -> + {ok, [R]} = emscripten:run_script_tracked(<<"['\\u00e9\\u20ac\\ud83c\\udf89']">>), + [{ok, <<16#C3, 16#A9, 16#E2, 16#82, 16#AC, 16#F0, 16#9F, 16#8E, 16#89>>}] = + emscripten:get_tracked([R], value), + ok. + +% 80 bytes is past REFC_BINARY_MIN, so this takes the refc binary branch +test_large_value_round_trips() -> + Str = lists:duplicate(80, $a), + {ok, [R]} = emscripten:run_script_tracked(["['", Str, "']"]), + Expected = list_to_binary(Str), + [{ok, Expected}] = emscripten:get_tracked([R], value), + ok. + +test_empty_string_value_round_trips() -> + {ok, [R]} = emscripten:run_script_tracked(<<"['']">>), + [{ok, <<>>}] = emscripten:get_tracked([R], value), + ok. + +% Several values in one call catch an answer heap sized for a single binary: +% each of them takes its own binary on that heap. +test_many_small_values_round_trip() -> + Strings = [lists:duplicate(10, C) || C <- lists:seq($a, $j)], + {ok, Refs} = emscripten:run_script_tracked(tracked_script(Strings)), + Expected = [{ok, list_to_binary(S)} || S <- Strings], + Expected = emscripten:get_tracked(Refs, value), + ok. + +% values of REFC_BINARY_MIN bytes and more take the refc binary branch +test_many_large_values_round_trip() -> + Strings = [lists:duplicate(50, C) || C <- lists:seq($a, $h)], + {ok, Refs} = emscripten:run_script_tracked(tracked_script(Strings)), + Expected = [{ok, list_to_binary(S)} || S <- Strings], + Expected = emscripten:get_tracked(Refs, value), + ok. + +tracked_script(Strings) -> + Quoted = [[$', S, $'] || S <- Strings], + ["[", lists:join($,, Quoted), "]"]. + +test_deleted_key_is_badkey() -> + {ok, [Ra, Rb]} = emscripten:run_script_tracked(<<"['keep', 'drop']">>), + [_Ka, Kb] = emscripten:get_tracked([Ra, Rb], key), + ok = emscripten:run_script( + [<<"window.Module.trackedObjectsMap.delete(">>, integer_to_list(Kb), <<");">>], + [main_thread] + ), + [{ok, <<"keep">>}, {error, badkey}] = emscripten:get_tracked([Ra, Rb], value), + Ra. + +test_invalid_get_tracked_args_raise(Ra) -> + ok = expect_badarg(fun() -> emscripten:get_tracked(not_a_list, key) end), + ok = expect_badarg(fun() -> emscripten:get_tracked([Ra], bogus) end), + ok = expect_badarg(fun() -> emscripten:get_tracked([1, 2], key) end), + ok = expect_badarg(fun() -> emscripten:get_tracked([Ra | Ra], key) end), + ok. + +% the {ok, []} of a script that tracked nothing must be usable as input +test_empty_handle_list_yields_empty_list() -> + [] = emscripten:get_tracked([], key), + [] = emscripten:get_tracked([], value), + {ok, []} = emscripten:run_script_tracked(<<"[]">>), + ok. + +% a throwing override must yield the whole-call error, not hang the caller +test_throwing_get_hook_is_whole_call_error(Ra) -> + ok = emscripten:run_script( + << + "window.Module.savedOnGetTrackedObjects = window.Module.onGetTrackedObjects;" + "window.Module.onGetTrackedObjects = function() { throw new Error('hook boom'); };" + >>, + [main_thread] + ), + [{error, badvalue}] = emscripten:get_tracked([Ra], value), + ok = restore_get_hook(), + [{ok, <<"keep">>}] = emscripten:get_tracked([Ra], value), + ok. + +test_wrong_length_get_hook_is_whole_call_error(Ra) -> + ok = emscripten:run_script( + << + "window.Module.savedOnGetTrackedObjects = window.Module.onGetTrackedObjects;" + "window.Module.onGetTrackedObjects = function() { return []; };" + >>, + [main_thread] + ), + [{error, badvalue}] = emscripten:get_tracked([Ra], value), + ok = restore_get_hook(), + ok. + +% The hook returns without throwing here: the throw comes from reading an +% element of the array it returned, which must not escape the guard either. +test_getter_throwing_get_hook_is_whole_call_error(Ra) -> + ok = emscripten:run_script( + << + "window.Module.savedOnGetTrackedObjects = window.Module.onGetTrackedObjects;" + "window.Module.onGetTrackedObjects = function(keys) {" + " const result = new Array(keys.length);" + " Object.defineProperty(result, 0, {" + " get: function() { throw new Error('getter boom'); }" + " });" + " return result;" + "};" + >>, + [main_thread] + ), + [{error, badvalue}] = emscripten:get_tracked([Ra], value), + ok = restore_get_hook(), + [{ok, <<"keep">>}] = emscripten:get_tracked([Ra], value), + ok. + +% A key outside the unsigned 32 bit range must fail the call rather than be +% truncated into an unrelated entry, and every value the hook tracked must be +% dropped again, under the malformed key as well as the valid one. +test_invalid_hook_keys_are_an_error() -> + ok = expect_invalid_hook_key_error(<<"-1">>), + ok = expect_invalid_hook_key_error(<<"1.5">>), + ok = expect_invalid_hook_key_error(<<"4294967296">>), + ok = expect_invalid_hook_key_error(<<"4294967295">>), + ok. + +expect_invalid_hook_key_error(InvalidKey) -> + ok = install_invalid_key_hook(InvalidKey), + {error, badarg} = emscripten:run_script_tracked(<<"ignored by the hook">>), + ok = restore_run_hook(), + {ok, [RValid, RBad]} = emscripten:run_script_tracked( + << + "[String(window.Module.trackedObjectsMap.has(window.validHookKey))," + " String(window.Module.trackedObjectsMap.has(window.badHookKey))]" + >> + ), + [{ok, <<"false">>}, {ok, <<"false">>}] = emscripten:get_tracked([RValid, RBad], value), + ok. + +install_invalid_key_hook(InvalidKey) -> + emscripten:run_script( + [ + <<"window.Module.savedOnRunTrackedJs = window.Module.onRunTrackedJs;">>, + <<"window.Module.onRunTrackedJs = function() {">>, + <<" const key = window.Module.nextTrackedObjectKey();">>, + <<" window.Module.trackedObjectsMap.set(key, 'orphan');">>, + <<" window.validHookKey = key;">>, + <<" const bad = ">>, + InvalidKey, + <<";">>, + <<" window.Module.trackedObjectsMap.set(bad, 'orphan');">>, + <<" window.badHookKey = bad;">>, + <<" return [key, bad];">>, + <<"};">> + ], + [main_thread] + ). + +% An evaluated script shares its realm with the module and can replace a +% global, through an undeclared assignment in sloppy mode if nothing else. +% Two values are needed: a single one never reaches the duplicate check. +test_script_replacing_a_global_still_answers() -> + {ok, [R1, R2]} = emscripten:run_script_tracked( + << + "window.savedSet = window.Set;" + "window.Set = function () { throw new Error('clobbered'); };" + "['one', 'two'];" + >> + ), + ok = emscripten:run_script( + <<"window.Set = window.savedSet; delete window.savedSet;">>, + [main_thread] + ), + [{ok, <<"one">>}, {ok, <<"two">>}] = emscripten:get_tracked([R1, R2], value), + ok. + +% Reading the returned array can throw after it has yielded elements: the +% default hook has tracked them by then and has to give them up itself. +test_throwing_result_iterator_drops_its_values() -> + {error, badarg} = emscripten:run_script_tracked( + << + "const values = [];" + "values[Symbol.iterator] = function* () {" + " yield 'iterated';" + " throw new Error('iterator boom');" + "};" + "values;" + >> + ), + {ok, [R]} = emscripten:run_script_tracked( + << + "[String([...window.Module.trackedObjectsMap.values()]" + ".includes('iterated'))]" + >> + ), + [{ok, <<"false">>}] = emscripten:get_tracked([R], value), + ok. + +% One key returned twice would get one handle each, and the first collected +% would delete the value the other still addresses. The rollback must drop it +% once: a deletion hook is not required to be idempotent. +test_duplicate_hook_keys_are_an_error() -> + ok = emscripten:run_script( + << + "window.Module.savedOnTrackedObjectDelete = window.Module.onTrackedObjectDelete;" + "window.duplicateDeleteCount = 0;" + "window.Module.onTrackedObjectDelete = function(key) {" + " if (key === window.duplicateHookKey) {" + " window.duplicateDeleteCount += 1;" + " }" + " window.Module.savedOnTrackedObjectDelete(key);" + "};" + "window.Module.savedOnRunTrackedJs = window.Module.onRunTrackedJs;" + "window.Module.onRunTrackedJs = function() {" + " const key = window.Module.nextTrackedObjectKey();" + " window.Module.trackedObjectsMap.set(key, 'twice');" + " window.duplicateHookKey = key;" + " return [key, key];" + "};" + >>, + [main_thread] + ), + {error, badarg} = emscripten:run_script_tracked(<<"ignored by the hook">>), + ok = restore_run_hook(), + ok = emscripten:run_script( + << + "window.Module.onTrackedObjectDelete = window.Module.savedOnTrackedObjectDelete;" + "delete window.Module.savedOnTrackedObjectDelete;" + >>, + [main_thread] + ), + {ok, [RHas, RCount]} = emscripten:run_script_tracked( + << + "[String(window.Module.trackedObjectsMap.has(window.duplicateHookKey))," + " String(window.duplicateDeleteCount)]" + >> + ), + [{ok, <<"false">>}, {ok, <<"1">>}] = emscripten:get_tracked([RHas, RCount], value), + ok. + +% The key counter saturates at the reserved key rather than wrapping into a +% key a live handle still owns, and the default hook refuses to track under it. +test_exhausted_key_space_is_an_error() -> + ok = emscripten:run_script( + << + "window.Module.savedNextTrackedObjectKey = window.Module.nextTrackedObjectKey;" + "window.Module.nextTrackedObjectKey = function() { return 4294967295; };" + >>, + [main_thread] + ), + {error, badarg} = emscripten:run_script_tracked(<<"['exhausted']">>), + ok = emscripten:run_script( + << + "window.Module.nextTrackedObjectKey = window.Module.savedNextTrackedObjectKey;" + "delete window.Module.savedNextTrackedObjectKey;" + >>, + [main_thread] + ), + {ok, [R]} = emscripten:run_script_tracked( + <<"[String(window.Module.trackedObjectsMap.has(4294967295))]">> + ), + [{ok, <<"false">>}] = emscripten:get_tracked([R], value), + ok. + +restore_run_hook() -> + emscripten:run_script( + << + "window.Module.onRunTrackedJs = window.Module.savedOnRunTrackedJs;" + "delete window.Module.savedOnRunTrackedJs;" + >>, + [main_thread] + ). + +restore_get_hook() -> + emscripten:run_script( + << + "window.Module.onGetTrackedObjects = window.Module.savedOnGetTrackedObjects;" + "delete window.Module.savedOnGetTrackedObjects;" + >>, + [main_thread] + ). + +% A resource of another type is not a tracked object, and neither is a +% plain reference, even though a handle is one. +test_wrong_handle_type_raises() -> + Listener = + case emscripten:register_click_callback(window, []) of + {ok, Handle} -> Handle; + {ok, Handle, deferred} -> Handle + end, + ok = expect_badarg(fun() -> emscripten:get_tracked([Listener], key) end), + ok = expect_badarg(fun() -> emscripten:get_tracked([Listener], value) end), + ok = emscripten:unregister_click_callback(Listener), + ok = expect_badarg(fun() -> emscripten:get_tracked([make_ref()], key) end), + ok = expect_badarg(fun() -> emscripten:get_tracked([make_ref()], value) end), + ok. + +% The same handle twice is not an error: it names one entry, so both +% positions answer for it. +test_repeated_handles_answer_once_each() -> + Created = emscripten:run_script_tracked(<<"['twice']">>), + {ok, [Ref]} = Created, + Values = emscripten:get_tracked([Ref, Ref], value), + [{ok, <<"twice">>}, {ok, <<"twice">>}] = Values, + Keys = emscripten:get_tracked([Ref, Ref], key), + [Key, Key] = Keys, + true = is_integer(Key), + ok. + +% exercises the key buffer and the answer heap at a realistic size +test_many_values_in_one_call() -> + Count = 1000, + Strings = [integer_to_list(I) || I <- lists:seq(1, Count)], + Created = emscripten:run_script_tracked(tracked_script(Strings)), + {ok, Refs} = Created, + Count = length(Refs), + Values = emscripten:get_tracked(Refs, value), + Expected = [{ok, list_to_binary(S)} || S <- Strings], + Expected = Values, + ok. + +% embedders build scripts around serialized arguments, so they can be large +test_large_script_is_accepted() -> + Padding = lists:duplicate(200000, $a), + Created = emscripten:run_script_tracked(["[String('", Padding, "'.length)]"]), + {ok, Refs} = Created, + Values = emscripten:get_tracked(Refs, value), + [{ok, <<"200000">>}] = Values, + ok. + +test_undefined_yields_empty_list() -> + {ok, []} = emscripten:run_script_tracked(<<"undefined">>), + ok. + +% The key belongs to the handle, not to the map entry, so it stays +% readable after the value behind it is gone. +test_key_survives_value_deletion() -> + Created = emscripten:run_script_tracked(<<"['vanishes']">>), + {ok, [Ref]} = Created, + [Key] = emscripten:get_tracked([Ref], key), + ok = emscripten:run_script( + [<<"window.Module.trackedObjectsMap.delete(">>, integer_to_list(Key), <<");">>], + [main_thread] + ), + [Key] = emscripten:get_tracked([Ref], key), + [{error, badkey}] = emscripten:get_tracked([Ref], value), + ok. + +% a handle is an ordinary term and survives being sent to another process +test_handle_is_usable_from_another_process() -> + Created = emscripten:run_script_tracked(<<"['shared']">>), + {ok, [Ref]} = Created, + Parent = self(), + _ = spawn(fun() -> Parent ! {fetched, emscripten:get_tracked([Ref], value)} end), + receive + {fetched, Fetched} -> [{ok, <<"shared">>}] = Fetched + after 10000 -> error(fetch_timeout) + end, + ok. + +% The JavaScript value belongs to the handle, not to the process that ran +% the script, so it stays alive once its creator is gone. +test_handle_outlives_its_creating_process() -> + Parent = self(), + {Creator, Monitor} = spawn_monitor(fun() -> + Created = emscripten:run_script_tracked(<<"['outlives']">>), + {ok, [Ref]} = Created, + Parent ! {handle, Ref} + end), + Handle = + receive + {handle, H} -> H + after 10000 -> error(handle_timeout) + end, + receive + {'DOWN', Monitor, process, Creator, _Reason} -> ok + after 10000 -> error(down_timeout) + end, + erlang:garbage_collect(), + Fetched = emscripten:get_tracked([Handle], value), + [{ok, <<"outlives">>}] = Fetched, + ok. + +% The key counter and the answer messages are shared, so concurrent callers +% must still each get their own values and keys. +test_concurrent_callers_get_their_own_values() -> + Parent = self(), + Ids = lists:seq(1, 8), + _ = [spawn(fun() -> concurrent_caller(Parent, Id) end) || Id <- Ids], + Results = collect_concurrent(length(Ids), []), + Expected = [{Id, [{ok, concurrent_value(Id)}]} || Id <- Ids], + Expected = Results, + ok. + +concurrent_caller(Parent, Id) -> + Created = emscripten:run_script_tracked([<<"['concurrent">>, integer_to_list(Id), <<"']">>]), + {ok, Refs} = Created, + Values = emscripten:get_tracked(Refs, value), + Keys = emscripten:get_tracked(Refs, key), + Parent ! {tracked, Id, Values, Keys}, + ok. + +concurrent_value(Id) -> + list_to_binary("concurrent" ++ integer_to_list(Id)). + +collect_concurrent(0, Acc) -> + lists:sort(Acc); +collect_concurrent(Pending, Acc) -> + receive + {tracked, Id, Values, [Key]} when is_integer(Key), Key >= 0 -> + collect_concurrent(Pending - 1, [{Id, Values} | Acc]) + after 30000 -> + error({concurrent_timeout, Pending}) + end. + +% Nobody owns the values tracked by a caller killed mid-call, so the answer +% the VM built for a process that no longer exists has to be torn down and its +% deletions dispatched, or the values stay in the map forever. +test_killed_caller_drops_its_tracked_values() -> + Caller = spawn(fun() -> + _ = emscripten:run_script_tracked( + << + "const until = Date.now() + 300;" + "while (Date.now() < until) {}" + "['inflight'];" + >> + ), + loop([]) + end), + receive + after 100 -> ok + end, + true = erlang:exit(Caller, kill), + ok = wait_until_untracked(<<"inflight">>, 100). + +% The deletion is itself dispatched to the main thread, so it lands some +% time after the answer message was processed. +wait_until_untracked(_Value, 0) -> + {error, still_tracked}; +wait_until_untracked(Value, Attempts) -> + Probed = emscripten:run_script_tracked( + [ + <<"[String([...window.Module.trackedObjectsMap.values()].includes('">>, + Value, + <<"'))]">> + ] + ), + {ok, Refs} = Probed, + Fetched = emscripten:get_tracked(Refs, value), + case Fetched of + [{ok, <<"false">>}] -> + ok; + [{ok, <<"true">>}] -> + receive + after 50 -> ok + end, + wait_until_untracked(Value, Attempts - 1) + end. + +% Only the final state can be asserted, by cypress through window.gcBaseline +% and the map contents: the VM may collect dropped handles at any allocation +% point, not just at an explicit erlang:garbage_collect(). +test_garbage_collection_deletes_values() -> + erlang:garbage_collect(), + ok = emscripten:run_script( + <<"window.gcBaseline = window.Module.trackedObjectsMap.size;">>, + [main_thread] + ), + ok = make_garbage(), + erlang:garbage_collect(), + ok. + +% The handles must go out of scope before the collection, so they are made in +% a frame of their own and dropped on return. +make_garbage() -> + {ok, [_, _, _]} = emscripten:run_script_tracked(<<"['g1', 'g2', 'g3']">>), + ok. + +expect_badarg(Fun) -> + try + Result = Fun(), + {no_error_raised, Result} + catch + error:badarg -> ok; + T:V -> {unexpected_error, T, V} + end. + +report_success() -> + emscripten:run_script( + [<<"window.document.getElementById('result').innerHTML = 'Test success';">>], + [main_thread] + ). + +report_failure(T, V, S) -> + emscripten:run_script( + [ + <<"window.document.getElementById('result').innerHTML = \"Failure: ">>, + escape_js_str(lists:flatten(io_lib:format("~p\n~p\n~p", [T, V, S]))), + <<"\";">> + ], + [main_thread, async] + ). + +loop(KeepRefs) -> + receive + _Any -> loop(KeepRefs) + end. + +escape_js_str(Str) -> + escape_js_str(Str, []). + +escape_js_str([$\\ | Tail], Acc) -> + escape_js_str(Tail, ["\\\\" | Acc]); +escape_js_str([$" | Tail], Acc) -> + escape_js_str(Tail, ["\\\"" | Acc]); +escape_js_str([$\n | Tail], Acc) -> + escape_js_str(Tail, ["
    " | Acc]); +escape_js_str([$& | Tail], Acc) -> + escape_js_str(Tail, ["&" | Acc]); +escape_js_str([$< | Tail], Acc) -> + escape_js_str(Tail, ["<" | Acc]); +escape_js_str([$> | Tail], Acc) -> + escape_js_str(Tail, [">" | Acc]); +escape_js_str([C | Tail], Acc) -> + escape_js_str(Tail, [C | Acc]); +escape_js_str([], Acc) -> + lists:reverse(Acc). diff --git a/src/platforms/emscripten/tests/src/test_run_script_tracked.html b/src/platforms/emscripten/tests/src/test_run_script_tracked.html new file mode 100644 index 0000000000..a77c09e624 --- /dev/null +++ b/src/platforms/emscripten/tests/src/test_run_script_tracked.html @@ -0,0 +1,46 @@ + + + + + + + +
    N/A
    + + + diff --git a/src/platforms/emscripten/tests/src/test_tracked_hook_overrides.erl b/src/platforms/emscripten/tests/src/test_tracked_hook_overrides.erl new file mode 100644 index 0000000000..1cca85f46c --- /dev/null +++ b/src/platforms/emscripten/tests/src/test_tracked_hook_overrides.erl @@ -0,0 +1,218 @@ +% +% This file is part of AtomVM. +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +%% Exercises the tracked values API against overridden hooks rather than the +%% defaults. The overrides live in test_tracked_hook_overrides.html, shaped +%% like an embedder's: the script is a function receiving the module, values +%% are serialized to JSON, keys may be allocated by the hook, and deleting a +%% key runs a cleanup closure first. +-module(test_tracked_hook_overrides). +-export([start/0]). + +start() -> + try + ok = wait_for_hooks(200), + ok = test_function_script_yields_one_handle_per_element(), + ok = test_values_come_back_as_json(), + ok = test_hook_allocated_key_is_kept(), + ok = test_unserializable_value_fails_every_element(), + ok = test_arguments_are_deserialized_by_the_hook(), + Kept = test_cleanup_runs_when_a_handle_is_collected(), + ok = report_success(), + % Returning from start/0 would tear the runtime down, and cypress + % still has to read the final state. + loop([Kept]) + catch + T:V:S -> + report_failure(T, V, S) + end. + +%% The page installs its hooks right after the module factory resolves, but the +%% browser main thread may drain a proxied call while it is still suspended on +%% that await. The default hook refuses a script evaluating to a function. +wait_for_hooks(0) -> + {error, hooks_never_installed}; +wait_for_hooks(Attempts) -> + Probed = + try + emscripten:run_script_tracked(<<"(Module) => ['ready']">>) + catch + T:V -> {caught, T, V} + end, + case Probed of + {ok, [_]} -> + ok; + _ -> + receive + after 50 -> ok + end, + wait_for_hooks(Attempts - 1) + end. + +test_function_script_yields_one_handle_per_element() -> + Created = emscripten:run_script_tracked(<<"(Module) => { return ['one', 'two']; }">>), + {ok, Refs} = Created, + 2 = length(Refs), + ok. + +%% The hook serializes with JSON.stringify, so a string comes back quoted +%% and a map comes back as an object. +test_values_come_back_as_json() -> + Created = emscripten:run_script_tracked(<<"(Module) => [{x: 1}, 'str', 42]">>), + {ok, Refs} = Created, + Values = emscripten:get_tracked(Refs, value), + [{ok, <<"{\"x\":1}">>}, {ok, <<"\"str\"">>}, {ok, <<"42">>}] = Values, + ok. + +%% A hook may hand back a key it allocated itself instead of letting the +%% default one do it. The VM must accept it and report it unchanged. +test_hook_allocated_key_is_kept() -> + Created = emscripten:run_script_tracked( + << + "(Module) => {" + " const key = Module.nextTrackedObjectKey();" + " window.preallocatedKey = key;" + " return [new TrackedValue({key: key, value: 'preallocated'})];" + "}" + >> + ), + {ok, Refs} = Created, + [Key] = emscripten:get_tracked(Refs, key), + Values = emscripten:get_tracked(Refs, value), + [{ok, <<"\"preallocated\"">>}] = Values, + Reported = emscripten:run_script_tracked( + <<"(Module) => [String(window.preallocatedKey)]">> + ), + {ok, ReportedRefs} = Reported, + % the hook serializes, so the key comes back as a JSON string + Expected = list_to_binary("\"" ++ integer_to_list(Key) ++ "\""), + [{ok, Expected}] = emscripten:get_tracked(ReportedRefs, value), + ok. + +%% JSON.stringify throws on a circular structure, which is a hook contract +%% violation: the fetch then says nothing about any single key. +test_unserializable_value_fails_every_element() -> + Created = emscripten:run_script_tracked( + <<"(Module) => { const o = {}; o.self = o; return [o, 'plain']; }">> + ), + {ok, Refs} = Created, + Values = emscripten:get_tracked(Refs, value), + [{error, badvalue}, {error, badvalue}] = Values, + ok. + +%% Embedders pass arguments by embedding them in the script they build. +test_arguments_are_deserialized_by_the_hook() -> + Created = emscripten:run_script_tracked([ + <<"(Module) => { const args = Module.deserialize('">>, + <<"{\"n\": 21}">>, + <<"'); return [String(args.n * 2)]; }">> + ]), + {ok, Refs} = Created, + Values = emscripten:get_tracked(Refs, value), + [{ok, <<"\"42\"">>}] = Values, + ok. + +%% Collecting a handle must reach the override, which runs the closure +%% registered for that key before dropping the entry. Returns a handle that +%% must NOT be collected, so cypress can tell the two apart. +test_cleanup_runs_when_a_handle_is_collected() -> + ok = emscripten:run_script(<<"window.cleanupRan = 0;">>, [main_thread]), + Kept = make_kept_handle(), + ok = make_collectable_handle(), + erlang:garbage_collect(), + ok = wait_for_cleanup(100), + Kept. + +make_kept_handle() -> + Created = emscripten:run_script_tracked(<<"(Module) => ['kept']">>), + {ok, [Ref]} = Created, + Ref. + +%% The handle has to go out of scope before the collection, so it is made +%% in a frame of its own and dropped on return. +make_collectable_handle() -> + Created = emscripten:run_script_tracked( + << + "(Module) => {" + " const key = Module.nextTrackedObjectKey();" + " Module.cleanupFunctions.set(key, () => { window.cleanupRan += 1; });" + " return [new TrackedValue({key: key, value: 'collectable'})];" + "}" + >> + ), + {ok, [_]} = Created, + ok. + +wait_for_cleanup(0) -> + {error, cleanup_never_ran}; +wait_for_cleanup(Attempts) -> + Probed = emscripten:run_script_tracked(<<"(Module) => [String(window.cleanupRan)]">>), + {ok, Refs} = Probed, + Fetched = emscripten:get_tracked(Refs, value), + case Fetched of + [{ok, <<"\"0\"">>}] -> + receive + after 50 -> ok + end, + wait_for_cleanup(Attempts - 1); + [{ok, <<"\"1\"">>}] -> + ok + end. + +report_success() -> + emscripten:run_script( + [<<"window.document.getElementById('result').innerHTML = 'Test success';">>], + [main_thread] + ). + +report_failure(T, V, S) -> + emscripten:run_script( + [ + <<"window.document.getElementById('result').innerHTML = \"Failure: ">>, + escape_js_str(lists:flatten(io_lib:format("~p\n~p\n~p", [T, V, S]))), + <<"\";">> + ], + [main_thread, async] + ). + +loop(KeepRefs) -> + receive + _Any -> loop(KeepRefs) + end. + +escape_js_str(Str) -> + escape_js_str(Str, []). + +escape_js_str([$\\ | Tail], Acc) -> + escape_js_str(Tail, ["\\\\" | Acc]); +escape_js_str([$" | Tail], Acc) -> + escape_js_str(Tail, ["\\\"" | Acc]); +escape_js_str([$\n | Tail], Acc) -> + escape_js_str(Tail, ["
    " | Acc]); +escape_js_str([$& | Tail], Acc) -> + escape_js_str(Tail, ["&" | Acc]); +escape_js_str([$< | Tail], Acc) -> + escape_js_str(Tail, ["<" | Acc]); +escape_js_str([$> | Tail], Acc) -> + escape_js_str(Tail, [">" | Acc]); +escape_js_str([C | Tail], Acc) -> + escape_js_str(Tail, [C | Acc]); +escape_js_str([], Acc) -> + lists:reverse(Acc). diff --git a/src/platforms/emscripten/tests/src/test_tracked_hook_overrides.html b/src/platforms/emscripten/tests/src/test_tracked_hook_overrides.html new file mode 100644 index 0000000000..c07ce74b02 --- /dev/null +++ b/src/platforms/emscripten/tests/src/test_tracked_hook_overrides.html @@ -0,0 +1,121 @@ + + + + + + + +
    N/A
    + + +