From 0ac12fcc1dfc5c587d9333fe6a9db0593dc52563 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Sat, 20 Sep 2025 13:17:02 +0300 Subject: [PATCH 01/17] wasm: Add JS scaffolding for tracked values Signed-off-by: Jakub Gonet --- src/platforms/emscripten/src/atomvm.pre.js | 58 ++++++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/src/platforms/emscripten/src/atomvm.pre.js b/src/platforms/emscripten/src/atomvm.pre.js index 73ac9f647a..af0b150472 100644 --- a/src/platforms/emscripten/src/atomvm.pre.js +++ b/src/platforms/emscripten/src/atomvm.pre.js @@ -17,10 +17,58 @@ * * 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 () { + return ccall("next_tracked_object_key", "integer", [], []); +}; +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); +}; +Module["onRunTrackedJs"] = (scriptString, isDebug) => { + const trackValue = (value) => { + const key = Module["nextTrackedObjectKey"](); + Module["trackedObjectsMap"].set(key, value); + return key; + }; + + let result; + try { + const indirectEval = eval; + result = indirectEval(scriptString); + } catch (_e) { + return null; + } + isDebug && ensureValidResult(result); + return result?.map(trackValue) ?? []; +}; + +function ensureValidResult(result) { + const isIndex = (k) => typeof k === "number"; + + if (result === null) { + return; + } + if (Array.isArray(result) && keys.every(isIndex)) { + return; + } + + const message = + "Evaluated script returned invalid value. Expected number array or null"; + throw new Error(message); +} From 8709ee3a6c24c51ef68eaef1821458168953e951 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Sat, 20 Sep 2025 13:38:45 +0300 Subject: [PATCH 02/17] wasm: Add TrackedObject resource Signed-off-by: Jakub Gonet --- .../emscripten/src/lib/emscripten_sys.h | 6 +++++ src/platforms/emscripten/src/lib/sys.c | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/platforms/emscripten/src/lib/emscripten_sys.h b/src/platforms/emscripten/src/lib/emscripten_sys.h index 1889b2ab97..9d286b0918 100644 --- a/src/platforms/emscripten/src/lib/emscripten_sys.h +++ b/src/platforms/emscripten/src/lib/emscripten_sys.h @@ -108,6 +108,11 @@ struct EmscriptenMessageUnregisterHTMLEvent struct HTMLEventUserDataResource *rsrc; }; +struct TrackedObjectResource +{ + int32_t key; +}; + struct EmscriptenPlatformData { pthread_mutex_t poll_mutex; @@ -116,6 +121,7 @@ struct EmscriptenPlatformData 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; diff --git a/src/platforms/emscripten/src/lib/sys.c b/src/platforms/emscripten/src/lib/sys.c index 1a234c9cee..a939757841 100644 --- a/src/platforms/emscripten/src/lib/sys.c +++ b/src/platforms/emscripten/src/lib/sys.c @@ -130,6 +130,19 @@ static void htmlevent_user_data_down(ErlNifEnv *caller_env, void *obj, ErlNifPid } } +static void do_remove_tracked_object(atomic_size_t key) +{ + EM_ASM({ Module['onTrackedObjectDelete']($0); }, key); +} + +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, do_remove_tracked_object, NULL, tracked_object_rsrc->key); +} + static const ErlNifResourceTypeInit promise_resource_type_init = { .members = 1, .dtor = promise_dtor, @@ -142,6 +155,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)); @@ -176,6 +194,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)) { From 1843a22bc03b0dc2aa9d16e3bf19d32f1f527258 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Sat, 20 Sep 2025 14:00:24 +0300 Subject: [PATCH 03/17] wasm: Add get_next_tracked_object_key It's a field in emscripten platform struct. It could be a callback entirely in JS but we use threads. Threads are emulated via webworkers which have their own contexts and variables aren't shared with each other. This means that we would lose uniqueness of the keys. Signed-off-by: Jakub Gonet --- src/platforms/emscripten/src/CMakeLists.txt | 6 +++--- src/platforms/emscripten/src/lib/emscripten_sys.h | 2 ++ src/platforms/emscripten/src/lib/sys.c | 7 +++++++ src/platforms/emscripten/src/main.c | 10 ++++++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/platforms/emscripten/src/CMakeLists.txt b/src/platforms/emscripten/src/CMakeLists.txt index a57e0b3f52..78d15bb5e6 100644 --- a/src/platforms/emscripten/src/CMakeLists.txt +++ b/src/platforms/emscripten/src/CMakeLists.txt @@ -36,12 +36,12 @@ if (NOT AVM_DISABLE_JIT) # JIT requires addFunction to register dynamically compiled WASM functions set(JIT_LINK_FLAGS -sALLOW_TABLE_GROWTH - "-sEXPORTED_RUNTIME_METHODS=['ccall','addFunction','ENV']" + "-sEXPORTED_RUNTIME_METHODS=['ccall','addFunction','ENV','cwrap','stringToNewUTF8','FS']" ) else() - set(JIT_LINK_FLAGS "-sEXPORTED_RUNTIME_METHODS=['ccall','ENV']") + set(JIT_LINK_FLAGS "-sEXPORTED_RUNTIME_METHODS=['ccall','ENV','cwrap','stringToNewUTF8','FS']") 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,_cast,_call,_next_tracked_object_key,_main -sEMULATE_FUNCTION_POINTER_CASTS=1 -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/lib/emscripten_sys.h b/src/platforms/emscripten/src/lib/emscripten_sys.h index 9d286b0918..8e396e9305 100644 --- a/src/platforms/emscripten/src/lib/emscripten_sys.h +++ b/src/platforms/emscripten/src/lib/emscripten_sys.h @@ -118,6 +118,7 @@ struct EmscriptenPlatformData pthread_mutex_t poll_mutex; pthread_cond_t poll_cond; struct ListHead messages; + atomic_size_t next_tracked_object_key; ErlNifResourceType *promise_resource_type; ErlNifResourceType *htmlevent_user_data_resource_type; ErlNifResourceType *websocket_resource_type; @@ -140,6 +141,7 @@ 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); +size_t sys_get_next_tracked_object_key(GlobalContext *glb); 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/sys.c b/src/platforms/emscripten/src/lib/sys.c index a939757841..15f76f619e 100644 --- a/src/platforms/emscripten/src/lib/sys.c +++ b/src/platforms/emscripten/src/lib/sys.c @@ -48,6 +48,12 @@ #include "platform_defaultatoms.h" #include "websocket_nifs.h" +size_t sys_get_next_tracked_object_key(GlobalContext *glb) +{ + struct EmscriptenPlatformData *platform = glb->platform_data; + return platform->next_tracked_object_key++; +} + /** * @brief resolve a promise with an int value and destroy it * @details called on the main thread using `emscripten_dispatch_to_thread` @@ -176,6 +182,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); diff --git a/src/platforms/emscripten/src/main.c b/src/platforms/emscripten/src/main.c index 756bfb43fa..b0cf7fedc7 100644 --- a/src/platforms/emscripten/src/main.c +++ b/src/platforms/emscripten/src/main.c @@ -138,6 +138,16 @@ 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. + */ +EMSCRIPTEN_KEEPALIVE +size_t next_tracked_object_key() +{ + 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 From e3ea27789da1cdb88d67b32b9fa97fe5c188d58f Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Sun, 21 Sep 2025 10:59:47 +0300 Subject: [PATCH 04/17] wasm: Add js_tracked_eval Signed-off-by: Jakub Gonet --- .../emscripten/src/lib/platform_nifs.c | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/platforms/emscripten/src/lib/platform_nifs.c b/src/platforms/emscripten/src/lib/platform_nifs.c index 803c8e5138..75ecc73c9f 100644 --- a/src/platforms/emscripten/src/lib/platform_nifs.c +++ b/src/platforms/emscripten/src/lib/platform_nifs.c @@ -160,6 +160,103 @@ 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(Context *ctx, atomic_size_t key) +{ + struct EmscriptenPlatformData *platform = ctx->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 = enif_make_resource(erl_nif_env_from_context(ctx), rsrc_obj); + enif_release_resource(rsrc_obj); + return obj; +} + +// clang-format off +EM_JS(uint32_t *, js_tracked_eval, (const char *code, uint32_t *size, bool debug), { + const keys = Module['onRunTrackedJs'](UTF8ToString(code), debug); + const error = keys === null; + if (error) { + HEAPU32[size / HEAPU32.BYTES_PER_ELEMENT] = 0; + return 0; + } + + const ptr = Module['_malloc'](keys.length * HEAPU32.BYTES_PER_ELEMENT); + HEAPU32[size / HEAPU32.BYTES_PER_ELEMENT] = keys.length; + HEAPU32.set(keys, ptr / HEAPU32.BYTES_PER_ELEMENT); + return ptr; +}); +// clang-format on + +static void do_run_script_tracked(const char *script, int32_t sync_caller_pid, GlobalContext *global) +{ +#ifdef NDEBUG + bool debug = false; +#else + bool debug = true; +#endif + uint32_t keys_n; + uint32_t *keys = js_tracked_eval(script, &keys_n, debug); + Context *target_ctx = globalcontext_get_process_lock(global, sync_caller_pid); + if (target_ctx) { + term result = term_invalid_term(); + term refs = term_nil(); + if (UNLIKELY(memory_ensure_free_opt(target_ctx, TUPLE_SIZE(2) + LIST_SIZE(keys_n, TERM_BOXED_REFC_BINARY_SIZE), MEMORY_CAN_SHRINK) != MEMORY_GC_OK)) { + // TODO: how to raise? + result = OUT_OF_MEMORY_ATOM; + goto send_result; + } + result = term_alloc_tuple(2, &target_ctx->heap); + + if (IS_NULL_PTR(keys)) { + term_put_tuple_element(result, 0, ERROR_ATOM); + term_put_tuple_element(result, 1, BADARG_ATOM); + goto send_result; + } + + if (keys_n == 0) { + term_put_tuple_element(result, 0, OK_ATOM); + term_put_tuple_element(result, 1, term_nil()); + goto send_result; + } + + for (long i = keys_n - 1; i >= 0; --i) { + term tracked_object = term_tracked_object_from_key(target_ctx, keys[i]); + // we can't easily recover from OOM here + assert(!term_is_invalid_term(tracked_object)); + refs = term_list_prepend(tracked_object, refs, &target_ctx->heap); + } + term_put_tuple_element(result, 0, OK_ATOM); + term_put_tuple_element(result, 1, refs); + + send_result: + free(keys); + mailbox_send_term_signal(target_ctx, TrapAnswerSignal, result); + globalcontext_get_process_unlock(global, target_ctx); + } else { + // sender died + free(keys); + } +} + +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(); +} + static const struct Nif atomvm_platform_nif = { .base.type = NIFFunctionType, .nif_ptr = nif_atomvm_platform @@ -180,6 +277,10 @@ 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 bool get_callback_target(Context *ctx, term t, const char **target, char **str) { @@ -795,6 +896,9 @@ 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("promise_resolve/1", nifname) == 0) { return &emscripten_promise_resolve_nif; } From 135be833d747ddef2a4f34c3f68815c134246cae Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Sun, 21 Sep 2025 11:11:24 +0300 Subject: [PATCH 05/17] wasm: Add get_tracked Signed-off-by: Jakub Gonet --- .../src/lib/platform_defaultatoms.def | 1 + .../emscripten/src/lib/platform_nifs.c | 194 ++++++++++++++++++ 2 files changed, 195 insertions(+) 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 75ecc73c9f..e9610dbb5f 100644 --- a/src/platforms/emscripten/src/lib/platform_nifs.c +++ b/src/platforms/emscripten/src/lib/platform_nifs.c @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -257,6 +258,192 @@ static term nif_emscripten_run_script_tracked(Context *ctx, int argc, term argv[ return term_invalid_term(); } +// 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 *strings_n, uint32_t *all_byte_size), { + const OK = 0; + const BAD_KEY = 1; + const NOT_STRING = 2; + + const keysOffset = keys_ptr / HEAPU32.BYTES_PER_ELEMENT; + const keys = [...HEAPU32.subarray(keysOffset, keysOffset + keys_n)]; + + const objects = Module['onGetTrackedObjects'](keys); + if (!Array.isArray(objects)) { + return 0; + } + const n = objects.length; + + if (n === 0) { + return 0; + } + + const sizesPtr = Module['_malloc'](n * HEAPU32.BYTES_PER_ELEMENT); + const statusPtr = Module['_malloc'](n * HEAPU8.BYTES_PER_ELEMENT); + let allByteSize = 0; + let stringsN = 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); + stringsN += 1; + } + HEAPU8[statusPtr / HEAPU8.BYTES_PER_ELEMENT + i] = status; + HEAPU32[sizesPtr / HEAPU32.BYTES_PER_ELEMENT + i] = byteSize; + allByteSize += byteSize; + } + + const stringsPtr = Module['_malloc'](allByteSize * HEAPU8.BYTES_PER_ELEMENT); + 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[strings_n / HEAPU32.BYTES_PER_ELEMENT] = stringsN; + HEAPU32[all_byte_size / HEAPU32.BYTES_PER_ELEMENT] = allByteSize; + + return stringsPtr; +}); +// clang-format on + +static void do_get_tracked_objects(uint32_t *ref_keys, size_t keys_n, int32_t sync_caller_pid, GlobalContext *global) +{ + static const uint8_t OK = 0; + static const uint8_t BAD_KEY = 1; + static const uint8_t NOT_STRING = 2; + UNUSED(BAD_KEY); + + uint32_t objects_n = 0; + uint32_t strings_n = 0; + uint32_t all_byte_size = 0; + uint32_t *sizes = NULL; + uint8_t *statuses = NULL; + + char *strings = js_get_tracked_objects(ref_keys, keys_n, &sizes, &statuses, &objects_n, &strings_n, &all_byte_size); + assert(strings_n <= objects_n); + Context *target_ctx = globalcontext_get_process_lock(global, sync_caller_pid); + if (target_ctx) { + term result = term_invalid_term(); + if (IS_NULL_PTR(strings)) { + result = BADVALUE_ATOM; + goto send_result; + } + size_t size = LIST_SIZE(objects_n, TUPLE_SIZE(2)) + LIST_SIZE(strings_n, BINARY_HEADER_SIZE) + term_binary_data_size_in_terms(all_byte_size); + if (UNLIKELY(memory_ensure_free_opt(target_ctx, size, MEMORY_CAN_SHRINK) != MEMORY_GC_OK)) { + result = OUT_OF_MEMORY_ATOM; + goto send_result; + } + + // move pointer to one byte past buffer + const char *current_string = strings + all_byte_size; + result = term_nil(); + for (long i = objects_n - 1; i >= 0; --i) { + uint8_t status = statuses[i]; + + term tuple = term_alloc_tuple(2, &target_ctx->heap); + if (status == OK) { + size_t size = sizes[i]; + current_string -= size; + + term binary = term_create_uninitialized_binary(size, &target_ctx->heap, global); + 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 == NOT_STRING) { + term_put_tuple_element(tuple, 0, ERROR_ATOM); + term_put_tuple_element(tuple, 1, BADVALUE_ATOM); + } else /* BAD_KEY */ { + term_put_tuple_element(tuple, 0, ERROR_ATOM); + term_put_tuple_element(tuple, 1, BADKEY_ATOM); + } + result = term_list_prepend(tuple, result, &target_ctx->heap); + } + + send_result: + free(sizes); + free(statuses); + free(strings); + mailbox_send_term_signal(target_ctx, TrapAnswerSignal, result); + globalcontext_get_process_unlock(global, target_ctx); + } // else: sender died +} + +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); + } + + if (n == 0) { + RAISE_ERROR(BADARG_ATOM); + } + + int32_t *ref_keys = malloc(n * sizeof(int32_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, 1), MEMORY_CAN_SHRINK) != MEMORY_GC_OK)) { + RAISE_ERROR(OUT_OF_MEMORY_ATOM); + } + + term keys = term_nil(); + for (long i = n - 1; i >= 0; --i) { + keys = term_list_prepend(term_from_int32(ref_keys[i]), keys, &ctx->heap); + } + return keys; + } + assert(type == VALUE_ATOM); + // Trap caller waiting for completion + context_update_flags(ctx, ~NoFlags, Trap); + emscripten_dispatch_to_thread(emscripten_main_runtime_thread_id(), EM_FUNC_SIG_VIIII, do_get_tracked_objects, NULL, 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 @@ -281,6 +468,10 @@ 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) { @@ -899,6 +1090,9 @@ const struct Nif *platform_nifs_get_nif(const char *nifname) 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; } From 2d06f23296e26cd8b25f258ed83f25d16805be46 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 28 Jul 2026 08:32:50 +0000 Subject: [PATCH 06/17] Doc: Add avm_emscripten to generated docs The lib was extracted from eavmlib without updating the doc pipeline, so emscripten/websocket edoc was never rendered. Also fix the three links still pointing at the module's old eavmlib location. Signed-off-by: Davide Bettio --- doc/CMakeLists.txt | 1 + doc/src/api-reference-documentation.rst | 10 ++++++++++ doc/src/atomvm-internals.md | 4 ++-- 3 files changed, 13 insertions(+), 2 deletions(-) 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..7c94ac80af 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,7 +457,7 @@ 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. From 43a5a05166e89fc7a4ca791a52222f1389964090 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 28 Jul 2026 08:33:04 +0000 Subject: [PATCH 07/17] wasm: Remove unneeded emscripten link options EMULATE_FUNCTION_POINTER_CASTS makes the JIT trap at runtime, and nothing here uses cwrap, stringToNewUTF8 or FS. Signed-off-by: Davide Bettio --- src/platforms/emscripten/src/CMakeLists.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/platforms/emscripten/src/CMakeLists.txt b/src/platforms/emscripten/src/CMakeLists.txt index 78d15bb5e6..42059d3907 100644 --- a/src/platforms/emscripten/src/CMakeLists.txt +++ b/src/platforms/emscripten/src/CMakeLists.txt @@ -36,12 +36,12 @@ if (NOT AVM_DISABLE_JIT) # JIT requires addFunction to register dynamically compiled WASM functions set(JIT_LINK_FLAGS -sALLOW_TABLE_GROWTH - "-sEXPORTED_RUNTIME_METHODS=['ccall','addFunction','ENV','cwrap','stringToNewUTF8','FS']" + "-sEXPORTED_RUNTIME_METHODS=['ccall','addFunction','ENV']" ) else() - set(JIT_LINK_FLAGS "-sEXPORTED_RUNTIME_METHODS=['ccall','ENV','cwrap','stringToNewUTF8','FS']") + set(JIT_LINK_FLAGS "-sEXPORTED_RUNTIME_METHODS=['ccall','ENV']") endif() -target_link_options(AtomVM PRIVATE ${JIT_LINK_FLAGS} -sEXPORTED_FUNCTIONS=_malloc,_cast,_call,_next_tracked_object_key,_main -sEMULATE_FUNCTION_POINTER_CASTS=1 -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,_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) From 1e8a03ebe97cdc7d5085280e805d2d76ec6249a8 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 28 Jul 2026 08:33:20 +0000 Subject: [PATCH 08/17] wasm: Fix the default tracked values hooks - the result validation read an out of scope variable, so a script evaluating to an array threw and left the caller waiting forever - sparse array holes were tracked as key 0 - keys came back from ccall signed, while the VM read them unsigned - a throw while mapping the result left values owned by nobody - the hook stored a value under the sentinel key that means the key space is exhausted, so the VM had to undo it Signed-off-by: Davide Bettio --- src/platforms/emscripten/src/atomvm.pre.js | 57 +++++++++++++++------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/src/platforms/emscripten/src/atomvm.pre.js b/src/platforms/emscripten/src/atomvm.pre.js index af0b150472..6e6144c609 100644 --- a/src/platforms/emscripten/src/atomvm.pre.js +++ b/src/platforms/emscripten/src/atomvm.pre.js @@ -30,7 +30,8 @@ Module["call"] = async function (name, message) { return promiseMap.get(promiseId).promise; }; Module["nextTrackedObjectKey"] = function () { - return ccall("next_tracked_object_key", "integer", [], []); + // 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) => { @@ -40,10 +41,17 @@ 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; }; @@ -51,24 +59,37 @@ Module["onRunTrackedJs"] = (scriptString, isDebug) => { try { const indirectEval = eval; result = indirectEval(scriptString); - } catch (_e) { + } catch (e) { + isDebug && console.error("onRunTrackedJs: evaluated script threw", e); return null; } - isDebug && ensureValidResult(result); - return result?.map(trackValue) ?? []; -}; - -function ensureValidResult(result) { - const isIndex = (k) => typeof k === "number"; - - if (result === null) { - return; + if (result === null || result === undefined) { + return []; } - if (Array.isArray(result) && keys.every(isIndex)) { - return; + if (!Array.isArray(result)) { + isDebug && + console.error( + "onRunTrackedJs: script must evaluate to an array, null or undefined; got", + result, + ); + return null; } - - const message = - "Evaluated script returned invalid value. Expected number array or null"; - throw new Error(message); -} + 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; + } +}; From aa603e4882af39eb0c15a9fd1a60de1572cf9c71 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 28 Jul 2026 08:33:50 +0000 Subject: [PATCH 09/17] wasm: Fix the tracked values NIFs - the answer was built on the caller's heap from the browser main thread, which is not a scheduler thread, and posted from there too - the answer heap was sized for a single binary, so fetching several values overflowed it - the strings buffer was one byte too small for its last NUL - ref_keys and the fetched buffers leaked - allocation failures wrote through address zero, aborted the VM or dereferenced NULL, instead of raising out_of_memory - a throwing hook escaped the proxied call, leaving the caller trapped forever - keys coming from a hook were narrowed to uint32_t unchecked - keys that never became a handle stayed in the map - the key counter wrapped and handed out keys that live handles own - the exported counter dereferenced the null global context when called before main - keys and statuses had a different type in every layer Signed-off-by: Davide Bettio --- src/platforms/emscripten/src/CMakeLists.txt | 2 +- .../emscripten/src/lib/emscripten_sys.h | 25 +- .../emscripten/src/lib/platform_nifs.c | 544 +++++++++++++----- src/platforms/emscripten/src/lib/sys.c | 79 ++- src/platforms/emscripten/src/main.c | 8 +- 5 files changed, 490 insertions(+), 168 deletions(-) diff --git a/src/platforms/emscripten/src/CMakeLists.txt b/src/platforms/emscripten/src/CMakeLists.txt index 42059d3907..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} -sEXPORTED_FUNCTIONS=_malloc,_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) +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/lib/emscripten_sys.h b/src/platforms/emscripten/src/lib/emscripten_sys.h index 8e396e9305..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,9 +111,23 @@ 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 { - int32_t key; + uint32_t key; }; struct EmscriptenPlatformData @@ -118,7 +135,7 @@ struct EmscriptenPlatformData pthread_mutex_t poll_mutex; pthread_cond_t poll_cond; struct ListHead messages; - atomic_size_t next_tracked_object_key; + _Atomic uint32_t next_tracked_object_key; ErlNifResourceType *promise_resource_type; ErlNifResourceType *htmlevent_user_data_resource_type; ErlNifResourceType *websocket_resource_type; @@ -141,7 +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); -size_t sys_get_next_tracked_object_key(GlobalContext *glb); +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_nifs.c b/src/platforms/emscripten/src/lib/platform_nifs.c index e9610dbb5f..9a8dc39179 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 @@ -161,84 +162,219 @@ 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(Context *ctx, atomic_size_t key) +static term term_tracked_object_from_key(GlobalContext *global, uint32_t key, Heap *heap) { - struct EmscriptenPlatformData *platform = ctx->global->platform_data; + 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 = enif_make_resource(erl_nif_env_from_context(ctx), rsrc_obj); + 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, bool debug), { - const keys = Module['onRunTrackedJs'](UTF8ToString(code), debug); - const error = keys === null; - if (error) { - HEAPU32[size / HEAPU32.BYTES_PER_ELEMENT] = 0; +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; } - - const ptr = Module['_malloc'](keys.length * HEAPU32.BYTES_PER_ELEMENT); - HEAPU32[size / HEAPU32.BYTES_PER_ELEMENT] = keys.length; - HEAPU32.set(keys, ptr / HEAPU32.BYTES_PER_ELEMENT); - return ptr; -}); +}) // clang-format on -static void do_run_script_tracked(const char *script, int32_t sync_caller_pid, GlobalContext *global) +// 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; - uint32_t *keys = js_tracked_eval(script, &keys_n, debug); - Context *target_ctx = globalcontext_get_process_lock(global, sync_caller_pid); - if (target_ctx) { - term result = term_invalid_term(); - term refs = term_nil(); - if (UNLIKELY(memory_ensure_free_opt(target_ctx, TUPLE_SIZE(2) + LIST_SIZE(keys_n, TERM_BOXED_REFC_BINARY_SIZE), MEMORY_CAN_SHRINK) != MEMORY_GC_OK)) { - // TODO: how to raise? - result = OUT_OF_MEMORY_ATOM; - goto send_result; - } - result = term_alloc_tuple(2, &target_ctx->heap); + 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 (IS_NULL_PTR(keys)) { - term_put_tuple_element(result, 0, ERROR_ATOM); - term_put_tuple_element(result, 1, BADARG_ATOM); - goto send_result; - } + if (UNLIKELY(eval_status == TRACKED_CALL_OOM)) { + send_tracked_trap_oom(global, process_id); + return; + } - if (keys_n == 0) { - term_put_tuple_element(result, 0, OK_ATOM); - term_put_tuple_element(result, 1, term_nil()); - goto send_result; - } + 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(target_ctx, keys[i]); - // we can't easily recover from OOM here - assert(!term_is_invalid_term(tracked_object)); - refs = term_list_prepend(tracked_object, refs, &target_ctx->heap); + 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); - - send_result: - free(keys); - mailbox_send_term_signal(target_ctx, TrapAnswerSignal, result); - globalcontext_get_process_unlock(global, target_ctx); - } else { - // sender died - free(keys); } + 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[]) @@ -258,133 +394,224 @@ static term nif_emscripten_run_script_tracked(Context *ctx, int argc, term argv[ 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 *strings_n, uint32_t *all_byte_size), { +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; - - const keysOffset = keys_ptr / HEAPU32.BYTES_PER_ELEMENT; - const keys = [...HEAPU32.subarray(keysOffset, keysOffset + keys_n)]; - - const objects = Module['onGetTrackedObjects'](keys); - if (!Array.isArray(objects)) { - return 0; - } - const n = objects.length; - - if (n === 0) { - return 0; - } - - const sizesPtr = Module['_malloc'](n * HEAPU32.BYTES_PER_ELEMENT); - const statusPtr = Module['_malloc'](n * HEAPU8.BYTES_PER_ELEMENT); - let allByteSize = 0; - let stringsN = 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); - stringsN += 1; + // 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]; } - HEAPU8[statusPtr / HEAPU8.BYTES_PER_ELEMENT + i] = status; - HEAPU32[sizesPtr / HEAPU32.BYTES_PER_ELEMENT + i] = byteSize; - allByteSize += byteSize; - } - const stringsPtr = Module['_malloc'](allByteSize * HEAPU8.BYTES_PER_ELEMENT); - let currentStringsPtr = stringsPtr; - for (let i = 0; i < n; ++i) { - const status = HEAPU8[statusPtr / HEAPU8.BYTES_PER_ELEMENT + i]; - if (status !== OK) { - continue; + 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; } - 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[strings_n / HEAPU32.BYTES_PER_ELEMENT] = stringsN; - HEAPU32[all_byte_size / HEAPU32.BYTES_PER_ELEMENT] = allByteSize; + // 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; + } - return stringsPtr; -}); + 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 sync_caller_pid, GlobalContext *global) +static void do_get_tracked_objects(uint32_t *ref_keys, size_t keys_n, int32_t process_id, GlobalContext *global) { - static const uint8_t OK = 0; - static const uint8_t BAD_KEY = 1; - static const uint8_t NOT_STRING = 2; - UNUSED(BAD_KEY); - uint32_t objects_n = 0; - uint32_t strings_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, &strings_n, &all_byte_size); - assert(strings_n <= objects_n); - Context *target_ctx = globalcontext_get_process_lock(global, sync_caller_pid); - if (target_ctx) { - term result = term_invalid_term(); - if (IS_NULL_PTR(strings)) { - result = BADVALUE_ATOM; - goto send_result; - } - size_t size = LIST_SIZE(objects_n, TUPLE_SIZE(2)) + LIST_SIZE(strings_n, BINARY_HEADER_SIZE) + term_binary_data_size_in_terms(all_byte_size); - if (UNLIKELY(memory_ensure_free_opt(target_ctx, size, MEMORY_CAN_SHRINK) != MEMORY_GC_OK)) { - result = OUT_OF_MEMORY_ATOM; - goto send_result; - } + char *strings = js_get_tracked_objects(ref_keys, keys_n, &sizes, &statuses, &objects_n, &all_byte_size, &get_status); - // move pointer to one byte past buffer - const char *current_string = strings + all_byte_size; - result = term_nil(); - for (long i = objects_n - 1; i >= 0; --i) { - uint8_t status = statuses[i]; - - term tuple = term_alloc_tuple(2, &target_ctx->heap); - if (status == OK) { - size_t size = sizes[i]; - current_string -= size; - - term binary = term_create_uninitialized_binary(size, &target_ctx->heap, global); - 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 == NOT_STRING) { - term_put_tuple_element(tuple, 0, ERROR_ATOM); - term_put_tuple_element(tuple, 1, BADVALUE_ATOM); - } else /* BAD_KEY */ { - term_put_tuple_element(tuple, 0, ERROR_ATOM); - term_put_tuple_element(tuple, 1, BADKEY_ATOM); - } - result = term_list_prepend(tuple, result, &target_ctx->heap); + if (IS_NULL_PTR(strings)) { + free(sizes); + free(statuses); + if (UNLIKELY(get_status == TRACKED_CALL_OOM)) { + send_tracked_trap_oom(global, process_id); + return; } + // FIXME: a whole-call failure yields the bare atom badvalue, + // inconsistent with the {error, badvalue} shape of the per-element + // results. Documented as an unstable return in emscripten.erl. + sys_enqueue_emscripten_tracked_answer_message(global, process_id, BADVALUE_ATOM, NULL); + return; + } - send_result: + 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); - mailbox_send_term_signal(target_ctx, TrapAnswerSignal, result); - globalcontext_get_process_unlock(global, target_ctx); - } // else: sender died + 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[]) @@ -409,7 +636,7 @@ static term nif_emscripten_get_tracked(Context *ctx, int argc, term argv[]) RAISE_ERROR(BADARG_ATOM); } - int32_t *ref_keys = malloc(n * sizeof(int32_t)); + uint32_t *ref_keys = malloc(n * sizeof(uint32_t)); if (IS_NULL_PTR(ref_keys)) { RAISE_ERROR(OUT_OF_MEMORY_ATOM); } @@ -427,20 +654,23 @@ static term nif_emscripten_get_tracked(Context *ctx, int argc, term argv[]) } if (type == KEY_ATOM) { - if (UNLIKELY(memory_ensure_free_opt(ctx, LIST_SIZE(n, 1), MEMORY_CAN_SHRINK) != MEMORY_GC_OK)) { + 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_from_int32(ref_keys[i]), keys, &ctx->heap); + 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); - emscripten_dispatch_to_thread(emscripten_main_runtime_thread_id(), EM_FUNC_SIG_VIIII, do_get_tracked_objects, NULL, ref_keys, n, ctx->process_id, ctx->global); + // 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(); } diff --git a/src/platforms/emscripten/src/lib/sys.c b/src/platforms/emscripten/src/lib/sys.c index 15f76f619e..0c7a6c9777 100644 --- a/src/platforms/emscripten/src/lib/sys.c +++ b/src/platforms/emscripten/src/lib/sys.c @@ -48,10 +48,18 @@ #include "platform_defaultatoms.h" #include "websocket_nifs.h" -size_t sys_get_next_tracked_object_key(GlobalContext *glb) +uint32_t sys_get_next_tracked_object_key(GlobalContext *glb) { struct EmscriptenPlatformData *platform = glb->platform_data; - return platform->next_tracked_object_key++; + // 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; } /** @@ -136,9 +144,22 @@ static void htmlevent_user_data_down(ErlNifEnv *caller_env, void *obj, ErlNifPid } } -static void do_remove_tracked_object(atomic_size_t key) +void sys_remove_tracked_object(uint32_t key) { - EM_ASM({ Module['onTrackedObjectDelete']($0); }, 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) @@ -146,7 +167,7 @@ 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, do_remove_tracked_object, NULL, tracked_object_rsrc->key); + 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 = { @@ -338,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 @@ -558,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 b0cf7fedc7..b3a96428e5 100644 --- a/src/platforms/emscripten/src/main.c +++ b/src/platforms/emscripten/src/main.c @@ -140,11 +140,15 @@ em_promise_t call(const char *name, const char *message) /** * @brief Gets a number representing TrackedObject identity. - * @return a TrackedObject id. + * @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 -size_t next_tracked_object_key() +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); } From 40cccb3bbba0c8c7765a572e6d88bbf6446c7db1 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 28 Jul 2026 08:34:31 +0000 Subject: [PATCH 10/17] wasm: Document the tracked JS values API Add the Erlang stubs with their specs and edoc, and an internals section on the flow, the value lifetime and the hook contract. Signed-off-by: Davide Bettio --- doc/src/atomvm-internals.md | 69 ++++++++++++++++ libs/avm_emscripten/src/emscripten.erl | 108 +++++++++++++++++++++++++ 2 files changed, 177 insertions(+) diff --git a/doc/src/atomvm-internals.md b/doc/src/atomvm-internals.md index 7c94ac80af..ced3143a5a 100644 --- a/doc/src/atomvm-internals.md +++ b/doc/src/atomvm-internals.md @@ -464,3 +464,72 @@ Call allows Javascript code to wait for the result and is based on Javascript pr 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 +(non-empty) 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. + +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 fails the whole call) | +| `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/libs/avm_emscripten/src/emscripten.erl b/libs/avm_emscripten/src/emscripten.erl index f0dfadab3c..1c9c3f85e8 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,102 @@ 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. +%% +%% Raises `badarg' if the first argument is not a proper, non-empty 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. +%% +%% Note: with `value', if the `Module.onGetTrackedObjects' hook violates +%% its contract (throws, or does not return one entry per requested key), +%% the current implementation returns the bare atom `badvalue'; this +%% abnormal return is not part of the stable contract and may become an +%% exception in a future release. +%% @param _TrackedObjects Proper, non-empty 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()] | badvalue. +get_tracked(_TrackedObjects, _Type) -> + erlang:nif_error(undefined). + %% @equiv promise_resolve(_Promise, 0) -spec promise_resolve(promise()) -> ok. promise_resolve(_Promise) -> From fbaaaf95bf0a7338521552fb79a1ade7560ae4ef Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 28 Jul 2026 08:34:31 +0000 Subject: [PATCH 11/17] wasm: Add tests for tracked JS values Add an extensive test suite for the tracked values API. It is web-only like the other emscripten tests: these NIFs dispatch to the browser main thread, which is blocked in main() under node. Signed-off-by: Davide Bettio --- .../cypress/e2e/run_script_tracked.spec.cy.js | 38 ++ .../emscripten/tests/src/CMakeLists.txt | 2 + .../tests/src/test_run_script_tracked.erl | 446 ++++++++++++++++++ .../tests/src/test_run_script_tracked.html | 38 ++ 4 files changed, 524 insertions(+) create mode 100644 src/platforms/emscripten/tests/cypress/e2e/run_script_tracked.spec.cy.js create mode 100644 src/platforms/emscripten/tests/src/test_run_script_tracked.erl create mode 100644 src/platforms/emscripten/tests/src/test_run_script_tracked.html 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..1cb3c861bf --- /dev/null +++ b/src/platforms/emscripten/tests/cypress/e2e/run_script_tracked.spec.cy.js @@ -0,0 +1,38 @@ +/* + * 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"); + }); + + 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/src/CMakeLists.txt b/src/platforms/emscripten/tests/src/CMakeLists.txt index 1965f8779f..120260b925 100644 --- a/src/platforms/emscripten/tests/src/CMakeLists.txt +++ b/src/platforms/emscripten/tests/src/CMakeLists.txt @@ -32,11 +32,13 @@ endfunction() compile_erlang(test_atomvm) compile_erlang(test_call) compile_erlang(test_html5) +compile_erlang(test_run_script_tracked) 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_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..a97ce2aa29 --- /dev/null +++ b/src/platforms/emscripten/tests/src/test_run_script_tracked.erl @@ -0,0 +1,446 @@ +% +% 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_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_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([], 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. + +% 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] + ), + 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] + ), + 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] + ), + 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] + ). + +% Handles dropped on the Erlang side must disappear from the JS map once +% garbage collected, while kept handles must survive; asserted by cypress +% through window.gcBaseline and the final map contents. Only the final +% state can be asserted: the VM may collect dropped handles at any +% allocation point, not just at explicit erlang:garbage_collect() calls. +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 garbage collection: create them +% in their own stack frame and drop them 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..22908c4a64 --- /dev/null +++ b/src/platforms/emscripten/tests/src/test_run_script_tracked.html @@ -0,0 +1,38 @@ + + + + + + + +
N/A
+ + + From ca70d1d3c957280004a1986b853295fadbab42fd Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Mon, 27 Jul 2026 18:09:11 +0000 Subject: [PATCH 12/17] wasm: Change get_tracked to answer with a list A fetch hook that threw, or returned the wrong number of entries, was answered with the bare atom badvalue. Callers map over the result, so that atom reaches them as a term their list cannot hold, and popcorn gets there with its own hook: it serializes with JSON.stringify, which throws on a circular value or a BigInt. Tell them the same thing in the shape they asked for. The hook said nothing about any single key, so every element is {error, badvalue}, an outcome callers already handle for a value that is not a string. Signed-off-by: Davide Bettio --- doc/src/atomvm-internals.md | 2 +- libs/avm_emscripten/src/emscripten.erl | 11 +++++------ .../emscripten/src/lib/platform_nifs.c | 19 +++++++++++++++---- .../tests/src/test_run_script_tracked.erl | 6 +++--- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/doc/src/atomvm-internals.md b/doc/src/atomvm-internals.md index ced3143a5a..92b441e3aa 100644 --- a/doc/src/atomvm-internals.md +++ b/doc/src/atomvm-internals.md @@ -512,7 +512,7 @@ AtomVM: | `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 fails the whole call) | +| `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 diff --git a/libs/avm_emscripten/src/emscripten.erl b/libs/avm_emscripten/src/emscripten.erl index 1c9c3f85e8..35fec828e4 100644 --- a/libs/avm_emscripten/src/emscripten.erl +++ b/libs/avm_emscripten/src/emscripten.erl @@ -395,18 +395,17 @@ run_script_tracked(_Script) -> %% and `out_of_memory' if the VM runs out of memory while building the %% result, on either side of the JavaScript boundary. %% -%% Note: with `value', if the `Module.onGetTrackedObjects' hook violates -%% its contract (throws, or does not return one entry per requested key), -%% the current implementation returns the bare atom `badvalue'; this -%% abnormal return is not part of the stable contract and may become an -%% exception in a future release. +%% 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, non-empty 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()] | badvalue. + (_TrackedObjects :: [tracked_object(), ...], value) -> [get_tracked_result()]. get_tracked(_TrackedObjects, _Type) -> erlang:nif_error(undefined). diff --git a/src/platforms/emscripten/src/lib/platform_nifs.c b/src/platforms/emscripten/src/lib/platform_nifs.c index 9a8dc39179..932c916cc1 100644 --- a/src/platforms/emscripten/src/lib/platform_nifs.c +++ b/src/platforms/emscripten/src/lib/platform_nifs.c @@ -546,10 +546,21 @@ static void do_get_tracked_objects(uint32_t *ref_keys, size_t keys_n, int32_t pr send_tracked_trap_oom(global, process_id); return; } - // FIXME: a whole-call failure yields the bare atom badvalue, - // inconsistent with the {error, badvalue} shape of the per-element - // results. Documented as an unstable return in emscripten.erl. - sys_enqueue_emscripten_tracked_answer_message(global, process_id, BADVALUE_ATOM, NULL); + // 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; } diff --git a/src/platforms/emscripten/tests/src/test_run_script_tracked.erl b/src/platforms/emscripten/tests/src/test_run_script_tracked.erl index a97ce2aa29..8cb1b528a3 100644 --- a/src/platforms/emscripten/tests/src/test_run_script_tracked.erl +++ b/src/platforms/emscripten/tests/src/test_run_script_tracked.erl @@ -170,7 +170,7 @@ test_throwing_get_hook_is_whole_call_error(Ra) -> >>, [main_thread] ), - badvalue = emscripten:get_tracked([Ra], value), + [{error, badvalue}] = emscripten:get_tracked([Ra], value), ok = restore_get_hook(), [{ok, <<"keep">>}] = emscripten:get_tracked([Ra], value), ok. @@ -183,7 +183,7 @@ test_wrong_length_get_hook_is_whole_call_error(Ra) -> >>, [main_thread] ), - badvalue = emscripten:get_tracked([Ra], value), + [{error, badvalue}] = emscripten:get_tracked([Ra], value), ok = restore_get_hook(), ok. @@ -203,7 +203,7 @@ test_getter_throwing_get_hook_is_whole_call_error(Ra) -> >>, [main_thread] ), - badvalue = emscripten:get_tracked([Ra], value), + [{error, badvalue}] = emscripten:get_tracked([Ra], value), ok = restore_get_hook(), [{ok, <<"keep">>}] = emscripten:get_tracked([Ra], value), ok. From b4915580c387956370c6b5c5768591b28abe238e Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Mon, 27 Jul 2026 18:11:17 +0000 Subject: [PATCH 13/17] wasm: Make get_tracked accept an empty list run_script_tracked/1 answers {ok, []} for a script that tracked nothing, and get_tracked/2 then refused that very list, so callers had to special case it before passing it on. Answer with an empty list instead. It costs the value arm nothing, since there is no key to ask the main thread about. Signed-off-by: Davide Bettio --- doc/src/atomvm-internals.md | 6 ++++-- libs/avm_emscripten/src/emscripten.erl | 18 +++++++++++------- .../emscripten/src/lib/platform_nifs.c | 4 +++- .../tests/src/test_run_script_tracked.erl | 9 ++++++++- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/doc/src/atomvm-internals.md b/doc/src/atomvm-internals.md index 92b441e3aa..092e6b08ea 100644 --- a/doc/src/atomvm-internals.md +++ b/doc/src/atomvm-internals.md @@ -494,14 +494,16 @@ behind. Values that a hook tracks under keys it does not return are invisible to stay its own responsibility. [`emscripten:get_tracked/2`](./apidocs/erlang/avm_emscripten/emscripten.md#get_tracked2) maps a -(non-empty) list of handles back to JavaScript: with `key`, it returns the integer keys +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. +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 diff --git a/libs/avm_emscripten/src/emscripten.erl b/libs/avm_emscripten/src/emscripten.erl index 35fec828e4..5283ae836e 100644 --- a/libs/avm_emscripten/src/emscripten.erl +++ b/libs/avm_emscripten/src/emscripten.erl @@ -390,22 +390,26 @@ run_script_tracked(_Script) -> %% default hooks a stored `undefined' value is indistinguishable from a %% missing key). Results are in the same order as the input list. %% -%% Raises `badarg' if the first argument is not a proper, non-empty 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. +%% 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, non-empty list of tracked object handles +%% @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()]. + (_TrackedObjects :: [tracked_object()], key) -> [non_neg_integer()]; + (_TrackedObjects :: [tracked_object()], value) -> [get_tracked_result()]. get_tracked(_TrackedObjects, _Type) -> erlang:nif_error(undefined). diff --git a/src/platforms/emscripten/src/lib/platform_nifs.c b/src/platforms/emscripten/src/lib/platform_nifs.c index 932c916cc1..2fec61f571 100644 --- a/src/platforms/emscripten/src/lib/platform_nifs.c +++ b/src/platforms/emscripten/src/lib/platform_nifs.c @@ -643,8 +643,10 @@ static term nif_emscripten_get_tracked(Context *ctx, int argc, term argv[]) 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) { - RAISE_ERROR(BADARG_ATOM); + return term_nil(); } uint32_t *ref_keys = malloc(n * sizeof(uint32_t)); diff --git a/src/platforms/emscripten/tests/src/test_run_script_tracked.erl b/src/platforms/emscripten/tests/src/test_run_script_tracked.erl index 8cb1b528a3..bbd55ce3ef 100644 --- a/src/platforms/emscripten/tests/src/test_run_script_tracked.erl +++ b/src/platforms/emscripten/tests/src/test_run_script_tracked.erl @@ -40,6 +40,7 @@ start() -> 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), @@ -155,12 +156,18 @@ test_deleted_key_is_badkey() -> 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([], 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( From e2a4867aa6fba035d1e4171827da5b242199e71d Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 28 Jul 2026 08:34:57 +0000 Subject: [PATCH 14/17] wasm: Add more tracked values tests Extend the suite to handles crossing processes, a caller killed mid-call, concurrency, edge inputs and scale, plus a module that drives the whole API through overridden hooks. Signed-off-by: Davide Bettio --- .../e2e/tracked_hook_overrides.spec.cy.js | 38 +++ .../emscripten/tests/src/CMakeLists.txt | 2 + .../tests/src/test_run_script_tracked.erl | 200 +++++++++++++++- .../tests/src/test_tracked_hook_overrides.erl | 218 ++++++++++++++++++ .../src/test_tracked_hook_overrides.html | 113 +++++++++ 5 files changed, 564 insertions(+), 7 deletions(-) create mode 100644 src/platforms/emscripten/tests/cypress/e2e/tracked_hook_overrides.spec.cy.js create mode 100644 src/platforms/emscripten/tests/src/test_tracked_hook_overrides.erl create mode 100644 src/platforms/emscripten/tests/src/test_tracked_hook_overrides.html 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..2445a6e5c2 --- /dev/null +++ b/src/platforms/emscripten/tests/cypress/e2e/tracked_hook_overrides.spec.cy.js @@ -0,0 +1,38 @@ +/* + * 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"); + }); + + 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 120260b925..2e9bdbd6ce 100644 --- a/src/platforms/emscripten/tests/src/CMakeLists.txt +++ b/src/platforms/emscripten/tests/src/CMakeLists.txt @@ -33,6 +33,7 @@ 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 @@ -40,5 +41,6 @@ add_custom_target(emscripten_erlang_test_modules DEPENDS 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 index bbd55ce3ef..cd93d4f127 100644 --- a/src/platforms/emscripten/tests/src/test_run_script_tracked.erl +++ b/src/platforms/emscripten/tests/src/test_run_script_tracked.erl @@ -49,6 +49,17 @@ start() -> 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 @@ -381,11 +392,186 @@ restore_get_hook() -> [main_thread] ). -% Handles dropped on the Erlang side must disappear from the JS map once -% garbage collected, while kept handles must survive; asserted by cypress -% through window.gcBaseline and the final map contents. Only the final -% state can be asserted: the VM may collect dropped handles at any -% allocation point, not just at explicit erlang:garbage_collect() calls. +% 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( @@ -396,8 +582,8 @@ test_garbage_collection_deletes_values() -> erlang:garbage_collect(), ok. -% The handles must go out of scope before garbage collection: create them -% in their own stack frame and drop them on return. +% 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. 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..81168e4a78 --- /dev/null +++ b/src/platforms/emscripten/tests/src/test_tracked_hook_overrides.html @@ -0,0 +1,113 @@ + + + + + + + +
N/A
+ + + From c5355689415e3ab1129e096764d8ccb397ba6d51 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 28 Jul 2026 08:35:07 +0000 Subject: [PATCH 15/17] wasm: Make the web tests run against the JIT The workflow built the JIT flavor and threw it away. Keep both builds and run the tracked specs against each. A JIT build compiles every module at startup, so the pages take their argument list from a query parameter and load the bundle carrying the wasm32 backend. The JIT leg cannot run the example specs, whose runnables are packed without a backend. Signed-off-by: Davide Bettio --- .github/workflows/wasm-build.yaml | 45 ++++++++++++++----- .../cypress/e2e/run_script_tracked.spec.cy.js | 5 ++- .../e2e/tracked_hook_overrides.spec.cy.js | 5 ++- .../tests/src/test_run_script_tracked.html | 10 ++++- .../src/test_tracked_hook_overrides.html | 10 ++++- 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/.github/workflows/wasm-build.yaml b/.github/workflows/wasm-build.yaml index c5b75101a8..6cc32ebfa5 100644 --- a/.github/workflows/wasm-build.yaml +++ b/.github/workflows/wasm-build.yaml @@ -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/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 index 1cb3c861bf..c43a16954f 100644 --- 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 @@ -19,7 +19,10 @@ */ describe("run_script_tracked", () => { beforeEach(() => { - cy.visit("/tests/src/test_run_script_tracked.html"); + cy.visit( + "/tests/src/test_run_script_tracked.html" + + (Cypress.env("JIT") ? "#jit" : ""), + ); }); it("should track, fetch and garbage collect JS values", () => { 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 index 2445a6e5c2..f3cd93fc2e 100644 --- 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 @@ -19,7 +19,10 @@ */ describe("tracked hook overrides", () => { beforeEach(() => { - cy.visit("/tests/src/test_tracked_hook_overrides.html"); + cy.visit( + "/tests/src/test_tracked_hook_overrides.html" + + (Cypress.env("JIT") ? "#jit" : ""), + ); }); it("should drive the tracked API through overridden hooks", () => { diff --git a/src/platforms/emscripten/tests/src/test_run_script_tracked.html b/src/platforms/emscripten/tests/src/test_run_script_tracked.html index 22908c4a64..a77c09e624 100644 --- a/src/platforms/emscripten/tests/src/test_run_script_tracked.html +++ b/src/platforms/emscripten/tests/src/test_run_script_tracked.html @@ -30,8 +30,16 @@ // Arguments are loaded using fetch API. // wasm_webserver serves under /tests/build/ files in src/platform/escripten/build/tests/src subdirectory // and under /build/ files in build/ subdirectory. + // A JIT build has no interpreter, so it needs the bundle carrying the + // wasm32 backend rather than the individual libraries. The flavor travels + // in the fragment because wasm_webserver would take a query string as part + // of the file name to serve. + const jit = window.location.hash === "#jit"; + const libraries = jit + ? ['/build/libs/atomvmlib-emscripten-wasm32.avm'] + : ['/build/libs/eavmlib/src/eavmlib.avm', '/build/libs/avm_emscripten/src/avm_emscripten.avm', '/build/libs/estdlib/src/estdlib.avm']; window.Module = await AtomVM({ - arguments: ['/tests/build/test_run_script_tracked.beam', '/build/libs/eavmlib/src/eavmlib.avm', '/build/libs/avm_emscripten/src/avm_emscripten.avm', '/build/libs/estdlib/src/estdlib.avm'] + arguments: ['/tests/build/test_run_script_tracked.beam', ...libraries] }); diff --git a/src/platforms/emscripten/tests/src/test_tracked_hook_overrides.html b/src/platforms/emscripten/tests/src/test_tracked_hook_overrides.html index 81168e4a78..c07ce74b02 100644 --- a/src/platforms/emscripten/tests/src/test_tracked_hook_overrides.html +++ b/src/platforms/emscripten/tests/src/test_tracked_hook_overrides.html @@ -43,8 +43,16 @@ // Arguments are loaded using fetch API. // wasm_webserver serves under /tests/build/ files in src/platform/escripten/build/tests/src subdirectory // and under /build/ files in build/ subdirectory. + // A JIT build has no interpreter, so it needs the bundle carrying the + // wasm32 backend rather than the individual libraries. The flavor travels + // in the fragment because wasm_webserver would take a query string as part + // of the file name to serve. + const jit = window.location.hash === "#jit"; + const libraries = jit + ? ['/build/libs/atomvmlib-emscripten-wasm32.avm'] + : ['/build/libs/eavmlib/src/eavmlib.avm', '/build/libs/avm_emscripten/src/avm_emscripten.avm', '/build/libs/estdlib/src/estdlib.avm']; const module = await AtomVM({ - arguments: ['/tests/build/test_tracked_hook_overrides.beam', '/build/libs/eavmlib/src/eavmlib.avm', '/build/libs/avm_emscripten/src/avm_emscripten.avm', '/build/libs/estdlib/src/estdlib.avm'] + arguments: ['/tests/build/test_tracked_hook_overrides.beam', ...libraries] }); window.Module = module; From b0c8bbae49eceab6917e5e1be2ff19c51f7993c6 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 28 Jul 2026 08:35:07 +0000 Subject: [PATCH 16/17] wasm: Add a tracked values example The example tracks a DOM node, reaches it again through its key, reads a property back, and lets a dropped handle take its JavaScript value with it. Signed-off-by: Davide Bettio --- .github/workflows/wasm-build.yaml | 2 +- examples/emscripten/CMakeLists.txt | 1 + examples/emscripten/index.html | 1 + examples/emscripten/tracked_values.erl | 91 +++++++++++++++++++++++++ examples/emscripten/tracked_values.html | 57 ++++++++++++++++ 5 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 examples/emscripten/tracked_values.erl create mode 100644 examples/emscripten/tracked_values.html diff --git a/.github/workflows/wasm-build.yaml b/.github/workflows/wasm-build.yaml index 6cc32ebfa5..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 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 +
    + +
      + + + + From 0895eb078d49e0690474d5ffa9e96c92f89c3250 Mon Sep 17 00:00:00 2001 From: Davide Bettio Date: Tue, 28 Jul 2026 08:35:18 +0000 Subject: [PATCH 17/17] wasm: Add the tracked values API to the changelog Signed-off-by: Davide Bettio --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) 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: