From 1f2161a160fbd57067aa621bd0a886faab896be1 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Mon, 29 Jun 2026 13:52:41 +0200 Subject: [PATCH 1/3] otp: add tracked values --- otp/js/e2e/helpers.ts | 3 +- otp/js/e2e/otp.spec.ts | 33 +++--- otp/js/src/beam.ts | 80 ++++++++++++--- otp/js/src/events.ts | 84 ++++++++++------ otp/js/src/popcorn.ts | 40 ++++++-- otp/js/src/types.ts | 10 +- otp/js/src/worker.ts | 5 + otp/patches/0001-emscripten-support.patch | 116 +++++++++++++++++----- 8 files changed, 277 insertions(+), 94 deletions(-) diff --git a/otp/js/e2e/helpers.ts b/otp/js/e2e/helpers.ts index 9b531a39..900258ce 100644 --- a/otp/js/e2e/helpers.ts +++ b/otp/js/e2e/helpers.ts @@ -11,6 +11,7 @@ import type { PopcornOpts, PopcornEvent, PopcornSendOpts, + BeamTarget, SerializedError, } from "@swmansion/popcorn-otp"; @@ -74,7 +75,7 @@ export class Otp { } public async send( - target: string, + target: string | BeamTarget, payload?: unknown, opts?: PopcornSendOpts, ): Promise { diff --git a/otp/js/e2e/otp.spec.ts b/otp/js/e2e/otp.spec.ts index a1df3428..7ed6a7e6 100644 --- a/otp/js/e2e/otp.spec.ts +++ b/otp/js/e2e/otp.spec.ts @@ -211,43 +211,44 @@ test("handles events in both directions", async ({ otp }) => { }); }); -test("run_js evaluates code on the main thread", async ({ otp, page }) => { - // run_js is fire-and-forget: it emits no onEvent and returns nothing, so we - // poll the side effect the eval'd code leaves on the main-thread window. - const RUN_JS_BOOT_EVAL = 'wasm:run_js(<<"window.popcorn = {js: true}">>).'; +test("run_js returns the snippet result to the caller", async ({ otp }) => { + const RUN_JS_BOOT_EVAL = + 'V = wasm:run_js(<<"(args) => 1 + 2">>, #{}), ' + + "ok = wasm:send(#{run_js_result => V})."; const boot = await otp.boot({ beam: { assetsUrl: "/otp-assets", extraArgs: ["-eval", RUN_JS_BOOT_EVAL] }, }); assert(boot.ok); - await page.waitForFunction("window.popcorn?.js === true"); + await otp.waitForEvent("run_js_result"); + expect(otp.events).toContainEqual({ run_js_result: 3 }); }); -test("run_js runs an async snippet", async ({ otp, page }) => { +test("run_js passes args and awaits an async snippet", async ({ otp }) => { const RUN_JS_BOOT_EVAL = - 'wasm:run_js(<<"(async () => { window.popcorn = {async: true}; })()">>).'; + 'V = wasm:run_js(<<"async ({a, b}) => a + b">>, #{a => 2, b => 5}), ' + + "ok = wasm:send(#{run_js_async => V})."; const boot = await otp.boot({ beam: { assetsUrl: "/otp-assets", extraArgs: ["-eval", RUN_JS_BOOT_EVAL] }, }); assert(boot.ok); - await page.waitForFunction("window.popcorn?.async === true"); + await otp.waitForEvent("run_js_async"); + expect(otp.events).toContainEqual({ run_js_async: 7 }); }); -test("a throwing run_js snippet doesn't stop later ones from running", async ({ - otp, - page, -}) => { - // The first eval throws; the second must still run and leave its side effect. +test("a throwing run_js snippet raises in the caller", async ({ otp }) => { const RUN_JS_BOOT_EVAL = - "wasm:run_js(<<\"throw new Error('boom')\">>), " + - 'wasm:run_js(<<"window.popcorn = {throwing: true}">>).'; + "R = try wasm:run_js(<<\"() => { throw new Error('boom') }\">>, #{}) " + + "catch error:{run_js, Msg} -> Msg end, " + + "ok = wasm:send(#{run_js_error => R})."; const boot = await otp.boot({ beam: { assetsUrl: "/otp-assets", extraArgs: ["-eval", RUN_JS_BOOT_EVAL] }, }); assert(boot.ok); - await page.waitForFunction("window.popcorn?.throwing === true"); + await otp.waitForEvent("run_js_error"); + expect(otp.events).toContainEqual({ run_js_error: "Error: boom" }); }); test("popcorn.send() reports unregistered process", async ({ otp }) => { diff --git a/otp/js/src/beam.ts b/otp/js/src/beam.ts index 3aba7b4a..b4845e49 100644 --- a/otp/js/src/beam.ts +++ b/otp/js/src/beam.ts @@ -4,6 +4,7 @@ import { extractTar } from "./tar"; import type { BeamBootOptions, BeamSendPayload, + BeamTarget, EmscriptenModule, } from "./types"; import { @@ -13,6 +14,7 @@ import { fetchBinary, fetchJson, isGzip, + unreachable, } from "./utils"; const DEFAULT_USER = "web_user"; @@ -213,13 +215,43 @@ export function send( return { ok: false, error: err("bridge:not-started", {}) }; } + let targetName: string; + let target: PreparedTarget; + if (isNameTarget(message.target)) { + targetName = message.target.name; + target = { + kind: TARGET_REGISTERED_NAME, + argType: "string", + value: targetName, + length: utf8Length(targetName), + }; + } else { + targetName = message.target.pid; + const bytes = base64ToBytes(targetName); + target = { + kind: TARGET_PID_BYTES, + argType: "array", + value: bytes, + length: bytes.length, + }; + } + const status = module.ccall( "sendVmMessage", "number", - ["string", "number", "string", "number", "string", "number"], [ - message.targetName, - utf8Length(message.targetName), + "number", + target.argType, + "number", + "string", + "number", + "string", + "number", + ], + [ + target.kind, + target.value, + target.length, message.payloadJson, utf8Length(message.payloadJson), message.metaJson, @@ -234,20 +266,44 @@ export function send( if (status === 1) { return { ok: false, - error: err("bridge:listener-not-found", { - targetName: message.targetName, - }), + error: err("bridge:listener-not-found", { targetName }), }; } + unreachable(); +} - return { - ok: false, - error: err("internal:check", { - detail: `Unexpected sendVmMessage status: ${String(status)}`, - }), - }; +const TARGET_REGISTERED_NAME = 0; +const TARGET_PID_BYTES = 1; + +type PreparedTarget = + | { + kind: typeof TARGET_REGISTERED_NAME; + argType: "string"; + value: string; + length: number; + } + | { + kind: typeof TARGET_PID_BYTES; + argType: "array"; + value: Uint8Array; + length: number; + }; + +function isNameTarget( + target: BeamTarget, +): target is Extract { + return Object.hasOwn(target, "name"); } function utf8Length(text: string): number { return UTF8.encode(text).length; } + +function base64ToBytes(b64: string): Uint8Array { + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} diff --git a/otp/js/src/events.ts b/otp/js/src/events.ts index 7e293da3..e0bb2f90 100644 --- a/otp/js/src/events.ts +++ b/otp/js/src/events.ts @@ -1,11 +1,12 @@ -import { err, type Result, type SerializedError } from "./errors"; +import { type Result, type SerializedError } from "./errors"; import type { AnyValue, BeamBootOptions, BeamEvent, BeamSendPayload, + BeamTarget, } from "./types"; -import { objectWithKeys } from "./utils"; +import { check, objectWithKeys } from "./utils"; type BootEvent = { type: "popcorn:boot"; @@ -17,6 +18,15 @@ type SendEvent = { payload: SendRequestPayload; }; +export type RunJsReplyPayload = { + message: BeamSendPayload; +}; + +type RunJsReplyEvent = { + type: "popcorn:run-js-reply"; + payload: RunJsReplyPayload; +}; + export type SendRequestPayload = { id: string; message: BeamSendPayload; @@ -40,7 +50,7 @@ type BootEndEvent = | { type: "popcorn:boot-end"; payload: {} } | { type: "popcorn:boot-fail"; payload: SerializedError }; -export type MainToVmEvent = BootEvent | SendEvent; +export type MainToVmEvent = BootEvent | SendEvent | RunJsReplyEvent; export type PopcornEvent = AnyValue; @@ -60,6 +70,8 @@ type BridgeEnvelope = | { type: "run_js"; code: string; + args: AnyValue; + reply_to: string; }; export function readMainEvent(value: unknown): MainToVmEvent | null { @@ -71,6 +83,7 @@ export function readMainEvent(value: unknown): MainToVmEvent | null { switch (data.type) { case "popcorn:boot": case "popcorn:send": + case "popcorn:run-js-reply": return data as MainToVmEvent; default: return null; @@ -99,34 +112,38 @@ export function readWorkerEvent(value: unknown): VmToMainEvent | null { } export function serializeSendPayload( - targetName: string, + target: BeamTarget, payload: AnyValue, meta: AnyValue, ): Result { - if (targetName.length === 0) { - return { ok: false, error: err("bridge:invalid-target", {}) }; + if (isNameTarget(target)) { + check(target.name.length > 0); + } else { + check(target.pid.length > 0); } - try { - return { - ok: true, - data: { - targetName, - payloadJson: serializeJson(payload), - metaJson: serializeJson(meta), - }, - }; - } catch { - const error = err("bridge:unserializable", {}); - return { ok: false, error }; - } + return { + ok: true, + data: { + target, + payloadJson: serializeJson(payload), + metaJson: serializeJson(meta), + }, + }; +} + +function isNameTarget( + target: BeamTarget, +): target is Extract { + return Object.hasOwn(target, "name"); } export function deserializeBridgeMessage( text: string, -): - | Extract - | null { +): Extract< + BeamEvent, + { type: "otp:message" | "otp:error" | "otp:run_js" } +> | null { try { const parsed = JSON.parse(text) as unknown; if (!isBridgeEnvelope(parsed)) return null; @@ -137,13 +154,17 @@ export function deserializeBridgeMessage( case "vm_error": return { type: "otp:error", + payload: { kind: "error", data: parsed.data }, + }; + case "run_js": + return { + type: "otp:run_js", payload: { - kind: "error", - data: parsed.data, + code: parsed.code, + args: parsed.args, + replyTo: parsed.reply_to, }, }; - case "run_js": - return { type: "otp:run_js", payload: { code: parsed.code } }; default: return null; } @@ -163,16 +184,13 @@ export function toMain(event: VmToMainEvent): void { } function isBridgeEnvelope(value: unknown): value is BridgeEnvelope { + const KNOWN_MESSAGE_TYPES: unknown[] = ["vm_message", "vm_error", "run_js"]; const data = objectWithKeys(value, ["type"]); - return ( - data !== null && - (data.type === "vm_message" || - data.type === "vm_error" || - data.type === "run_js") - ); + return data !== null && KNOWN_MESSAGE_TYPES.includes(data.type); } function serializeJson(value: AnyValue): string { const serialized = JSON.stringify(value); - return serialized === undefined ? "null" : serialized; + check(serialized !== undefined); + return serialized; } diff --git a/otp/js/src/popcorn.ts b/otp/js/src/popcorn.ts index 3c4ecc36..ab298d26 100644 --- a/otp/js/src/popcorn.ts +++ b/otp/js/src/popcorn.ts @@ -6,7 +6,13 @@ import { type PopcornEvent, type SendCompletionPayload, } from "./events"; -import type { AnyValue, BeamBootOptions, OtpErrorPayload } from "./types"; +import type { + AnyValue, + BeamBootOptions, + BeamTarget, + OtpErrorPayload, + RunJsRequest, +} from "./types"; import { check, unreachable } from "./utils"; export type PopcornOpts = { @@ -43,6 +49,11 @@ type PopcornState = | { status: "booted" } | { status: "closed"; error: PopcornError<"vm:exited"> }; type PendingSend = (result: Result) => void; +type RunJsFn = (args: AnyValue) => AnyValue; + +function assertRunJsFn(value: unknown): asserts value is RunJsFn { + check(typeof value === "function"); +} export class Popcorn { private vmWorker!: Worker; @@ -64,7 +75,7 @@ export class Popcorn { this.emit(data.payload); return; case "otp:run_js": - this.runJs(data.payload.code); + this.runJs(data.payload); return; case "otp:stdout": console.log(`${LOG_PREFIX} stdout:`, data.payload); @@ -184,7 +195,7 @@ export class Popcorn { * Resolves after VM sent message to registered process. */ public async send( - target: string, + target: string | BeamTarget, payload?: AnyValue, opts?: PopcornSendOpts, ): Promise> { @@ -196,7 +207,7 @@ export class Popcorn { } const command = serializeSendPayload( - target, + typeof target === "string" ? { name: target } : target, payload ?? {}, opts?.meta ?? {}, ); @@ -260,9 +271,24 @@ export class Popcorn { } } - private runJs(code: string): void { - // Main thread; supports sync or async code, errors are thrown. - indirectEval(code); + private async runJs(request: RunJsRequest): Promise { + let payload: AnyValue; + try { + const fn = indirectEval(request.code); + assertRunJsFn(fn); + const result = await fn(request.args); + payload = { ok: true, value: result ?? null }; + } catch (error) { + check(error instanceof Error); + payload = { ok: false, error: error.toString() }; + } + + const command = serializeSendPayload({ pid: request.replyTo }, payload, {}); + check(command.ok); + toVm(this.vmWorker, { + type: "popcorn:run-js-reply", + payload: { message: command.data }, + }); } private completeSend(payload: SendCompletionPayload): void { diff --git a/otp/js/src/types.ts b/otp/js/src/types.ts index 835bd316..6cee30ad 100644 --- a/otp/js/src/types.ts +++ b/otp/js/src/types.ts @@ -5,7 +5,7 @@ export type AnyValue = unknown; export type BeamTarget = { name: string } | { pid: string }; export type BeamSendPayload = { - targetName: string; + target: BeamTarget; payloadJson: string; metaJson: string; }; @@ -23,7 +23,13 @@ export type BeamEvent = | { type: "otp:stderr"; payload: string } | { type: "otp:error"; payload: OtpErrorPayload } | { type: "otp:message"; payload: AnyValue } - | { type: "otp:run_js"; payload: { code: string } }; + | { type: "otp:run_js"; payload: RunJsRequest }; + +export type RunJsRequest = { + code: string; + args: AnyValue; + replyTo: string; +}; export type OtpErrorPayload = | { kind: "abort"; data: string } diff --git a/otp/js/src/worker.ts b/otp/js/src/worker.ts index 73e29b2f..9dd43d65 100644 --- a/otp/js/src/worker.ts +++ b/otp/js/src/worker.ts @@ -47,6 +47,11 @@ self.onmessage = async (event: MessageEvent) => { }); break; } + case "popcorn:run-js-reply": { + // ignore the `send()` result, process could've died + send(instance, data.payload.message); + break; + } default: unreachable(); } diff --git a/otp/patches/0001-emscripten-support.patch b/otp/patches/0001-emscripten-support.patch index 338a2001..9a51cfaf 100644 --- a/otp/patches/0001-emscripten-support.patch +++ b/otp/patches/0001-emscripten-support.patch @@ -183,10 +183,10 @@ index 0000000000000000000000000000000000000000..0b446eedf17294c0c47c2b872bf2fb44 +}); diff --git a/erts/emulator/nifs/common/wasm_nif.c b/erts/emulator/nifs/common/wasm_nif.c new file mode 100644 -index 0000000000000000000000000000000000000000..b6bce2ecfc556414033534076d9d85867ee0d0ff +index 0000000000000000000000000000000000000000..2b786c77d12f0716646938b60fdcbc4b91408d52 --- /dev/null +++ b/erts/emulator/nifs/common/wasm_nif.c -@@ -0,0 +1,528 @@ +@@ -0,0 +1,580 @@ +/* + * %CopyrightBegin% + * @@ -216,6 +216,7 @@ index 0000000000000000000000000000000000000000..b6bce2ecfc556414033534076d9d8586 +#include "sys.h" + +#include ++#include +#include +#include +#include @@ -229,6 +230,11 @@ index 0000000000000000000000000000000000000000..b6bce2ecfc556414033534076d9d8586 + WASM_LISTENER_NAME_REGISTERED = 1, +} wasm_listener_name_kind_t; + ++typedef enum { ++ WASM_TARGET_REGISTERED_NAME = 0, ++ WASM_TARGET_PID_BYTES = 1, ++} wasm_target_kind_t; ++ +typedef struct wasm_state_s wasm_state_t; + +typedef struct wasm_listener_s { @@ -281,7 +287,7 @@ index 0000000000000000000000000000000000000000..b6bce2ecfc556414033534076d9d8586 +static wasm_state_t *erts_wasm_state = NULL; + +#ifdef ERTS_EMSCRIPTEN -+int sendVmMessage(const char *target_name, int target_name_len, ++int sendVmMessage(int target_kind, const char *target_name, int target_name_len, + const char *payload_json, int payload_len, + const char *meta_json, int meta_len); +#endif @@ -649,16 +655,48 @@ index 0000000000000000000000000000000000000000..b6bce2ecfc556414033534076d9d8586 +} + +#ifdef ERTS_EMSCRIPTEN ++static bool decode_pid_target(ErlNifEnv *env, const char *target_name, ++ int target_name_len, ErlNifPid *pid, ++ bool *has_token, ERL_NIF_TERM *token) { ++ ERL_NIF_TERM target_term; ++ int arity; ++ const ERL_NIF_TERM *elems; ++ ++ *has_token = false; ++ ++ if (enif_binary_to_term(env, (const unsigned char *)target_name, ++ (size_t)target_name_len, &target_term, ++ ERL_NIF_BIN2TERM_SAFE) == 0) { ++ return false; ++ } ++ ++ if (enif_get_local_pid(env, target_term, pid)) { ++ return true; ++ } ++ ++ if (enif_get_tuple(env, target_term, &arity, &elems) && arity == 2 && ++ enif_get_local_pid(env, elems[0], pid)) { ++ *has_token = true; ++ *token = elems[1]; ++ return true; ++ } ++ ++ return false; ++} ++ +EMSCRIPTEN_KEEPALIVE -+int sendVmMessage(const char *target_name, int target_name_len, ++int sendVmMessage(int target_kind, const char *target_name, int target_name_len, + const char *payload_json, int payload_len, + const char *meta_json, int meta_len) { + ErlNifEnv *env; + ERL_NIF_TERM atom; + ErlNifPid pid; + ERL_NIF_TERM payload_term; ++ ERL_NIF_TERM message_payload; + ERL_NIF_TERM meta_term; + ERL_NIF_TERM message; ++ ERL_NIF_TERM token; ++ bool has_token = false; + + if (target_name == NULL || target_name_len <= 0 || payload_len < 0 || + meta_len < 0 || (payload_json == NULL && payload_len > 0) || @@ -671,17 +709,28 @@ index 0000000000000000000000000000000000000000..b6bce2ecfc556414033534076d9d8586 + return 2; + } + -+ /* -+ * Resolve the target by its registered name at send time. The -+ * existing-atom lookup keeps arbitrary JS strings from growing the atom -+ * table, and enif_whereis_pid piggybacks on erlang:register/2 so any -+ * registered process is addressable without opting in. -+ */ -+ if (!enif_make_existing_atom_len(env, target_name, (size_t)target_name_len, -+ &atom, ERL_NIF_UTF8) || -+ !enif_whereis_pid(env, atom, &pid)) { ++ if (target_kind == WASM_TARGET_PID_BYTES) { ++ if (!decode_pid_target(env, target_name, target_name_len, &pid, &has_token, ++ &token)) { ++ enif_free_env(env); ++ return 1; ++ } ++ } else if (target_kind == WASM_TARGET_REGISTERED_NAME) { ++ /* ++ * Resolve the target by its registered name at send time. The ++ * existing-atom lookup keeps arbitrary JS strings from growing the atom ++ * table, and enif_whereis_pid piggybacks on erlang:register/2 so any ++ * registered process is addressable without opting in. ++ */ ++ if (!enif_make_existing_atom_len(env, target_name, (size_t)target_name_len, ++ &atom, ERL_NIF_UTF8) || ++ !enif_whereis_pid(env, atom, &pid)) { ++ enif_free_env(env); ++ return 1; ++ } ++ } else { + enif_free_env(env); -+ return 1; ++ return 2; + } + + if (!make_binary_copy(env, payload_json != NULL ? payload_json : "", @@ -692,7 +741,9 @@ index 0000000000000000000000000000000000000000..b6bce2ecfc556414033534076d9d8586 + return 2; + } + -+ message = enif_make_tuple3(env, am_wasm, payload_term, meta_term); ++ message_payload = ++ has_token ? enif_make_tuple2(env, token, payload_term) : payload_term; ++ message = enif_make_tuple3(env, am_wasm, message_payload, meta_term); + + if (!enif_send(NULL, &pid, env, message)) { + enif_free_env(env); @@ -703,9 +754,10 @@ index 0000000000000000000000000000000000000000..b6bce2ecfc556414033534076d9d8586 + return 0; +} +#else -+int sendVmMessage(const char *target_name, int target_name_len, ++int sendVmMessage(int target_kind, const char *target_name, int target_name_len, + const char *payload_json, int payload_len, + const char *meta_json, int meta_len) { ++ (void)target_kind; + (void)target_name; + (void)target_name_len; + (void)payload_json; @@ -894,10 +946,10 @@ index 3b672dc41a554cf7d5440ccfd94e85bd44b61d35..4e5efb04e7eb04556ff14dc3ba8e0d83 ]}, diff --git a/erts/preloaded/src/wasm.erl b/erts/preloaded/src/wasm.erl new file mode 100644 -index 0000000000000000000000000000000000000000..cdc153b8b2a72525f044c8586c6893b399095f08 +index 0000000000000000000000000000000000000000..e6996fe9345bff0e934b8e0bf7f9515c18123b15 --- /dev/null +++ b/erts/preloaded/src/wasm.erl -@@ -0,0 +1,46 @@ +@@ -0,0 +1,64 @@ +%% +%% %CopyrightBegin% +%% @@ -924,19 +976,37 @@ index 0000000000000000000000000000000000000000..cdc153b8b2a72525f044c8586c6893b3 + +%% on_load/0 is called explicitly from erl_init:start/2; do NOT use the +%% -on_load attribute (a preloaded module with one aborts the VM at boot). -+-export([on_load/0, send/1, run_js/1]). ++-export([on_load/0, send/1, run_js/2]). + +-nifs([send_raw/1]). + ++-define(RUN_JS_TIMEOUT, 5000). ++ +-spec send(json:encode_value()) -> ok. +send(Message) -> + Envelope = #{type => vm_message, data => Message}, + ok = send_raw(iolist_to_binary(json:encode(Envelope))). + -+-spec run_js(binary()) -> ok. -+run_js(Code) when is_binary(Code) -> -+ Envelope = #{type => run_js, code => Code}, -+ ok = send_raw(iolist_to_binary(json:encode(Envelope))). ++%% Run a JS function expression on the main browser thread and wait for its reply. ++-spec run_js(binary(), map()) -> json:decode_value(). ++run_js(Code, Args) when is_binary(Code), is_map(Args) -> ++ Token = erlang:unique_integer(), ++ ReplyTo = base64:encode(term_to_binary({self(), Token})), ++ Envelope = #{type => run_js, code => Code, args => Args, reply_to => ReplyTo}, ++ ok = send_raw(iolist_to_binary(json:encode(Envelope))), ++ receive ++ {wasm, {Token, PayloadJson}, _MetaJson} -> ++ handle_run_js_reply(PayloadJson) ++ after ?RUN_JS_TIMEOUT -> ++ error(run_js_timeout) ++ end. ++ ++handle_run_js_reply(PayloadJson) -> ++ case json:decode(PayloadJson) of ++ #{<<"ok">> := true, <<"value">> := Value} -> Value; ++ #{<<"ok">> := true} -> null; ++ #{<<"ok">> := false, <<"error">> := Reason} -> error({run_js, Reason}) ++ end. + +-spec on_load() -> ok. +on_load() -> From 1743a2a5f6a24205d3310dce0bfde0ae99d59a74 Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Tue, 30 Jun 2026 21:11:42 +0200 Subject: [PATCH 2/3] otp: simplify tests --- otp/js/e2e/helpers.ts | 61 +++++++++++++++++++-------- otp/js/e2e/otp.spec.ts | 93 +++++++++++++++++++++--------------------- 2 files changed, 91 insertions(+), 63 deletions(-) diff --git a/otp/js/e2e/helpers.ts b/otp/js/e2e/helpers.ts index 900258ce..6ff6a9b8 100644 --- a/otp/js/e2e/helpers.ts +++ b/otp/js/e2e/helpers.ts @@ -31,6 +31,26 @@ export type Result = | { ok: true; data: T } | { ok: false; error: SerializedError }; +export function trimLeft(text: string): string { + const leadingBlanks = /^(?:[ \t]*\n)+/; + const trailingBlanks = /(?:\n[ \t]*)+$/; + const trimmedText = text + .replace(leadingBlanks, "") + .replace(trailingBlanks, ""); + const lines = trimmedText.split("\n"); + const nonBlank = lines.filter((line) => line.trim() !== ""); + assert(nonBlank.length > 0); + + const indents = nonBlank.map((line) => { + const trimmedLine = line.trimStart(); + return line.length - trimmedLine.length; + }); + const indentN = Math.min(...indents); + + const trimmedLines = lines.map((line) => line.slice(indentN)); + return trimmedLines.join("\n"); +} + export class Otp { public readonly events = new Set(); @@ -60,12 +80,13 @@ export class Otp { }); }); - const result = await this.popcorn.evaluate(async (popcorn): Promise => { - const boot = await popcorn.boot(); - return boot.ok - ? { ok: true, data: null } - : { ok: false, error: boot.error.serialize() }; - }); + const result = await this.popcorn.evaluate( + async (popcorn): Promise => { + const boot = await popcorn.boot(); + if (boot.ok) return { ok: true, data: null }; + return { ok: false, error: boot.error.serialize() }; + }, + ); if (!result.ok) { await this.dispose(); @@ -83,11 +104,14 @@ export class Otp { return await popcorn.evaluate( async (instance, args) => { - const result = await instance.send(args.target, args.payload, args.opts); - - return result.ok - ? { ok: true, data: null } - : { ok: false, error: result.error.serialize() }; + const result = await instance.send( + args.target, + args.payload, + args.opts, + ); + + if (result.ok) return { ok: true, data: null }; + return { ok: false, error: result.error.serialize() }; }, { target, payload, opts }, ); @@ -137,7 +161,11 @@ export class Otp { this.events.add(event); for (const [name, waiters] of this.eventWaiters) { - if (hasKey(event, name)) { + if ( + typeof event === "object" && + event !== null && + Object.hasOwn(event, name) + ) { this.eventWaiters.delete(name); for (const resolve of waiters) { resolve(event); @@ -148,7 +176,11 @@ export class Otp { private findEvent(name: string): PopcornEvent | null { for (const event of this.events) { - if (hasKey(event, name)) { + if ( + typeof event === "object" && + event !== null && + Object.hasOwn(event, name) + ) { return event; } } @@ -157,9 +189,6 @@ export class Otp { } } -const hasKey = (event: PopcornEvent, key: string) => - typeof event === "object" && event !== null && key in event; - type Fixtures = { otp: Otp; }; diff --git a/otp/js/e2e/otp.spec.ts b/otp/js/e2e/otp.spec.ts index 7ed6a7e6..7123266c 100644 --- a/otp/js/e2e/otp.spec.ts +++ b/otp/js/e2e/otp.spec.ts @@ -1,4 +1,10 @@ -import { assert, expect, test } from "./helpers"; +import { assert, expect, test, trimLeft } from "./helpers"; + +function evalOpts(code: string) { + return { + beam: { assetsUrl: "/otp-assets", extraArgs: ["-eval", code] }, + }; +} test("boots", async ({ otp }) => { const result = await otp.boot({ beam: { assetsUrl: "/otp-assets" } }); @@ -163,30 +169,25 @@ test("failing boot fails fast (no timeout)", async ({ page }) => { }); test("handles events in both directions", async ({ otp }) => { - const BRIDGE_BOOT_EVAL = [ - "spawn(fun() ->", - " ok = wasm:send(#{direct => true, nested => #{count => 1}}),", - " true = register(bridge, self()),", - " Loop = fun(F) ->", - " ok = wasm:send(#{bridge_ready => true}),", - " receive", - " {wasm, PayloadJson, MetaJson} ->", - " Payload = json:decode(PayloadJson),", - " Meta = json:decode(MetaJson),", - " ok = wasm:send(#{reply => Payload, meta => Meta})", - " after 100 ->", - " F(F)", - " end", - " end,", - " Loop(Loop)", - "end).", - ].join(" "); - const boot = await otp.boot({ - beam: { - assetsUrl: "/otp-assets", - extraArgs: ["-eval", BRIDGE_BOOT_EVAL], - }, - }); + const BRIDGE_BOOT_EVAL = trimLeft(` + spawn(fun() -> + ok = wasm:send(#{direct => true, nested => #{count => 1}}), + true = register(bridge, self()), + Loop = fun(F) -> + ok = wasm:send(#{bridge_ready => true}), + receive + {wasm, PayloadJson, MetaJson} -> + Payload = json:decode(PayloadJson), + Meta = json:decode(MetaJson), + ok = wasm:send(#{reply => Payload, meta => Meta}) + after 100 -> + F(F) + end + end, + Loop(Loop) + end). + `); + const boot = await otp.boot(evalOpts(BRIDGE_BOOT_EVAL)); assert(boot.ok); await otp.waitForEvent("direct"); @@ -212,12 +213,11 @@ test("handles events in both directions", async ({ otp }) => { }); test("run_js returns the snippet result to the caller", async ({ otp }) => { - const RUN_JS_BOOT_EVAL = - 'V = wasm:run_js(<<"(args) => 1 + 2">>, #{}), ' + - "ok = wasm:send(#{run_js_result => V})."; - const boot = await otp.boot({ - beam: { assetsUrl: "/otp-assets", extraArgs: ["-eval", RUN_JS_BOOT_EVAL] }, - }); + const RUN_JS_BOOT_EVAL = trimLeft(` + V = wasm:run_js(<<"(args) => 1 + 2">>, #{}), + ok = wasm:send(#{run_js_result => V}). + `); + const boot = await otp.boot(evalOpts(RUN_JS_BOOT_EVAL)); assert(boot.ok); await otp.waitForEvent("run_js_result"); @@ -225,12 +225,11 @@ test("run_js returns the snippet result to the caller", async ({ otp }) => { }); test("run_js passes args and awaits an async snippet", async ({ otp }) => { - const RUN_JS_BOOT_EVAL = - 'V = wasm:run_js(<<"async ({a, b}) => a + b">>, #{a => 2, b => 5}), ' + - "ok = wasm:send(#{run_js_async => V})."; - const boot = await otp.boot({ - beam: { assetsUrl: "/otp-assets", extraArgs: ["-eval", RUN_JS_BOOT_EVAL] }, - }); + const RUN_JS_BOOT_EVAL = trimLeft(` + V = wasm:run_js(<<"async ({a, b}) => a + b">>, #{a => 2, b => 5}), + ok = wasm:send(#{run_js_async => V}). + `); + const boot = await otp.boot(evalOpts(RUN_JS_BOOT_EVAL)); assert(boot.ok); await otp.waitForEvent("run_js_async"); @@ -238,13 +237,15 @@ test("run_js passes args and awaits an async snippet", async ({ otp }) => { }); test("a throwing run_js snippet raises in the caller", async ({ otp }) => { - const RUN_JS_BOOT_EVAL = - "R = try wasm:run_js(<<\"() => { throw new Error('boom') }\">>, #{}) " + - "catch error:{run_js, Msg} -> Msg end, " + - "ok = wasm:send(#{run_js_error => R})."; - const boot = await otp.boot({ - beam: { assetsUrl: "/otp-assets", extraArgs: ["-eval", RUN_JS_BOOT_EVAL] }, - }); + const RUN_JS_BOOT_EVAL = trimLeft(` + R = try + wasm:run_js(<<"() => { throw new Error('boom') }">>, #{}) + catch + error:{run_js, Msg} -> Msg + end, + ok = wasm:send(#{run_js_error => R}). + `); + const boot = await otp.boot(evalOpts(RUN_JS_BOOT_EVAL)); assert(boot.ok); await otp.waitForEvent("run_js_error"); @@ -253,9 +254,7 @@ test("a throwing run_js snippet raises in the caller", async ({ otp }) => { test("popcorn.send() reports unregistered process", async ({ otp }) => { const READY_BOOT_EVAL = "ok = wasm:send(#{ready => true})."; - const boot = await otp.boot({ - beam: { assetsUrl: "/otp-assets", extraArgs: ["-eval", READY_BOOT_EVAL] }, - }); + const boot = await otp.boot(evalOpts(READY_BOOT_EVAL)); assert(boot.ok); await otp.waitForEvent("ready"); From bb1b2c4a0e8b86d86bcaf752eaddbc08d773c0ca Mon Sep 17 00:00:00 2001 From: Jakub Gonet Date: Tue, 30 Jun 2026 21:29:23 +0200 Subject: [PATCH 3/3] otp: allow customizing timeout --- otp/js/e2e/otp.spec.ts | 16 ++++++++++++++++ otp/patches/0001-emscripten-support.patch | 14 +++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/otp/js/e2e/otp.spec.ts b/otp/js/e2e/otp.spec.ts index 7123266c..7a8fd4d7 100644 --- a/otp/js/e2e/otp.spec.ts +++ b/otp/js/e2e/otp.spec.ts @@ -236,6 +236,22 @@ test("run_js passes args and awaits an async snippet", async ({ otp }) => { expect(otp.events).toContainEqual({ run_js_async: 7 }); }); +test("run_js accepts a custom timeout", async ({ otp }) => { + const RUN_JS_BOOT_EVAL = trimLeft(` + R = try + wasm:run_js(<<"() => new Promise(() => {})">>, #{}, [{timeout, 0}]) + catch + error:run_js_timeout -> <<"timeout">> + end, + ok = wasm:send(#{run_js_timeout => R}). + `); + const boot = await otp.boot(evalOpts(RUN_JS_BOOT_EVAL)); + assert(boot.ok); + + await otp.waitForEvent("run_js_timeout"); + expect(otp.events).toContainEqual({ run_js_timeout: "timeout" }); +}); + test("a throwing run_js snippet raises in the caller", async ({ otp }) => { const RUN_JS_BOOT_EVAL = trimLeft(` R = try diff --git a/otp/patches/0001-emscripten-support.patch b/otp/patches/0001-emscripten-support.patch index 9a51cfaf..03e095ad 100644 --- a/otp/patches/0001-emscripten-support.patch +++ b/otp/patches/0001-emscripten-support.patch @@ -946,10 +946,10 @@ index 3b672dc41a554cf7d5440ccfd94e85bd44b61d35..4e5efb04e7eb04556ff14dc3ba8e0d83 ]}, diff --git a/erts/preloaded/src/wasm.erl b/erts/preloaded/src/wasm.erl new file mode 100644 -index 0000000000000000000000000000000000000000..e6996fe9345bff0e934b8e0bf7f9515c18123b15 +index 0000000000000000000000000000000000000000..778a49d4b207159cd495116d0d548226777f43af --- /dev/null +++ b/erts/preloaded/src/wasm.erl -@@ -0,0 +1,64 @@ +@@ -0,0 +1,68 @@ +%% +%% %CopyrightBegin% +%% @@ -976,7 +976,7 @@ index 0000000000000000000000000000000000000000..e6996fe9345bff0e934b8e0bf7f9515c + +%% on_load/0 is called explicitly from erl_init:start/2; do NOT use the +%% -on_load attribute (a preloaded module with one aborts the VM at boot). -+-export([on_load/0, send/1, run_js/2]). ++-export([on_load/0, send/1, run_js/2, run_js/3]). + +-nifs([send_raw/1]). + @@ -987,9 +987,13 @@ index 0000000000000000000000000000000000000000..e6996fe9345bff0e934b8e0bf7f9515c + Envelope = #{type => vm_message, data => Message}, + ok = send_raw(iolist_to_binary(json:encode(Envelope))). + -+%% Run a JS function expression on the main browser thread and wait for its reply. +-spec run_js(binary(), map()) -> json:decode_value(). +run_js(Code, Args) when is_binary(Code), is_map(Args) -> ++ run_js(Code, Args, []). ++ ++-spec run_js(binary(), map(), proplists:proplist()) -> json:decode_value(). ++run_js(Code, Args, Opts) when is_binary(Code), is_map(Args), is_list(Opts) -> ++ Timeout = proplists:get_value(timeout, Opts, ?RUN_JS_TIMEOUT), + Token = erlang:unique_integer(), + ReplyTo = base64:encode(term_to_binary({self(), Token})), + Envelope = #{type => run_js, code => Code, args => Args, reply_to => ReplyTo}, @@ -997,7 +1001,7 @@ index 0000000000000000000000000000000000000000..e6996fe9345bff0e934b8e0bf7f9515c + receive + {wasm, {Token, PayloadJson}, _MetaJson} -> + handle_run_js_reply(PayloadJson) -+ after ?RUN_JS_TIMEOUT -> ++ after Timeout -> + error(run_js_timeout) + end. +