From 42a8bcfd6f6ffe14dc52868ebc414d48e1c6a88b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Fri, 11 Sep 2026 18:11:41 -0300 Subject: [PATCH] feat: MessagePort, MessageChannel, BroadcastChannel, and node:worker_threads Adds HTML's messaging primitives - MessagePort, MessageChannel, BroadcastChannel and MessageEvent, all lazy globals, so an app that never names one pays nothing - a node:worker_threads module, and Worker plus the worker global scope as real EventTargets. The native core is Node's node_messaging design without libuv: an isolate-free PortData (mutex-guarded queue, sibling-group entanglement) under a per-isolate NativeMessagePort whose wake primitive is a coalesced EventLoop::PostInternal, so a producer never takes a foreign isolate's Locker. Pairwise channels and named broadcast groups share one SiblingGroup mechanism; the pairwise-vs- broadcast close difference is a single guard, as in Node. Ports transfer through postMessage (Worker.postMessage included) and structuredClone as host-object tag 2: the index travels in the stream, the PortData out of band, nothing is detached until the whole graph has written, and received ports are constructed before ReadValue because no JS may run inside a read. A transferred port carries its queued backlog and drains after adoption on a later turn, per spec. worker.onmessage and the worker scope's onmessage are HTML event-handler IDL attributes now (defineEventHandler, position-fixed so a handler interleaves with addEventListener registrations), and delivery dispatches real MessageEvents with event.ports populated. A port starts on its first message listener; receiveMessageOnPort does forced synchronous drains. docs/worker-threads.md carries the full real-vs-shim table and every documented deviation. Fixed in passing: - The worker error path forwarded twice. A scope onerror that throws now replaces the error it was offered and reaches the parent once - in CallWorkerScopeOnErrorHandle, in the entry-rejection reporter and in the unhandled-rejection tracker alike - and a worker with no scope handler at all still reaches the parent instead of dropping the error. Parent-side delivery is a real cancelable ErrorEvent on the Worker EventTarget, so worker.addEventListener("error") works in registration order; handled means preventDefault() or a truthy onerror return. An error the Worker object leaves unhandled is dispatched on the parent's global scope per HTML, and logged if nothing handles it there. - AbortSignal#onabort moved onto the shared defineEventHandler helper. - EventLoop::Shutdown destroys the dropped lanes after releasing its mutex. A dropped message carrying a transferred port sentinels the port's sibling, which posts to that sibling's loop; when the sibling belonged to the isolate shutting down, the post re-entered the held, non-recursive mutex. - ConcurrentQueue::Terminate destroys dropped messages outside both locks and a push racing it is turned away under the queue mutex, so ports and buffers transferred to a worker terminated before its entry settled are released and their siblings told. Cross-runtime contract: the shared Workers suite pinned the double forward at 2 and expects 1 once Worker.prototype has an onmessage getter, which this change gives it. --- docs/README.md | 5 + docs/ns-builtin-modules.md | 53 +- docs/structured-clone.md | 14 +- docs/worker-threads.md | 266 +++++ eslint.config.mjs | 2 +- test-app/app/src/main/assets/app/mainpage.js | 6 + .../tests/messaging/parentPortOnceWorker.js | 8 + .../tests/messaging/parentPortPortsWorker.js | 4 + .../app/tests/messaging/parentPortWorker.js | 7 + .../app/tests/messaging/parkedWorker.mjs | 3 + .../app/tests/messaging/rejectingWorker.js | 4 + .../app/tests/messaging/throwingWorker.js | 1 + .../main/assets/app/tests/testMessaging.js | 313 +++++ .../app/tests/testRuntimeImplementedAPIs.js | 24 + test-app/runtime/CMakeLists.txt | 7 + .../runtime/src/main/cpp/CallbackHandlers.cpp | 7 +- .../runtime/src/main/cpp/ConcurrentQueue.cpp | 48 +- .../runtime/src/main/cpp/ConcurrentQueue.h | 6 +- test-app/runtime/src/main/cpp/EventLoop.cpp | 66 +- test-app/runtime/src/main/cpp/EventLoop.h | 5 + test-app/runtime/src/main/cpp/LazyGlobals.cpp | 5 + test-app/runtime/src/main/cpp/Messaging.cpp | 1057 +++++++++++++++++ test-app/runtime/src/main/cpp/Messaging.h | 216 ++++ .../src/main/cpp/NativeScriptException.cpp | 32 +- .../runtime/src/main/cpp/NsBuiltinModules.cpp | 6 + test-app/runtime/src/main/cpp/Runtime.cpp | 12 + .../src/main/cpp/StructuredSerialization.cpp | 359 +++++- .../src/main/cpp/StructuredSerialization.h | 62 +- .../runtime/src/main/cpp/WorkerEvents.cpp | 125 ++ test-app/runtime/src/main/cpp/WorkerEvents.h | 56 + .../runtime/src/main/cpp/WorkerWrapper.cpp | 173 +-- test-app/runtime/src/main/cpp/js/README.md | 27 +- .../runtime/src/main/cpp/js/abort-signal.js | 56 +- .../src/main/cpp/js/broadcast-channel.js | 119 ++ test-app/runtime/src/main/cpp/js/events.js | 189 ++- .../src/main/cpp/js/message-channel.js | 266 +++++ .../runtime/src/main/cpp/js/message-event.js | 153 +++ .../src/main/cpp/js/node-worker-threads.js | 355 ++++++ .../runtime/src/main/cpp/js/primordials.js | 11 + .../src/main/cpp/js/structured-clone.js | 24 +- .../runtime/src/main/cpp/js/worker-events.js | 102 ++ 41 files changed, 3948 insertions(+), 306 deletions(-) create mode 100644 docs/worker-threads.md create mode 100644 test-app/app/src/main/assets/app/tests/messaging/parentPortOnceWorker.js create mode 100644 test-app/app/src/main/assets/app/tests/messaging/parentPortPortsWorker.js create mode 100644 test-app/app/src/main/assets/app/tests/messaging/parentPortWorker.js create mode 100644 test-app/app/src/main/assets/app/tests/messaging/parkedWorker.mjs create mode 100644 test-app/app/src/main/assets/app/tests/messaging/rejectingWorker.js create mode 100644 test-app/app/src/main/assets/app/tests/messaging/throwingWorker.js create mode 100644 test-app/app/src/main/assets/app/tests/testMessaging.js create mode 100644 test-app/runtime/src/main/cpp/Messaging.cpp create mode 100644 test-app/runtime/src/main/cpp/Messaging.h create mode 100644 test-app/runtime/src/main/cpp/WorkerEvents.cpp create mode 100644 test-app/runtime/src/main/cpp/WorkerEvents.h create mode 100644 test-app/runtime/src/main/cpp/js/broadcast-channel.js create mode 100644 test-app/runtime/src/main/cpp/js/message-channel.js create mode 100644 test-app/runtime/src/main/cpp/js/message-event.js create mode 100644 test-app/runtime/src/main/cpp/js/node-worker-threads.js create mode 100644 test-app/runtime/src/main/cpp/js/worker-events.js diff --git a/docs/README.md b/docs/README.md index cd72dffbe..f36912fc9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,11 @@ and the lazy-global tier that runs their builtins only on first use. - [Error handling](error-handling.md) — global `error`/`unhandledrejection` events, `reportError`, catching Java exceptions in JS (`error.nativeException`), forwarding JS throws to Java callers (`interop.escapeException`), JS stacks on Java exceptions (`com.tns.JavaScriptStackTrace`), configuration flags, and crash-reporter integration. - [structuredClone](structured-clone.md) — the WHATWG `structuredClone(value, { transfer })` global: what clones, how graph identity and cycles are preserved, `ArrayBuffer` transfer, and the `DataCloneError` `DOMException` on failure. +- [Messaging and `node:worker_threads`](worker-threads.md) — `MessagePort`, + `MessageChannel`, `BroadcastChannel` and `MessageEvent`, the + `node:worker_threads` real-vs-shim table and its documented deviations, + the strong-until-closed port lifetime, HTML port enabling, and the + transfer support matrix with its `DataCloneError` messages. - [Implementing additional Chrome DevTools protocol Domains](extending-inspector.md) ## Knowledge diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index ce4a5290d..24f01c771 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -380,14 +380,17 @@ npm packages that require Node builtins by their prefixed names can run unmodified where a shim exists: - A shim implements a documented **subset** of the corresponding Node module's - API, backed by `ns:` modules. Unimplemented members are simply absent + API, backed by the runtime's own modules. Unimplemented members are simply absent (so `typeof util.promisify === "function"` feature-checks behave - correctly); they are never present-but-throwing. + correctly); they are never present-but-throwing. The one exception is a + member whose silent absence would read as a delivery bug rather than as a + missing feature — it may be present and throw, and the table below names + every such member. - **One source file per specifier.** A shim is its own module that consumes - the `ns:` module it adapts through the internal require, and it owns *all* - the adaptation — argument shapes, option names, aliases, anything that has - to track Node. A standard `ns:` module never contains compatibility code - and never knows a shim exists. + the module it adapts through the internal require, and it owns *all* the + adaptation — argument shapes, option names, aliases, anything that has to + track Node. A standard `ns:` module never contains compatibility code and + never knows a shim exists. - Shims are **lazy**: a shim's source is only evaluated when its specifier is first resolved, so an app that never touches the `node:` scheme never pays for one. @@ -411,6 +414,7 @@ unmodified where a shim exists: | `node:util` | `inspect`, `format`, `TextEncoder`, `TextDecoder` | Re-exports `ns:util`'s members unchanged (`nodeUtil.inspect === nsUtil.inspect`) from a **distinct, separately frozen module object**. `TextEncoder`/`TextDecoder` are the globals of those names, as they are in Node. Documented as partial. | | `node:url` | `fileURLToPath`, `pathToFileURL` | Node-strict converters between `file:` URLs and paths. Documented as partial — no `URL`/`URLSearchParams` re-exports (both are globals), no legacy `url.parse`/`format`/`resolve`. | | `node:module` | `createRequire` | Re-exports `ns:module`'s `createRequire` unchanged from a **distinct, separately frozen module object**. `createPumpingRequire` is deliberately absent: it has no Node counterpart, so code written against this shim keeps running on Node. `require.resolve`/`.cache`/`.main` are not implemented, and neither is any other `node:module` member (`Module`, `builtinModules`, `isBuiltin`, `register`, `syncBuiltinESMExports`). Documented as partial. | +| `node:worker_threads` | the messaging and thread surface — see [worker-threads.md](worker-threads.md) | The channel half (`MessagePort`, `MessageChannel`, `BroadcastChannel`, `receiveMessageOnPort`) is the real implementation, the same objects the globals of those names hold; the thread half is a bridge over the runtime's own `Worker`. It has no `ns:` counterpart — the surface tracks Node's, so there is nothing for a standard module to own. The one place it breaks the absent-not-throwing rule below is deliberate: `postMessageToThread` and `moveMessagePortToContext` are present and throw an `Error` naming themselves, because silently missing thread-addressed messaging reads as a delivery bug rather than as an unsupported call. Documented as partial. | `node:url`'s parsing goes through the URL intrinsic, so `file://localhost/x` is accepted (the URL spec folds a `localhost` authority to none) while any other @@ -719,21 +723,28 @@ shims are built on, so it is normative: both runtimes provide it. Android-only) note in between. - Internal runtime machinery must never be reachable through the scheme. -That last rule holds because public modules and internal builtins are **two -separate loading paths**, not one registry with a per-entry flag: - -- The **public registry** is a table mapping specifier → builtin, and it is the - only thing the `ns:`/`node:` resolver consults. A specifier absent from it - does not resolve, full stop. Today it holds six entries: `ns:module`, - `ns:runtime`, `ns:util`, `node:module`, `node:url`, `node:util`. -- **Internal builtins** (the intrinsics snapshot, the require factory, the - console formatter, and so on) are invoked directly from their own native call - sites. They are never named in the public registry, so there is no specifier - that could reach them and nothing to mark private. - -Adding an internal builtin therefore cannot accidentally expose it; exposing -one is an explicit registry entry, which is also the change this document has -to describe. +That last rule holds because every registry row carries its tier, and the two +resolvers read the same table differently: + +- The **`ns:`/`node:` resolver** — the app-facing one, behind `require()`, + `import` and `import()` — serves only rows *not* marked internal-only. An + internal-only specifier fails exactly as a name absent from the table does. + Seven rows are public today: `ns:module`, `ns:runtime`, `ns:util`, + `node:module`, `node:url`, `node:util`, `node:worker_threads`. +- The **internal require** builtins receive (previous section) is the only + thing that can name an internal-only row. Five rows are marked that way: + `internal/broadcast-channel`, `internal/dom-exception`, `internal/events`, + `internal/message-channel`, `internal/message-event`. Their exports carry + capabilities app code must not hold — listener-accounting hook keys, the + error-reporter setter, base classes that must be the runtime's own rather + than whatever a global currently names. +- Builtins with **no row at all** (the intrinsics snapshot, the require + factory, the console formatter) are invoked straight from their native call + sites. There is no specifier that could reach them and nothing to mark. + +So a builtin is unreachable from app code unless a registry row says +otherwise, and exposing one means editing that row's tier — which is also the +change this document has to describe. ## Source-text modules: deliberately not supported diff --git a/docs/structured-clone.md b/docs/structured-clone.md index 80dd045e6..095789780 100644 --- a/docs/structured-clone.md +++ b/docs/structured-clone.md @@ -1,6 +1,6 @@ # structuredClone -The runtime exposes the WHATWG [`structuredClone(value, options)`](https://html.spec.whatwg.org/multipage/structured-data.html#dom-structuredclone) global. It performs a deep, structure-preserving copy of `value` using V8's structured clone serializer — the same one worker `postMessage` uses — optionally taking ownership of `ArrayBuffer`s named in `options.transfer`. +The runtime exposes the WHATWG [`structuredClone(value, options)`](https://html.spec.whatwg.org/multipage/structured-data.html#dom-structuredclone) global. It performs a deep, structure-preserving copy of `value` using V8's structured clone serializer — the same one worker `postMessage` uses — optionally taking ownership of the `ArrayBuffer`s and `MessagePort`s named in `options.transfer`. ```js const clone = structuredClone({ when: new Date(), tags: new Set(["a"]) }); @@ -12,7 +12,7 @@ buffer.byteLength; // 0 — the memory now belongs to `moved` ## Surface -`structuredClone(value)` returns a clone of `value`. `structuredClone(value, { transfer })` additionally transfers every `ArrayBuffer` in `transfer`. +`structuredClone(value)` returns a clone of `value`. `structuredClone(value, { transfer })` additionally transfers every `ArrayBuffer` and `MessagePort` in `transfer`. - `value` is required; calling with no arguments throws a `TypeError`. - `options` may be `undefined` or `null` (both mean "no transfer"); anything else must be an object, or a `TypeError` is thrown. @@ -24,14 +24,16 @@ The clone preserves the shape of the graph, not just the values: an object refer `SharedArrayBuffer` is **shared, not copied**: the clone is a second `SharedArrayBuffer` over the same memory, so writes through either are visible through the other. -Not cloneable — each throws (see the deviations below): functions, symbols, `WeakMap`/`WeakSet`/`WeakRef`, `Promise`, and every native/interop object (Java proxies and the objects the metadata layer hands out), which have no serialized form. +Not cloneable — each throws (see the deviations below): functions, symbols, `WeakMap`/`WeakSet`/`WeakRef`, `Promise`, and every native/interop object (Java proxies and the objects the metadata layer hands out), which have no serialized form. A `MessagePort` is transferable but never cloneable, so one found in the graph has to be in the transfer list. ## Transfer semantics -Listed buffers are validated before anything is serialized: each entry must be an `ArrayBuffer`, must not already be detached, must be detachable, and must appear at most once. A violation throws before the source buffers are touched, so a rejected call never leaves a half-transferred graph behind. +The list is validated before anything is serialized: each entry must be an `ArrayBuffer` or a `MessagePort`, must not already be detached (an `ArrayBuffer` must additionally be detachable), and must appear at most once. A violation throws before the sources are touched, and nothing is detached or handed over until the whole graph has serialized successfully — a rejected call never leaves a half-transferred graph behind. The guarantee covers transfer state only: serializing the graph runs user getters, and a getter's own side effects (closing a listed port, say) are not rolled back — a port closed that way makes the call fail, already closed. On success the memory changes hands rather than being copied: the source buffer is detached (`byteLength` becomes 0, and every typed array over it becomes zero-length) and the clone receives the original backing store. A transferred buffer need not appear inside `value` at all; a buffer reached through a typed array in `value` is transferred as a unit, so the cloned view sees the original bytes. +A transferred `MessagePort` is closed as a handle on this side while its queue and its channel membership move to the clone. Unlike a buffer, a port that *is* reachable in `value` must also be listed — an unlisted one is a `DataCloneError`, since a copied port would be a port to nowhere. [worker-threads.md](worker-threads.md) has the full transfer matrix and the exact `DataCloneError` messages. + ## Worker `postMessage` `structuredClone` and worker `postMessage` run on the same serialization core, so everything above — which types clone, graph identity, cycles, `SharedArrayBuffer` sharing — holds for messages too. `postMessage` takes the same transfer list as a second argument: @@ -44,12 +46,12 @@ worker.postMessage({ pixels: buffer }, [buffer]); // buffer is detached here, Two differences are intentional: - **The transfer list must be an array.** Omitting it, or passing `undefined` or `null`, means "transfer nothing"; every other non-array value is a `TypeError`. The WebIDL iterable-to-sequence conversion that lets `structuredClone` take a `Set` or any iterable lives in the JavaScript wrapper around `structuredClone`; `postMessage` is native all the way down and has no such wrapper. -- **Host objects degrade instead of throwing.** Posting a native/interop object delivers an empty object to the receiver rather than raising a `DataCloneError`. This is long-standing shipped behavior, and app code relies on it; `structuredClone`, being new, follows the spec and rejects. The asymmetry is encoded in exactly one place — the `HostObjectPolicy` enum in `test-app/runtime/src/main/cpp/StructuredSerialization.h` — and unifying the two on rejection is a breaking change that needs the iOS runtime to move at the same time. +- **Host objects degrade instead of throwing.** Posting a native/interop object delivers an empty object to the receiver rather than raising a `DataCloneError`. This is long-standing shipped behavior, and app code relies on it; `structuredClone`, being new, follows the spec and rejects. The asymmetry is encoded in exactly one place — the `HostObjectPolicy` enum in `test-app/runtime/src/main/cpp/StructuredSerialization.h` — and unifying the two on rejection is a breaking change that needs the iOS runtime to move at the same time. `MessagePort` is outside the leniency: a port is rejected or transferred, never degraded, because an empty object in the receiver would strand its sibling. ## Deviations from the specification - **`DataCloneError` is a `DOMException`.** Failures throw a `DOMException` named `"DataCloneError"`, from the JS argument checks and the native serializer alike, so both `e.name === "DataCloneError"` and `instanceof DOMException` detect them. (The serializer falls back to a `DataCloneError`-named `Error` only when the builtin can no longer run, e.g. during isolate teardown.) -- **Only `ArrayBuffer` is transferable.** The spec's other transferable types — `MessagePort`, `ImageBitmap`, `ReadableStream` and friends — do not exist here. A non-`ArrayBuffer` in the transfer list is a `DataCloneError`. +- **Only `ArrayBuffer` and `MessagePort` are transferable.** The spec's other transferable types — `ImageBitmap`, `ReadableStream` and friends — do not exist here, and neither do the runtime's own native/interop wrapper objects, which have no serialized form. Anything else in the transfer list is a `DataCloneError`. Port transfer has rules of its own (a port may not travel on itself, a port in the graph must be listed); [worker-threads.md](worker-threads.md) has the full matrix and the exact messages. - **Host objects are not cloneable by `structuredClone`.** The spec leaves platform objects to each host; here every native/interop wrapper is rejected with a `DataCloneError`, because a JavaScript copy detached from its native counterpart would be a wrapper around nothing. Worker `postMessage` deliberately differs — see above. `SharedArrayBuffer` follows the spec: it is shared rather than copied, and it is not transferable (listing one throws a `DataCloneError`). diff --git a/docs/worker-threads.md b/docs/worker-threads.md new file mode 100644 index 000000000..35078728b --- /dev/null +++ b/docs/worker-threads.md @@ -0,0 +1,266 @@ +# Messaging and `node:worker_threads` + +The runtime implements HTML's messaging primitives — `MessagePort`, +`MessageChannel`, `BroadcastChannel` and `MessageEvent` — and exposes them both +as globals and through a `node:worker_threads` module. + +```js +const channel = new MessageChannel(); +channel.port1.onmessage = (event) => console.log(event.data); +channel.port2.postMessage({ hello: "world" }); + +const worker = new Worker("./worker.js"); +worker.postMessage({ port: channel.port2 }, [channel.port2]); +``` + +## Surface + +`MessagePort`, `MessageChannel`, `BroadcastChannel` and `MessageEvent` are +**lazy globals**: the name is placed on the first read of it, so an app that +never mentions one never pays for it. They are ordinary globals once read — +`instanceof`, subclassing and property access all behave normally. + +`require("node:worker_threads")` (or `import` of the same specifier) returns a +frozen module. Its channel half is not a re-implementation: the classes it +exports are the very objects the globals of those names hold, so +`require("node:worker_threads").MessagePort === globalThis.MessagePort`. + +`MessagePort` has no constructor — `new MessagePort()` throws a `TypeError`. +Ports come from a `MessageChannel` or arrive on a message. + +## `node:worker_threads` exports + +"Real" means genuine behaviour, and for a class the same object the global of +that name holds. "Shim" means a bridge over the runtime's own `Worker`, which +has no thread pool, no stdio plumbing and no per-thread environment. "Throws" +means deliberately unsupported. + +| export | status | notes | +|---|---|---| +| `MessagePort` | real | The global `MessagePort`. | +| `MessageChannel` | real | The global `MessageChannel`. | +| `BroadcastChannel` | real | The global `BroadcastChannel`. The process-wide registry described below. | +| `receiveMessageOnPort(port)` | real | Synchronously pops one queued message, `{ message }` or `undefined`. Works on a port that was never started; a close sentinel at the head closes the port and reports `undefined`. | +| `isMainThread` | real | `false` inside a runtime worker. | +| `threadId` | real | `0` on the main isolate, the worker's id (from 1) inside one. | +| `isInternalThread` | real | Always `false`; this runtime has no internal threads. | +| `markAsUntransferable(obj)` | real | Brands `obj` so listing it in a transfer list is a `DataCloneError`. | +| `isMarkedAsUntransferable(obj)` | real | Reads that brand. | +| `markAsUncloneable(obj)` | real | Brands `obj` so serializing it at all is a `DataCloneError`, in `structuredClone` and every `postMessage` alike. | +| `setEnvironmentData(key, value)` | real, deviates | Clones and stores process-wide. No per-thread snapshot — see below. Passing `undefined` (or omitting the value) deletes the key. | +| `getEnvironmentData(key)` | real, deviates | Deserializes a fresh copy per read, on any isolate. | +| `resourceLimits` | shim | Always `{}`; the runtime imposes no per-worker limits and reports none. | +| `SHARE_ENV` | shim | Exported so the spelling resolves, but inert — see below. | +| `threadName` | shim | Always `undefined`. | +| `workerData` | shim | Always `null` — see below. | +| `parentPort` | shim | `null` on the main isolate. Inside a worker, a `MessagePort`-shaped `EventTarget` over the worker's existing parent channel: `postMessage` forwards to the global `postMessage`, `message`/`messageerror` are re-dispatched from the worker global scope, `start()` and `close()` are no-ops. It is **not** a real port: not transferable, no queue of its own. | +| `Worker` | shim | A class over the runtime's global `Worker` with a small Node-style emitter (`on`/`once`/`off`/`removeListener`) for `message`, `messageerror`, `error`, `online` and `exit`. `postMessage(value, transfer)` and `terminate()` forward. `online` is emitted off a microtask after construction, not from the thread. Unsupported options throw a `TypeError` naming the option: `workerData`, `env`, `eval`, `transferList`, and `stdin`/`stdout`/`stderr` when explicitly truthy. The runtime's own `Worker` options (`androidPriority`) ride along untouched — the native constructor ignores keys it does not know. | +| `postMessageToThread` | throws | `Error: postMessageToThread is not supported in this runtime`. | +| `moveMessagePortToContext` | throws | `Error: moveMessagePortToContext is not supported in this runtime`. | +| `locks` | absent | Web Locks are not implemented; the property does not exist. | + +## Documented deviations + +### `setEnvironmentData` has no per-thread snapshot + +Node copies the environment-data store into a worker when it is spawned, so a +later write on the parent is invisible to it. Here the store is one +process-global map, and a worker reads it live: a `setEnvironmentData` call +made *after* a worker started is visible to that worker. + +Values are cloned on the way in and deserialized fresh on each read, so +mutating the object you passed does not reach a reader, and two readers never +share one object. + +### `exit` comes only from `terminate()` + +The runtime has no thread-exit signal — nothing reports that a worker's isolate +finished. `terminate()` therefore resolves with `0` and emits `exit` with code +`0` on the way, and that is the only path that emits it. A worker that ends by +its own `close()` produces no `exit`. + +### A worker error carries no `error` object, and the worker scope's `onerror` is not an event + +An error the worker scope leaves unhandled reaches the parent as a real +`ErrorEvent` dispatched on the `Worker`, so `worker.onerror` and +`addEventListener("error", …)` both fire, interleaved in the order they were +installed. Two things differ from a browser: + +- Only primitives cross the isolate boundary, so `event.error` is always + `null`; the worker's stack comes through as `event.stackTrace`, a string + alongside the standard `message`, `filename` and `lineno`. +- Inside the worker, `onerror` is still a direct call taking the thrown value — + not an `ErrorEvent`, and not reachable through `addEventListener`. Returning + truthy from it handles the error and stops it from reaching the parent, which + is the same "handled" contract `worker.onerror` has on the parent side (a + truthy return there cancels the event, as does `preventDefault()` from any + listener). + +### Inside a worker, `event.target` is not `globalThis` + +`globalThis` is not itself an `EventTarget` here. It forwards +`addEventListener`, `removeEventListener` and `dispatchEvent` to an internal +`EventTarget` that backs the worker global scope, and native delivery +dispatches on that internal target — which is what keeps app code from +intercepting message delivery by replacing `globalThis.dispatchEvent`. The +consequence is visible on the event: `event.target` inside a worker's message +handler is that internal target, not `globalThis`. + +### `SHARE_ENV` is a no-op + +It is exported so that an `options.env === SHARE_ENV` spelling resolves rather +than being a `ReferenceError`. There is one process environment and it is never +copied, so nothing distinguishes sharing it from not. (`env` is a rejected +`Worker` option regardless.) + +### No `workerData` + +There is no channel that would carry it: the `Worker` constructor rejects the +`workerData` option outright, so the export is permanently `null`. Send an +opening `postMessage` instead. + +### `BroadcastChannel`'s registry is process-global + +"Same user agent", in the spec's terms, is the app process. Every +`BroadcastChannel` built with the same name joins one group regardless of which +isolate constructed it, so a worker and the main isolate reach each other by +name alone. A channel is receiving from the moment it is constructed and stays +strongly held until `close()`. + +## `MessagePort` lifetime + +The GC model is Node's, not the browser's: **a port is held strongly by the +runtime from creation until it is closed.** An unreferenced-but-unclosed port +does not go away, and neither does its channel, its queue, or anything the +queue's messages hold. Close the ports you are done with. + +```js +const { port1, port2 } = new MessageChannel(); +port1.onmessage = handle; +// ... later +port1.close(); +``` + +Closing behaves as one channel-wide event: + +- `close()` sends a `close` event — a plain `Event`, not a `MessageEvent` — to + the port being closed **and** to its sibling. A channel with one end left is + no channel, so both ends learn about it. (A named `BroadcastChannel` group is + different: members join and leave it freely, so only the leaving member gets + the event.) +- The `close` event reaches a port that was never started. Enabling is about + *messages*; a port whose sibling died always learns about it. +- On the port being closed the event fires synchronously, inside `close()`. + On the sibling it orders behind whatever was already queued to it, so + messages already sent are still delivered first. +- `postMessage` on a closed port is a **silent no-op**. It still serializes: + the transfer list's side effects and its errors do not depend on delivery, so + a bad transfer list throws and a good one detaches its buffers, and only then + is the message dropped. +- `port.close(callback)` registers `callback` as a one-shot `close` listener + before closing. + +## Port enabling + +Delivery follows HTML's port-enable rules rather than starting automatically: + +- A port starts delivering on its **first `message` listener** — either + `addEventListener("message", …)` or an `onmessage` attribute assignment. The + first `onmessage` write counts even when it is `onmessage = null`: it is the + assignment, not the handler, that claims the listener slot. +- It stops when the last `message` listener goes away, and messages queue again + until one returns. +- `port.start()` enables delivery for code that only uses `addEventListener` + and wants control over when the queue drains. As in Node, it does not pin + the port on: removing the last `message` listener stops delivery again until + a listener returns or `start()` is called once more. +- `receiveMessageOnPort(port)` bypasses all of it and pops one message + synchronously. + +`BroadcastChannel` has no enable step; it receives from construction. + +## Transfer support matrix + +A transfer list moves ownership instead of copying. It is the second argument +to `port.postMessage` / `worker.postMessage`, and `options.transfer` for +`structuredClone`. + +| value | in a transfer list | in the message graph | +|---|---|---| +| `ArrayBuffer` | transferable — the receiver gets the original backing store, the sender's buffer is detached (`byteLength` 0, every view over it zero-length) | cloned | +| `MessagePort` | transferable — the sender's port is closed as a handle while its queue and channel membership travel to the receiver, so a sender on the far end keeps queueing into it while it is in flight | `DataCloneError` unless it is also listed | +| `SharedArrayBuffer` | **not** transferable — `DataCloneError` | *shared*: the receiver builds a second `SharedArrayBuffer` over the same memory, and writes through either are visible through the other | +| everything else | `DataCloneError` | per the [structured clone rules](structured-clone.md) | + +### Rejections + +Every one of these is a `DOMException` named `DataCloneError`, so both +`e.name === "DataCloneError"` and `instanceof DOMException` detect them. + +| condition | message | +|---|---| +| the port doing the posting is in its own transfer list | `Transfer list contains source port` | +| a listed port is already detached (closed, or transferred away) | `MessagePort in transfer list is already detached` | +| the same port listed twice | `Transfer list contains duplicate MessagePort` | +| the same `ArrayBuffer` listed twice | `The transfer list contains the same ArrayBuffer twice` | +| a listed `ArrayBuffer` is detached or not detachable | `An ArrayBuffer in the transfer list is detached and cannot be transferred` | +| a listed value branded by `markAsUntransferable` | `Cannot transfer object of unsupported type.` | +| anything else in the list (a non-object included) | `Found invalid value in transferList.` | +| a port reachable in the message but not listed | `Object that needs transfer was found in message but not listed in transferList` | +| a value branded by `markAsUncloneable`, anywhere in the graph | `Cannot clone object of unsupported type.` | + +The duplicate-port message ends in the constructor name of the listed object, +so a subclass of `MessagePort` names itself there. + +Those are the checks the native collector runs. What reaches it depends on the +entry point, and a list argument of the wrong *shape* is a `TypeError` rather +than a `DataCloneError`: + +- `port.postMessage(value, transfer)` does the WebIDL sequence conversion in + JavaScript, so an array, any iterable, or a `{ transfer }` dictionary all + work. Anything else is `TypeError: postMessage: transfer is not iterable`. +- `worker.postMessage(value, transfer)` is native all the way down and takes an + actual array; omitting it or passing `undefined`/`null` means "transfer + nothing", and any other value is + `TypeError: The transfer list must be an array`. +- `structuredClone(value, { transfer })` accepts any iterable and screens each + entry in its own wrapper first, so an untransferable entry there is still a + `DataCloneError` but carries that wrapper's message, + `structuredClone: value in transfer list is not transferable`, rather than + `Found invalid value in transferList.` + +### Nothing changes hands until the whole graph is written + +Validation and serialization run to completion before a single buffer is +detached or a single port is handed over. A `DataCloneError` from the middle of +a graph therefore leaves **every port and every buffer in the list exactly as +it found them** — still open, still holding their memory — so a failed +`postMessage` can be corrected and retried. + +The listed ports and buffers are re-checked after the write as well, because +writing the graph runs user getters and one of them may have closed a listed +port or detached a listed buffer; those late failures are the same +`MessagePort in transfer list is already detached` and `An ArrayBuffer in the +transfer list is detached and cannot be transferred`. What they undo is the +transfer — nothing is detached, nothing changes hands — not what the getters +did on the way there: a port a getter closed stays closed. + +A listed port that the value itself never names still travels, but on arrival +it has no way out: a `message` event hands it over in `event.ports`, while +`structuredClone` and `receiveMessageOnPort` return only the value. Those two +close such a port as soon as it arrives, so its sibling learns the channel is +gone instead of queueing into a port nothing can ever read. + +## Worker messages + +The runtime's own `Worker` and the worker global scope are `EventTarget`s that +deliver real `MessageEvent`s, so `worker.onmessage`, `worker.addEventListener`, +and the same pair on `globalThis` inside a worker, all work and interleave in +installation order. Handlers keep receiving the payload as `event.data`. + +`worker.postMessage` differs from `port.postMessage` in one respect: an +interop/native object anywhere in the graph is delivered as an empty object +rather than raising a `DataCloneError`, which is long-standing behaviour app +code relies on. Transfer is not part of that leniency — a port in a worker +transfer list is validated exactly as it is everywhere else, since degrading a +transfer would strand the port's sibling. diff --git a/eslint.config.mjs b/eslint.config.mjs index d7d9cbf61..f467cd4d2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,7 +32,7 @@ const capturedStatics = [ // Captured constructors. A destructure from `primordials` shadows the global, // so these only fire on the unguarded reference. -const restrictedGlobals = ['Date', 'FinalizationRegistry', 'Map', 'Number', 'Proxy', 'RangeError', 'Set', 'String', 'TypeError', 'Uint8Array', 'Uint32Array', 'WeakRef'].map((name) => ({ +const restrictedGlobals = ['Date', 'FinalizationRegistry', 'Map', 'Number', 'Promise', 'Proxy', 'RangeError', 'Set', 'String', 'TypeError', 'Uint8Array', 'Uint32Array', 'WeakRef', 'WeakSet'].map((name) => ({ name, message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`, })); diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index 62aeb52eb..eef723e59 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -23,12 +23,18 @@ shared.runStructuredCloneTests(); shared.runTextEncodingTests(); shared.runDOMExceptionTests(); shared.runEventsTests(); +shared.runMessageEventTests(); +shared.runMessageChannelTests(); +shared.runBroadcastChannelTests(); +shared.runWorkerEventsTests(); +shared.runNodeWorkerThreadsTests(); require("./tests/testWebAssembly"); require("./tests/testEventLoop"); require("./tests/testMultithreadedJavascript"); require("./tests/testWorkerTerminateDuringLoad"); require("./tests/testWorkerOptions"); require("./tests/testWorkerResourceLimits"); +require("./tests/testMessaging"); require("./tests/testInterfaceDefaultMethods"); require("./tests/testInterfaceStaticMethods"); require("./tests/testMetadata"); diff --git a/test-app/app/src/main/assets/app/tests/messaging/parentPortOnceWorker.js b/test-app/app/src/main/assets/app/tests/messaging/parentPortOnceWorker.js new file mode 100644 index 000000000..a3a255fe5 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/messaging/parentPortOnceWorker.js @@ -0,0 +1,8 @@ +var parentPort = require("node:worker_threads").parentPort; +var count = 0; +function listener() { + count++; + parentPort.postMessage(count); +} +parentPort.on("message", listener); +parentPort.once("message", listener); diff --git a/test-app/app/src/main/assets/app/tests/messaging/parentPortPortsWorker.js b/test-app/app/src/main/assets/app/tests/messaging/parentPortPortsWorker.js new file mode 100644 index 000000000..6873b1301 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/messaging/parentPortPortsWorker.js @@ -0,0 +1,4 @@ +var parentPort = require("node:worker_threads").parentPort; +parentPort.addEventListener("message", function (event) { + parentPort.postMessage(event.ports.length); +}); diff --git a/test-app/app/src/main/assets/app/tests/messaging/parentPortWorker.js b/test-app/app/src/main/assets/app/tests/messaging/parentPortWorker.js new file mode 100644 index 000000000..c6c14bda4 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/messaging/parentPortWorker.js @@ -0,0 +1,7 @@ +var parentPort = require("node:worker_threads").parentPort; +parentPort.once("message", function (value) { + parentPort.postMessage({ once: value }); +}); +parentPort.on("message", function (value) { + parentPort.postMessage({ on: value }); +}); diff --git a/test-app/app/src/main/assets/app/tests/messaging/parkedWorker.mjs b/test-app/app/src/main/assets/app/tests/messaging/parkedWorker.mjs new file mode 100644 index 000000000..0e7d2da36 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/messaging/parkedWorker.mjs @@ -0,0 +1,3 @@ +// Never finishes evaluating, so messages posted to this worker stay queued on +// the wrapper: the queue is only enabled once the entry has settled. +await new Promise(() => {}); diff --git a/test-app/app/src/main/assets/app/tests/messaging/rejectingWorker.js b/test-app/app/src/main/assets/app/tests/messaging/rejectingWorker.js new file mode 100644 index 000000000..1ff2eed06 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/messaging/rejectingWorker.js @@ -0,0 +1,4 @@ +onerror = function () { + throw new Error("thrown by scope onerror"); +}; +Promise.reject(new Error("original rejection")); diff --git a/test-app/app/src/main/assets/app/tests/messaging/throwingWorker.js b/test-app/app/src/main/assets/app/tests/messaging/throwingWorker.js new file mode 100644 index 000000000..22d3ee93a --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/messaging/throwingWorker.js @@ -0,0 +1 @@ +throw new Error("boom from worker"); diff --git a/test-app/app/src/main/assets/app/tests/testMessaging.js b/test-app/app/src/main/assets/app/tests/testMessaging.js new file mode 100644 index 000000000..c06a46037 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testMessaging.js @@ -0,0 +1,313 @@ +// Android-side regression specs for the messaging tier. The shared suites under +// app/shared cover the specified behavior; these pin runtime edges that need +// native wrappers, a worker that never settles, or a collection. +describe("Messaging runtime edges", function () { + var parkedEntry = "./messaging/parkedWorker.mjs"; + // Delivery goes through the event loop; SETTLE is long enough that an + // event which was going to arrive would have. + var SETTLE = 400; + + describe("transfer lists", function () { + it("rejects a buffer a getter detached while the graph was being written", function () { + var buffer = new ArrayBuffer(16); + var error = null; + try { + structuredClone({ get x() { buffer.transfer(); return 1; } }, { transfer: [buffer] }); + } catch (e) { + error = e; + } + expect(error).not.toBeNull(); + expect(error.name).toBe("DataCloneError"); + }); + + it("hands a listed port over through the cloned value", function (done) { + var channel = new MessageChannel(); + var clone = structuredClone(channel.port2, { transfer: [channel.port2] }); + expect(clone instanceof MessagePort).toBe(true); + expect(clone).not.toBe(channel.port2); + clone.addEventListener("message", function (event) { + expect(event.data).toBe("through"); + clone.close(); + channel.port1.close(); + done(); + }); + channel.port1.postMessage("through"); + }); + + it("closes a listed port the cloned value never names", function (done) { + var channel = new MessageChannel(); + var closed = false; + channel.port1.addEventListener("close", function () { closed = true; }); + structuredClone({}, { transfer: [channel.port2] }); + setTimeout(function () { + expect(closed).toBe(true); + channel.port1.close(); + done(); + }, SETTLE); + }); + + it("closes a port transferred to a worker that is terminated before its entry settles", function (done) { + var worker = new Worker(parkedEntry); + var channel = new MessageChannel(); + var closed = false; + channel.port1.addEventListener("close", function () { closed = true; }); + worker.postMessage(channel.port2, [channel.port2]); + worker.terminate(); + setTimeout(function () { + expect(closed).toBe(true); + channel.port1.close(); + done(); + }, SETTLE); + }); + }); + + describe("handler attributes", function () { + it("enables a port when onmessage is first set to null", function (done) { + var channel = new MessageChannel(); + channel.port2.postMessage("consumed"); + channel.port1.onmessage = null; + setTimeout(function () { + var received = 0; + channel.port1.addEventListener("message", function () { received++; }); + setTimeout(function () { + expect(received).toBe(0); + channel.port1.close(); + channel.port2.close(); + done(); + }, SETTLE); + }, SETTLE); + }); + + it("keeps a port disabled until a handler or listener arrives", function (done) { + var channel = new MessageChannel(); + channel.port2.postMessage("kept"); + setTimeout(function () { + var received = 0; + channel.port1.addEventListener("message", function () { received++; }); + setTimeout(function () { + expect(received).toBe(1); + channel.port1.close(); + channel.port2.close(); + done(); + }, SETTLE); + }, SETTLE); + }); + }); + + describe("MessagePort surface", function () { + it("runs an onclose handler when the port is closed", function () { + var channel = new MessageChannel(); + var seen = null; + channel.port1.onclose = function (event) { seen = event.type; }; + channel.port1.close(); + expect(seen).toBe("close"); + channel.port2.close(); + }); + }); + + describe("BroadcastChannel", function () { + it("treats the empty name as a channel like any other", function (done) { + var a = new BroadcastChannel(""); + var b = new BroadcastChannel(""); + var c = new BroadcastChannel(""); + var got = []; + a.onmessage = function (event) { got.push(event.data); }; + b.close(); + setTimeout(function () { + c.postMessage("still open"); + setTimeout(function () { + expect(got).toEqual(["still open"]); + a.close(); + c.close(); + done(); + }, SETTLE); + }, SETTLE); + }); + }); + + describe("node:worker_threads", function () { + var wt = require("node:worker_threads"); + + it("exposes the emitter surface on parentPort", function (done) { + // The shim resolves the entry from the app root, not from the + // requiring test file, hence the ~/ form. + var worker = new wt.Worker("~/tests/messaging/parentPortWorker.js"); + var got = []; + worker.on("message", function (value) { + got.push(value); + if (got.length === 3) { + expect(got).toEqual([{ once: 1 }, { on: 1 }, { on: 2 }]); + worker.terminate(); + done(); + } + }); + worker.on("error", function (error) { + fail("worker error: " + error.message); + worker.terminate(); + done(); + }); + worker.postMessage(1); + worker.postMessage(2); + }); + + it("lets the same listener be on() and once() at the same time", function (done) { + var worker = new wt.Worker("~/tests/messaging/parentPortOnceWorker.js"); + var got = []; + worker.on("message", function (value) { + got.push(value); + if (got.length === 3) { + setTimeout(function () { + // Three messages: the once() registration fires only + // for the first, the on() one for all three. + expect(got).toEqual([1, 2, 3, 4]); + worker.terminate(); + done(); + }, SETTLE); + } + }); + worker.on("error", function (error) { + fail("worker error: " + error.message); + worker.terminate(); + done(); + }); + worker.postMessage("a"); + worker.postMessage("b"); + worker.postMessage("c"); + }); + + it("relays transferred ports to parentPort message events", function (done) { + var worker = new wt.Worker("~/tests/messaging/parentPortPortsWorker.js"); + var channel = new MessageChannel(); + worker.on("message", function (value) { + expect(value).toBe(1); + channel.port1.close(); + worker.terminate(); + done(); + }); + worker.on("error", function (error) { + fail("worker error: " + error.message); + worker.terminate(); + done(); + }); + worker.postMessage(channel.port2, [channel.port2]); + }); + + it("forwards the option bag to the runtime's Worker", function () { + // androidPriority is the runtime Worker's own option: the shim does + // not know it, so a rejection proves the bag reached the native + // constructor. + expect(function () { + new wt.Worker("~/tests/messaging/parentPortWorker.js", { + androidPriority: "turbo", + }); + }).toThrow(); + }); + }); + + describe("worker error reporting", function () { + // A worker boots on its own thread, so the first error arrives whenever + // the runner gets to it; specs wait for it and only then settle for + // duplicates. + var originalTimeout; + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000; + }); + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + it("reports an error the Worker object left unhandled to the parent scope", function (done) { + var seen = []; + var worker = null; + var listener = function (event) { + seen.push(event); + event.preventDefault(); + if (seen.length === 1) { + setTimeout(finish, SETTLE); + } + }; + var finish = function () { + removeEventListener("error", listener); + expect(seen.length).toBe(1); + expect(seen[0].message).toContain("boom from worker"); + expect(seen[0].error instanceof Error).toBe(true); + worker.terminate(); + done(); + }; + addEventListener("error", listener); + worker = new Worker("./messaging/throwingWorker.js"); + }); + + it("forwards the error a throwing scope onerror raised for a rejection, once", function (done) { + var worker = new Worker("./messaging/rejectingWorker.js"); + var messages = []; + worker.onerror = function (event) { + messages.push(event.message); + event.preventDefault(); + if (messages.length === 1) { + setTimeout(function () { + expect(messages.length).toBe(1); + expect(messages[0]).toContain("thrown by scope onerror"); + worker.terminate(); + done(); + }, SETTLE); + } + }; + }); + }); + + describe("AbortSignal handler attribute accounting", function () { + function pollGC(predicate, cb) { + var turns = 0; + (function poll() { + __collect(); + if (predicate() || turns >= 100) { + cb(); + return; + } + turns++; + setTimeout(poll, 20); + })(); + } + + it("a timeout signal whose onabort was only ever set to null is collectable", function (done) { + var wr = (function () { + var signal = AbortSignal.timeout(60000); + signal.onabort = null; + return new WeakRef(signal); + })(); + pollGC(function () { return wr.deref() === undefined; }, function () { + expect(wr.deref()).toBeUndefined(); + done(); + }); + }); + + it("a timeout signal whose onabort was cleared again is collectable", function (done) { + var wr = (function () { + var signal = AbortSignal.timeout(60000); + signal.onabort = function () {}; + signal.onabort = null; + return new WeakRef(signal); + })(); + pollGC(function () { return wr.deref() === undefined; }, function () { + expect(wr.deref()).toBeUndefined(); + done(); + }); + }); + + it("a timeout signal with an onabort handler survives GC and still aborts", function (done) { + var reasonName = null; + (function () { + AbortSignal.timeout(300).onabort = function (event) { + reasonName = event.target.reason.name; + }; + })(); + __collect(); + pollGC(function () { return reasonName !== null; }, function () { + expect(reasonName).toBe("TimeoutError"); + done(); + }); + }); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js b/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js index 08b07c8f0..27b784df2 100644 --- a/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js +++ b/test-app/app/src/main/assets/app/tests/testRuntimeImplementedAPIs.js @@ -111,3 +111,27 @@ describe("CustomEvent canary", function () { expect(new CustomEvent("x") instanceof Event).toBe(true); }); }); + +// Same contract again for the shared messaging suites (MessageChannel, +// BroadcastChannel, MessageEvent, WorkerEvents, NodeWorkerThreads): they +// self-gate, these unguarded specs turn absence into a failure. +describe("messaging canary", function () { + it("implements the messaging interfaces as globals", function () { + expect(typeof MessagePort).toBe("function"); + expect(typeof MessageChannel).toBe("function"); + expect(typeof BroadcastChannel).toBe("function"); + expect(typeof MessageEvent).toBe("function"); + }); + + it("is not reachable as a module from app code", function () { + expect(function () { require("internal/message-channel"); }).toThrow(); + expect(function () { require("internal/message-event"); }).toThrow(); + expect(function () { require("internal/broadcast-channel"); }).toThrow(); + }); + + it("resolves node:worker_threads on the main thread", function () { + var workerThreads = require("node:worker_threads"); + expect(workerThreads.isMainThread).toBe(true); + expect(workerThreads.MessageChannel).toBe(MessageChannel); + }); +}); diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index f638b92a1..a3449e73d 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -71,14 +71,18 @@ set(RUNTIME_BUILTIN_JS ${RUNTIME_BUILTIN_JS_DIR}/abort-signal.js ${RUNTIME_BUILTIN_JS_DIR}/base64.js ${RUNTIME_BUILTIN_JS_DIR}/blob-url.js + ${RUNTIME_BUILTIN_JS_DIR}/broadcast-channel.js ${RUNTIME_BUILTIN_JS_DIR}/dom-exception.js ${RUNTIME_BUILTIN_JS_DIR}/error-events.js ${RUNTIME_BUILTIN_JS_DIR}/events.js ${RUNTIME_BUILTIN_JS_DIR}/inspect.js ${RUNTIME_BUILTIN_JS_DIR}/json-helper.js + ${RUNTIME_BUILTIN_JS_DIR}/message-channel.js + ${RUNTIME_BUILTIN_JS_DIR}/message-event.js ${RUNTIME_BUILTIN_JS_DIR}/node-module.js ${RUNTIME_BUILTIN_JS_DIR}/node-url.js ${RUNTIME_BUILTIN_JS_DIR}/node-util.js + ${RUNTIME_BUILTIN_JS_DIR}/node-worker-threads.js ${RUNTIME_BUILTIN_JS_DIR}/ns-module.js ${RUNTIME_BUILTIN_JS_DIR}/ns-runtime.js ${RUNTIME_BUILTIN_JS_DIR}/ns-util.js @@ -88,6 +92,7 @@ set(RUNTIME_BUILTIN_JS ${RUNTIME_BUILTIN_JS_DIR}/structured-clone.js ${RUNTIME_BUILTIN_JS_DIR}/text-encoding.js ${RUNTIME_BUILTIN_JS_DIR}/weak-ref.js + ${RUNTIME_BUILTIN_JS_DIR}/worker-events.js ) set(RUNTIME_BUILTINS_GENERATED_DIR ${PROJECT_SOURCE_DIR}/src/main/cpp/generated) get_filename_component(RUNTIME_BUILTINS_JS2C ${PROJECT_SOURCE_DIR}/../../tools/js2c.mjs ABSOLUTE) @@ -201,6 +206,7 @@ add_library( src/main/cpp/MetadataReader.cpp src/main/cpp/MetadataTreeNode.cpp src/main/cpp/MetadataEntry.cpp + src/main/cpp/Messaging.cpp src/main/cpp/MethodCache.cpp src/main/cpp/ModuleBinding.cpp src/main/cpp/ModuleInternal.cpp @@ -225,6 +231,7 @@ add_library( src/main/cpp/V8GlobalHelpers.cpp src/main/cpp/V8StringConstants.cpp src/main/cpp/WeakRef.cpp + src/main/cpp/WorkerEvents.cpp src/main/cpp/WorkerWrapper.cpp src/main/cpp/Timers.cpp src/main/cpp/com_tns_AssetExtractor.cpp diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index 8d2b18135..27e031c07 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -1898,13 +1898,16 @@ void CallbackHandlers::CallWorkerScopeOnErrorHandle(Isolate *isolate, TryCatch & std::string message, source, stackTrace; int lineno; - // will account for exceptions thrown inside the error handler + // A scope handler that threw replaces the error it was offered: the + // parent sees the handler's own error, and only that one. if (innerTc.HasCaught()) { ExtractTryCatchInfo(isolate, context, innerTc, message, source, stackTrace, lineno); wrapper->PassUncaughtExceptionFromWorkerToParent(message, source, stackTrace, lineno); + return; } - // bubble up to the main thread's Worker object `onerror` + // Unhandled at the worker scope - including when there is no scope + // handler at all - so it becomes the parent's error event. ExtractTryCatchInfo(isolate, context, tc, message, source, stackTrace, lineno); wrapper->PassUncaughtExceptionFromWorkerToParent(message, source, stackTrace, lineno); } catch (NativeScriptException &ex) { diff --git a/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp b/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp index 0a5fcd52b..e3eb9be6f 100644 --- a/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp +++ b/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp @@ -46,7 +46,13 @@ void ConcurrentQueue::Push(std::shared_ptr message) { } { + // Checked under the queue mutex, where Terminate() also flips it while + // emptying the queue: a push that loses the race is dropped rather than + // landing in a queue nothing will ever pop again. std::unique_lock mlock(this->mutex_); + if (terminated_) { + return; + } this->messagesQueue_.push(message); } @@ -85,28 +91,32 @@ std::vector> ConcurrentQueue::PopAll() { } void ConcurrentQueue::Terminate() { - // Must run on the looper's own thread: removing an fd concurrently with an - // in-flight callback dispatch is racy. - std::unique_lock lock(initializationMutex_); - terminated_ = true; - - if (this->fd_ != -1) { - ALooper_removeFd(this->looper_, this->fd_); - close(this->fd_); - this->fd_ = -1; - } - - if (this->looper_ != nullptr) { - ALooper_release(this->looper_); - this->looper_ = nullptr; + // Whatever is still queued is destroyed after both locks are released: a + // message owns transferred buffers and ports, and destroying a port takes + // its sibling group's lock and posts to the sibling's loop. + std::queue> dropped; + { + // Must run on the looper's own thread: removing an fd concurrently with + // an in-flight callback dispatch is racy. + std::unique_lock lock(initializationMutex_); + terminated_ = true; + + if (this->fd_ != -1) { + ALooper_removeFd(this->looper_, this->fd_); + close(this->fd_); + this->fd_ = -1; + } + + if (this->looper_ != nullptr) { + ALooper_release(this->looper_); + this->looper_ = nullptr; + } } - - // Release anything a racing Push() enqueued before it observed - // terminated_ - nothing will drain the queue from here on. { + // Release anything a racing Push() enqueued before it observed + // terminated_ - nothing will drain the queue from here on. std::unique_lock mlock(this->mutex_); - std::queue> empty; - this->messagesQueue_.swap(empty); + dropped.swap(this->messagesQueue_); } } diff --git a/test-app/runtime/src/main/cpp/ConcurrentQueue.h b/test-app/runtime/src/main/cpp/ConcurrentQueue.h index bbcbbd688..b550e78c3 100644 --- a/test-app/runtime/src/main/cpp/ConcurrentQueue.h +++ b/test-app/runtime/src/main/cpp/ConcurrentQueue.h @@ -2,6 +2,7 @@ #define CONCURRENTQUEUE_H_ #include +#include #include #include #include @@ -30,7 +31,10 @@ struct ConcurrentQueue { std::queue> messagesQueue_; ALooper* looper_ = nullptr; int fd_ = -1; - bool terminated_ = false; + // Read under either lock: Terminate() sets it holding the lifecycle lock + // and empties the queue holding the queue mutex, and a push must be turned + // away by whichever of the two it reaches first. + std::atomic terminated_{false}; std::mutex mutex_; std::mutex initializationMutex_; }; diff --git a/test-app/runtime/src/main/cpp/EventLoop.cpp b/test-app/runtime/src/main/cpp/EventLoop.cpp index f67011aaa..e1fb5f4ee 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.cpp +++ b/test-app/runtime/src/main/cpp/EventLoop.cpp @@ -165,35 +165,43 @@ void EventLoop::Shutdown() { // must run on the home thread: removing an fd concurrently with an // in-flight ALooper callback dispatch is racy JEnv env; - std::lock_guard lock(mutex_); - if (stopped_) { - return; - } - stopped_ = true; - internal_.immediate.clear(); - internal_.delayed.clear(); - ordered_.immediate.clear(); - ordered_.delayed.clear(); - deferredJavaThrows_.clear(); - pumpDrainHook_ = nullptr; - if (eventFd_ != -1) { - ALooper_removeFd(looper_, eventFd_); - close(eventFd_); - eventFd_ = -1; - } - if (timerFd_ != -1) { - ALooper_removeFd(looper_, timerFd_); - close(timerFd_); - timerFd_ = -1; - } - if (looper_ != nullptr) { - ALooper_release(looper_); - looper_ = nullptr; - } - if (handler_ != nullptr) { - // the global ref stays alive until the destructor, but the released - // handler ignores any token already in (or racing into) its queue - env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_RELEASE); + // The dropped entries are moved out here and destroyed only after the lock + // is released: an entry's destructor may post back into this very loop (a + // dropped message carrying a transferred port sentinels the port's + // sibling, and that sibling may live here), and mutex_ is not recursive. + Lane droppedInternal; + Lane droppedOrdered; + { + std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + stopped_ = true; + droppedInternal.immediate.swap(internal_.immediate); + droppedInternal.delayed.swap(internal_.delayed); + droppedOrdered.immediate.swap(ordered_.immediate); + droppedOrdered.delayed.swap(ordered_.delayed); + deferredJavaThrows_.clear(); + pumpDrainHook_ = nullptr; + if (eventFd_ != -1) { + ALooper_removeFd(looper_, eventFd_); + close(eventFd_); + eventFd_ = -1; + } + if (timerFd_ != -1) { + ALooper_removeFd(looper_, timerFd_); + close(timerFd_); + timerFd_ = -1; + } + if (looper_ != nullptr) { + ALooper_release(looper_); + looper_ = nullptr; + } + if (handler_ != nullptr) { + // the global ref stays alive until the destructor, but the released + // handler ignores any token already in (or racing into) its queue + env.CallVoidMethod(handler_, EVENT_LOOP_HANDLER_RELEASE); + } } } diff --git a/test-app/runtime/src/main/cpp/EventLoop.h b/test-app/runtime/src/main/cpp/EventLoop.h index 2770242d3..f273bfe05 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.h +++ b/test-app/runtime/src/main/cpp/EventLoop.h @@ -69,6 +69,11 @@ class OrderedTaskSource { * Shutdown are silently dropped, preserving the old LooperTasks "message to a * terminated runtime" semantics; leftover wakeups (tokens or eventfd units * whose work was drained early) are no-ops. + * + * No entry is ever destroyed while mutex_ is held: an entry's destructor may + * post (a dropped message carrying a transferred port sentinels the port's + * sibling, possibly on this loop), so Shutdown moves the lanes out and lets + * them die after the unlock. */ class EventLoop { public: diff --git a/test-app/runtime/src/main/cpp/LazyGlobals.cpp b/test-app/runtime/src/main/cpp/LazyGlobals.cpp index 9b174c3ba..fce833d57 100644 --- a/test-app/runtime/src/main/cpp/LazyGlobals.cpp +++ b/test-app/runtime/src/main/cpp/LazyGlobals.cpp @@ -3,6 +3,7 @@ #include "ArgConverter.h" #include "Base64.h" #include "BuiltinLoader.h" +#include "Messaging.h" #include "StructuredSerialization.h" #include "TextEncoding.h" @@ -44,6 +45,10 @@ constexpr LazyGlobalEntry kLazyGlobals[] = { // a file: the read hits the exports cache and only the placement is // lazy. {"CustomEvent", "CustomEvent", BuiltinExports}, + {"MessageEvent", "MessageEvent", BuiltinExports}, + {"MessagePort", "MessagePort", messaging::GetMessageChannelExports}, + {"MessageChannel", "MessageChannel", messaging::GetMessageChannelExports}, + {"BroadcastChannel", "BroadcastChannel", messaging::GetBroadcastChannelExports}, }; void LazyGlobalGetter(Local property, diff --git a/test-app/runtime/src/main/cpp/Messaging.cpp b/test-app/runtime/src/main/cpp/Messaging.cpp new file mode 100644 index 000000000..0d0f873f1 --- /dev/null +++ b/test-app/runtime/src/main/cpp/Messaging.cpp @@ -0,0 +1,1057 @@ +#include "Messaging.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "ArgConverter.h" +#include "BuiltinLoader.h" +#include "EventLoop.h" +#include "NativeScriptAssert.h" +#include "NativeScriptException.h" +#include "Runtime.h" +#include "RuntimeState.h" +#include "StructuredSerialization.h" +#include "Util.h" +#include "WorkerWrapper.h" + +using namespace v8; + +namespace tns { +namespace messaging { + +using Message = serialization::SerializedValue; + +namespace { + +// Per-isolate state. `livePorts` is the strong reference that keeps a port and +// its wrapper alive until it is closed; everything else is registered once by +// the JS tier or built on first use. +struct MessagingState { + ~MessagingState(); + + std::unordered_set> livePorts; + Global portTemplate; + Global emitMessage; + // The tier's per-wrapper setup, read off the builtin's exports. A wrapper is + // built from a template, so no JS constructor ever ran on it. + Global adoptPort; + Global untransferableBrand; + Global uncloneableBrand; +}; + +// Null once the runtime has started tearing down — callers bail rather than +// recreate state that would never be destroyed. The state outlives the +// force-close sweep: DestroyRuntime releases it in its very last statement, +// long after CloseAllPorts has run. +MessagingState* State(Isolate* isolate) { + return RuntimeState::For(isolate); +} + +MessagingState::~MessagingState() { + // Detach the set first so a port's teardown cannot mutate it mid-walk. + std::unordered_set> survivors = + std::move(this->livePorts); + this->livePorts.clear(); +} + +// Values set with setEnvironmentData, shared by every isolate in the process. +// Cloned on the way in and read back per isolate, so nothing but bytes is +// shared. Documented deviation from Node: a write after a worker spawned is +// visible to it, because there is no per-thread snapshot. +std::mutex g_environmentDataMutex; +std::unordered_map> + g_environmentData; + +void IllegalConstructorCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + isolate->ThrowException(Exception::TypeError( + ArgConverter::ConvertToV8String(isolate, "Illegal constructor"))); +} + +// The template every port wrapper is built from. It doubles as the brand: a +// wrapper is recognised by HasInstance, and the port itself lives in the one +// internal field. +Local PortTemplate(Isolate* isolate) { + MessagingState* state = State(isolate); + if (state == nullptr) { + return Local(); + } + if (state->portTemplate.IsEmpty()) { + Local tmpl = + FunctionTemplate::New(isolate, IllegalConstructorCallback); + tmpl->SetClassName(ArgConverter::ConvertToV8String(isolate, "MessagePort")); + tmpl->InstanceTemplate()->SetInternalFieldCount(1); + state->portTemplate.Reset(isolate, tmpl); + } + return state->portTemplate.Get(isolate); +} + +// A port can be created on an isolate that never touched MessagePort — a +// worker receiving a transferred one — so the builtin that registers the +// delivery function and exports the wrapper setup is run on demand rather than +// assumed. +bool EnsureJsTier(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + MessagingState* state = State(isolate); + if (state == nullptr) { + return false; + } + if (!state->emitMessage.IsEmpty() && !state->adoptPort.IsEmpty()) { + return true; + } + Local exports; + Local adopt; + if (!GetMessageChannelExports(context).ToLocal(&exports) || + !exports->Get(context, ArgConverter::ConvertToV8String(isolate, "adoptPort")) + .ToLocal(&adopt)) { + return false; + } + if (!adopt->IsFunction() || state->emitMessage.IsEmpty()) { + return false; + } + state->adoptPort.Reset(isolate, adopt.As()); + return true; +} + +Local UntransferableBrand(Isolate* isolate, bool create) { + MessagingState* state = State(isolate); + if (state == nullptr) { + return Local(); + } + if (state->untransferableBrand.IsEmpty()) { + if (!create) { + return Local(); + } + state->untransferableBrand.Reset( + isolate, Private::New(isolate, ArgConverter::ConvertToV8String( + isolate, "messagingUntransferable"))); + } + return state->untransferableBrand.Get(isolate); +} + +Local UncloneableBrand(Isolate* isolate, bool create) { + MessagingState* state = State(isolate); + if (state == nullptr) { + return Local(); + } + if (state->uncloneableBrand.IsEmpty()) { + if (!create) { + return Local(); + } + state->uncloneableBrand.Reset( + isolate, Private::New(isolate, ArgConverter::ConvertToV8String( + isolate, "messagingUncloneable"))); + } + return state->uncloneableBrand.Get(isolate); +} + +// Private, not a plain Symbol: app code can neither discover a brand nor forge +// one onto a value the sender never marked. +void StampBrand(const FunctionCallbackInfo& info, + Local (*brandFor)(Isolate*, bool)) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsObject()) { + return; + } + Local brand = brandFor(isolate, true); + if (brand.IsEmpty()) { + return; + } + (void)info[0].As()->SetPrivate(isolate->GetCurrentContext(), brand, + v8::True(isolate)); +} + +} // namespace + +// The process-wide set of ports that can reach each other. An anonymous group +// is one channel's two ends; a named one is every BroadcastChannel sharing a +// name, across every isolate in the process. +class SiblingGroup final : public std::enable_shared_from_this { +public: + static std::shared_ptr Get(const std::string& name); + + SiblingGroup() = default; + explicit SiblingGroup(std::string name) : name_(std::move(name)), named_(true) {} + ~SiblingGroup(); + + SiblingGroup(const SiblingGroup&) = delete; + SiblingGroup& operator=(const SiblingGroup&) = delete; + + DispatchResult Dispatch(PortData* source, std::shared_ptr message, + std::string* error); + void Entangle(std::initializer_list ports); + void Entangle(PortData* port); + void Disentangle(PortData* data); + +private: + const std::string name_; + // A BroadcastChannel group, whatever its name ("" included); an anonymous + // group is one channel's two ends. + const bool named_ = false; + std::shared_mutex mutex_; + std::set ports_; +}; + +namespace { + +std::mutex g_groupsMutex; +std::unordered_map> g_groups; + +} // namespace + +std::shared_ptr SiblingGroup::Get(const std::string& name) { + std::lock_guard lock(g_groupsMutex); + auto entry = g_groups.find(name); + if (entry != g_groups.end()) { + std::shared_ptr existing = entry->second.lock(); + if (existing != nullptr) { + return existing; + } + } + std::shared_ptr group = std::make_shared(name); + g_groups[name] = group; + return group; +} + +SiblingGroup::~SiblingGroup() { + if (!this->named_) { + return; + } + std::lock_guard lock(g_groupsMutex); + auto entry = g_groups.find(this->name_); + if (entry != g_groups.end() && entry->second.expired()) { + g_groups.erase(entry); + } +} + +DispatchResult SiblingGroup::Dispatch(PortData* source, std::shared_ptr message, + std::string* error) { + std::shared_lock lock(this->mutex_); + + if (this->ports_.find(source) == this->ports_.end()) { + if (error != nullptr) { + *error = "Source MessagePort is not entangled with this group."; + } + return DispatchResult::kFailed; + } + if (this->ports_.size() <= 1) { + return DispatchResult::kNoDestination; + } + // Nothing that can only be handed over once may fan out. + if (this->ports_.size() > 2 && message->HasTransferables()) { + if (error != nullptr) { + *error = "Transferables cannot be used with multiple destinations."; + } + return DispatchResult::kFailed; + } + + for (PortData* port : this->ports_) { + if (port == source) { + continue; + } + // Only reachable with a single destination, since a fan-out message can + // carry no transferables at all. + if (message->TransfersPort(port)) { + if (error != nullptr) { + *error = + "The target port was posted to itself, and the communication " + "channel was lost"; + } + return DispatchResult::kDelivered; + } + // One message object shared by every destination: legal only because a + // fan-out carries nothing that a destination could consume. + port->AddToIncomingQueue(message); + } + return DispatchResult::kDelivered; +} + +void SiblingGroup::Entangle(PortData* port) { + this->Entangle({port}); +} + +void SiblingGroup::Entangle(std::initializer_list ports) { + std::unique_lock lock(this->mutex_); + for (PortData* data : ports) { + this->ports_.insert(data); + // group_ is written under the port's own mutex, which is what lets + // PortData::Dispatch read it without racing a disentangle. Taken here in + // the only legal order: this group's lock is already held. + std::lock_guard dataLock(data->mutex_); + NS_CHECK(data->group_ == nullptr); + data->group_ = this->shared_from_this(); + } +} + +void SiblingGroup::Disentangle(PortData* data) { + // Keeps the group alive past the last member dropping its reference. + std::shared_ptr self = this->shared_from_this(); + std::unique_lock lock(this->mutex_); + this->ports_.erase(data); + { + std::lock_guard dataLock(data->mutex_); + data->group_.reset(); + } + + // Queued rather than delivered: a close orders behind everything already + // sent, on both ends. + data->AddToIncomingQueue(std::make_shared()); + if (this->ports_.size() == 1 && !this->named_) { + // A channel with one end left is a channel no more; a named group outlives + // any number of members joining and leaving. + (*this->ports_.begin())->AddToIncomingQueue(std::make_shared()); + } +} + +PortData::PortData(NativeMessagePort* owner) : owner_(owner) {} + +PortData::~PortData() { + NS_CHECK(this->owner_ == nullptr); + this->Disentangle(); +} + +void PortData::AddToIncomingQueue(std::shared_ptr message) { + std::lock_guard lock(this->mutex_); + this->incoming_.push_back(std::move(message)); + if (this->owner_ != nullptr) { + // Still holding the mutex: an owner read outside it could be detached by + // the time the wake reaches it. + this->owner_->TriggerAsync(); + } +} + +DispatchResult PortData::Dispatch(std::shared_ptr message, std::string* error) { + std::shared_ptr group; + { + std::lock_guard lock(this->mutex_); + group = this->group_; + } + // The group's lock is taken with this port's mutex released: the two are + // always acquired group first. + if (group == nullptr) { + if (error != nullptr) { + *error = "MessagePortData is not entangled."; + } + return DispatchResult::kFailed; + } + return group->Dispatch(this, std::move(message), error); +} + +void PortData::Entangle(PortData* a, PortData* b) { + std::make_shared()->Entangle({a, b}); +} + +void PortData::Disentangle() { + std::shared_ptr group; + { + std::lock_guard lock(this->mutex_); + group = this->group_; + } + if (group != nullptr) { + group->Disentangle(this); + } +} + +NativeMessagePort::NativeMessagePort(Isolate* isolate, Local wrapper) + : wrapper_(isolate, wrapper), isolate_(isolate) { + Runtime* runtime = Runtime::TryGetRuntime(isolate); + if (runtime != nullptr) { + this->loop_ = runtime->GetEventLoop(); + } +} + +NativeMessagePort::~NativeMessagePort() { + this->OrphanData(); +} + +std::shared_ptr NativeMessagePort::New(Local context, + std::unique_ptr data, + std::shared_ptr group) { + Isolate* isolate = v8::Isolate::GetCurrent(); + MessagingState* state = State(isolate); + if (state == nullptr || !EnsureJsTier(context)) { + return nullptr; + } + Local tmpl = PortTemplate(isolate); + Local wrapper; + if (tmpl.IsEmpty() || !tmpl->InstanceTemplate()->NewInstance(context).ToLocal(&wrapper)) { + return nullptr; + } + + std::shared_ptr port(new NativeMessagePort(isolate, wrapper)); + wrapper->SetAlignedPointerInInternalField(0, port.get(), v8::kEmbedderDataTypeTagDefault); + state->livePorts.insert(port); + + if (data != nullptr) { + port->data_ = std::move(data); + std::lock_guard lock(port->data_->mutex_); + port->data_->owner_ = port.get(); + // Whatever queued up while the port was in flight drains on a later turn, + // never inside the read that produced this port. + port->TriggerAsync(); + } else { + port->data_ = std::make_unique(port.get()); + if (group != nullptr) { + group->Entangle(port->data_.get()); + } + } + + // The tier installs whatever a MessagePort instance needs before the wrapper + // is handed out. A failure leaves a live channel behind, so take it down — + // without the close event, which would dispatch on a wrapper that never + // became a MessagePort. + Local arg = wrapper; + if (state->adoptPort.Get(isolate)->Call(context, v8::Undefined(isolate), 1, &arg).IsEmpty()) { + port->OrphanData(); + port->CloseHandle(); + return nullptr; + } + return port; +} + +void NativeMessagePort::TriggerAsync() { + // The caller holds this port's data mutex, which is what makes "the port is + // still owned" and "a drain is posted" one indivisible step against a + // concurrent detach. Never takes the receiving isolate's Locker: the posted + // entry runs under the home loop's own ceremony. + if (this->loop_ == nullptr || this->scheduled_.exchange(true)) { + return; + } + std::shared_ptr self = this->shared_from_this(); + // A dropped post (the loop already stopped) leaves scheduled_ set on + // purpose: nothing will ever run on that loop again, and the flag keeps + // producers from posting into it. + this->loop_->PostInternal([self]() { + if (Runtime::TryGetRuntime(self->isolate_) == nullptr) { + return; + } + self->Drain(); + }); +} + +void NativeMessagePort::Start() { + if (this->data_ == nullptr) { + return; + } + this->receiving_ = true; + std::lock_guard lock(this->data_->mutex_); + if (!this->data_->incoming_.empty()) { + this->TriggerAsync(); + } +} + +void NativeMessagePort::Stop() { + this->receiving_ = false; +} + +std::unique_ptr NativeMessagePort::Detach() { + // owner_ drops under the data mutex, so a producer either wakes this port + // before the detach or never sees an owner at all. Node carries a separate + // "closing" flag because libuv tears its handle down asynchronously; here + // the detach IS the close, so a null data_ is the whole [[Detached]] state. + std::lock_guard lock(this->data_->mutex_); + this->data_->owner_ = nullptr; + return std::move(this->data_); +} + +void NativeMessagePort::CloseHandle() { + Isolate* isolate = this->isolate_; + if (!this->wrapper_.IsEmpty()) { + HandleScope handleScope(isolate); + // The wrapper outlives the port whenever JS still holds it; clearing the + // field is what makes PortFromWrapper report a closed port instead of + // handing out a pointer to freed memory. + this->wrapper_.Get(isolate)->SetAlignedPointerInInternalField( + 0, nullptr, v8::kEmbedderDataTypeTagDefault); + this->wrapper_.Reset(); + } + MessagingState* state = State(isolate); + if (state != nullptr) { + state->livePorts.erase(this->shared_from_this()); + } +} + +void NativeMessagePort::Close() { + // Keeps this object alive across the registry erase in CloseHandle. + std::shared_ptr self = this->shared_from_this(); + if (this->wrapper_.IsEmpty() && this->data_ == nullptr) { + return; + } + Isolate* isolate = this->isolate_; + HandleScope handleScope(isolate); + Local wrapper = this->Wrapper(isolate); + + std::unique_ptr data; + if (this->data_ != nullptr) { + data = this->Detach(); + } + this->CloseHandle(); + if (data != nullptr) { + // Sequential, never nested: Detach released the data mutex before the + // group's lock is taken here. + data->Disentangle(); + data.reset(); + } + // Last, on the wrapper the port has just let go of, so a listener finds an + // already-detached port and a close() from inside one is a no-op rather than + // a recursion. + if (!wrapper.IsEmpty()) { + this->EmitClose(wrapper); + } +} + +void NativeMessagePort::EmitClose(Local wrapper) { + Isolate* isolate = this->isolate_; + Runtime* runtime = Runtime::TryGetRuntime(isolate); + if (runtime == nullptr) { + return; + } + Local context = runtime->GetContext(); + if (context.IsEmpty()) { + return; + } + Context::Scope contextScope(context); + if (!EnsureJsTier(context)) { + return; + } + Local undefined = v8::Undefined(isolate); + this->Emit(context, wrapper, State(isolate)->emitMessage.Get(isolate), undefined, undefined, + "close"); +} + +std::unique_ptr NativeMessagePort::TransferForMessaging() { + std::shared_ptr self = this->shared_from_this(); + std::unique_ptr data = this->Detach(); + // Deliberately not disentangled: the group membership and the queue are + // exactly what the receiving port adopts, and senders keep queueing into the + // data while it is in flight. + this->CloseHandle(); + return data; +} + +void NativeMessagePort::OrphanData() { + if (this->data_ == nullptr) { + return; + } + std::unique_ptr data = this->Detach(); + data->Disentangle(); +} + +Local NativeMessagePort::Wrapper(Isolate* isolate) const { + if (this->wrapper_.IsEmpty()) { + return Local(); + } + return this->wrapper_.Get(isolate); +} + +std::shared_ptr NativeMessagePort::TakeMessage(bool force) { + std::lock_guard lock(this->data_->mutex_); + if (this->data_->incoming_.empty()) { + return nullptr; + } + // A port that was never started still learns that its sibling died: the + // close sentinel is honoured with the message queue disabled. + if (!this->receiving_ && !force && !this->data_->incoming_.front()->IsCloseMessage()) { + return nullptr; + } + std::shared_ptr message = std::move(this->data_->incoming_.front()); + this->data_->incoming_.pop_front(); + return message; +} + +Maybe NativeMessagePort::ReceiveOne(Local context, Local* out) { + Isolate* isolate = v8::Isolate::GetCurrent(); + std::shared_ptr received = this->TakeMessage(true); + if (received == nullptr) { + return Just(false); + } + if (received->IsCloseMessage()) { + this->Close(); + return Just(false); + } + return received->Deserialize(isolate, context).ToLocal(out) ? Just(true) : Nothing(); +} + +bool NativeMessagePort::Emit(Local context, Local receiver, + Local emitMessage, Local data, + Local ports, const char* type) { + Isolate* isolate = v8::Isolate::GetCurrent(); + if (receiver.IsEmpty()) { + return false; + } + Local argv[] = {data, ports, ArgConverter::ConvertToV8String(isolate, type)}; + TryCatch tc(isolate); + if (!emitMessage->Call(context, receiver, 3, argv).IsEmpty()) { + return true; + } + if (tc.HasTerminated() || !tc.CanContinue()) { + return false; + } + // There is no event-loop frame to unwind into, so a listener that throws is + // an uncaught error, reported where a timer callback's would be. + if (!NativeScriptException::ContainUncaughtCallbackException(isolate, tc)) { + if (EventLoop::IsPumping()) { + // A pump keeps making JNI calls after this returns, so arming a + // pending Java exception here is illegal; the loop reports it from + // its next token dispatch instead. + Runtime* runtime = Runtime::TryGetRuntime(isolate); + std::shared_ptr loop = + runtime == nullptr ? nullptr : runtime->GetEventLoop(); + if (loop != nullptr) { + loop->DeferJavaThrow(std::make_shared(tc)); + } + } else { + NativeScriptException(tc).ReThrowToJava(); + } + } + return false; +} + +void NativeMessagePort::Drain() { + // Cleared first: a message arriving from here on must schedule a fresh + // drain rather than be left for this one, which may already be past its + // queue read. + this->scheduled_.store(false); + if (this->data_ == nullptr) { + return; + } + Isolate* isolate = this->isolate_; + Runtime* runtime = Runtime::TryGetRuntime(isolate); + if (runtime == nullptr) { + return; + } + HandleScope handleScope(isolate); + Local context = runtime->GetContext(); + if (context.IsEmpty()) { + return; + } + Context::Scope contextScope(context); + if (!EnsureJsTier(context)) { + return; + } + MessagingState* state = State(isolate); + if (state == nullptr || state->emitMessage.IsEmpty()) { + return; + } + Local emitMessage = state->emitMessage.Get(isolate); + Local wrapper = this->Wrapper(isolate); + + size_t budget; + { + std::lock_guard lock(this->data_->mutex_); + budget = std::max(this->data_->incoming_.size(), static_cast(1000)); + } + + bool reschedule = false; + // data_ is written only on this thread, but the callout below can transfer + // or close this very port, so it is re-checked every iteration. + while (this->data_ != nullptr) { + if (budget-- == 0) { + // Only messages that arrived after this drain began are deferred: the + // budget is a floor, not a cap, so the backlog present at the trigger + // always drains in one turn (Node's processing_limit semantics). The + // repost carries the late arrivals. + reschedule = true; + break; + } + HandleScope messageScope(isolate); + std::shared_ptr received = this->TakeMessage(false); + if (received == nullptr) { + break; + } + if (received->IsCloseMessage()) { + this->Close(); + return; + } + + Local payload; + Local ports = v8::Undefined(isolate); + bool read; + { + // Failures reading the value are the port's 'messageerror' event, not + // the isolate's uncaught-error path. Never holds the data mutex: the + // read runs arbitrary JS. + TryCatch tc(isolate); + read = received->Deserialize(isolate, context, &ports).ToLocal(&payload); + if (!read) { + if (tc.HasTerminated() || !tc.CanContinue()) { + return; + } + payload = tc.HasCaught() ? tc.Exception() : v8::Undefined(isolate).As(); + tc.Reset(); + } + } + if (!read) { + this->Emit(context, wrapper, emitMessage, payload, v8::Undefined(isolate), + "messageerror"); + reschedule = true; + break; + } + if (!this->Emit(context, wrapper, emitMessage, payload, ports, "message")) { + reschedule = true; + break; + } + // Per message, not per drain: a handler's microtasks run before the next + // message arrives, which is what both browsers and Node observe. + isolate->PerformMicrotaskCheckpoint(); + } + + if (reschedule && this->data_ != nullptr) { + std::lock_guard lock(this->data_->mutex_); + this->TriggerAsync(); + } +} + +NativeMessagePort* PortFromWrapper(Isolate* isolate, Local object) { + if (!IsPortWrapper(isolate, object)) { + return nullptr; + } + return static_cast( + object->GetAlignedPointerFromInternalField(0, v8::kEmbedderDataTypeTagDefault)); +} + +bool IsPortWrapper(Isolate* isolate, Local object) { + MessagingState* state = State(isolate); + if (state == nullptr || state->portTemplate.IsEmpty()) { + return false; + } + return state->portTemplate.Get(isolate)->HasInstance(object); +} + +MaybeLocal AdoptPort(Local context, std::unique_ptr data) { + std::shared_ptr port = NativeMessagePort::New(context, std::move(data)); + if (port == nullptr) { + return MaybeLocal(); + } + return port->Wrapper(v8::Isolate::GetCurrent()); +} + +Maybe IsMarkedUntransferable(Isolate* isolate, Local object) { + Local brand = UntransferableBrand(isolate, false); + if (brand.IsEmpty()) { + return Just(false); + } + return object->HasPrivate(isolate->GetCurrentContext(), brand); +} + +Maybe IsMarkedUncloneable(Isolate* isolate, Local object) { + Local brand = UncloneableBrand(isolate, false); + if (brand.IsEmpty()) { + return Just(false); + } + return object->HasPrivate(isolate->GetCurrentContext(), brand); +} + +Local UncloneableBrandIfAny(Isolate* isolate) { + return UncloneableBrand(isolate, false); +} + +void CloseAllPorts(Isolate* isolate) { + MessagingState* state = State(isolate); + if (state == nullptr) { + return; + } + // Orphaning every port's data drops the owner — so nothing can be woken on a + // loop that has stopped — and takes the data out of its group, which both + // sentinels the siblings on other isolates and puts the data beyond the + // reach of their sender threads. The ports themselves die with this + // runtime's state, by which time their data is inert. + for (const std::shared_ptr& port : state->livePorts) { + port->OrphanData(); + } +} + +namespace { + +// The wrapper argument, or false after throwing. A closed port passes: its +// wrapper is still a MessagePort, and every native here tolerates one. +bool PortArg(const FunctionCallbackInfo& info, int index, Local* wrapper) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() <= index || !info[index]->IsObject() || + !IsPortWrapper(isolate, info[index].As())) { + isolate->ThrowException(Exception::TypeError(ArgConverter::ConvertToV8String( + isolate, "The \"port\" argument must be a MessagePort instance"))); + return false; + } + *wrapper = info[index].As(); + return true; +} + +void CreateChannelCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local context = isolate->GetCurrentContext(); + std::shared_ptr port1 = NativeMessagePort::New(context); + if (port1 == nullptr) { + return; + } + std::shared_ptr port2 = NativeMessagePort::New(context); + if (port2 == nullptr) { + port1->Close(); + return; + } + PortData::Entangle(port1->Data(), port2->Data()); + + Local pair = v8::Array::New(isolate, 2); + if (!pair->Set(context, 0, port1->Wrapper(isolate)).FromMaybe(false) || + !pair->Set(context, 1, port2->Wrapper(isolate)).FromMaybe(false)) { + return; + } + info.GetReturnValue().Set(pair); +} + +void CreateBroadcastPortCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local context = isolate->GetCurrentContext(); + if (info.Length() < 1) { + isolate->ThrowException(Exception::TypeError(ArgConverter::ConvertToV8String( + isolate, "The \"name\" argument must be a string"))); + return; + } + std::shared_ptr port = NativeMessagePort::New( + context, nullptr, SiblingGroup::Get(ArgConverter::ToString(isolate, info[0]))); + if (port == nullptr) { + return; + } + // A BroadcastChannel has no port-enable step: it receives from the moment it + // exists. + port->Start(); + info.GetReturnValue().Set(port->Wrapper(isolate)); +} + +void PostMessageCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local wrapper; + if (!PortArg(info, 0, &wrapper)) { + return; + } + Local context = isolate->GetCurrentContext(); + Local value = info.Length() > 1 ? info[1] : v8::Undefined(isolate).As(); + Local transferList = info.Length() > 2 ? info[2] : v8::Undefined(isolate).As(); + + // Serialization runs even for a port that can no longer deliver: the + // transfer list's side effects, and its errors, do not depend on delivery. + std::shared_ptr message = std::make_shared(); + if (message->Serialize(isolate, context, value, transferList, + serialization::HostObjectPolicy::kReject, wrapper) + .IsNothing()) { + return; + } + // Re-read: serializing runs user getters, which may have closed the port. + NativeMessagePort* port = PortFromWrapper(isolate, wrapper); + if (port == nullptr || port->IsDetached()) { + return; + } + + std::string error; + port->Data()->Dispatch(std::move(message), &error); + if (!error.empty()) { + DEBUG_WRITE_FORCE("MessagePort: %s", error.c_str()); + } +} + +void StartCallback(const FunctionCallbackInfo& info) { + Local wrapper; + if (!PortArg(info, 0, &wrapper)) { + return; + } + NativeMessagePort* port = PortFromWrapper(info.GetIsolate(), wrapper); + if (port != nullptr) { + port->Start(); + } +} + +void StopCallback(const FunctionCallbackInfo& info) { + Local wrapper; + if (!PortArg(info, 0, &wrapper)) { + return; + } + NativeMessagePort* port = PortFromWrapper(info.GetIsolate(), wrapper); + if (port != nullptr) { + port->Stop(); + } +} + +void CloseCallback(const FunctionCallbackInfo& info) { + Local wrapper; + if (!PortArg(info, 0, &wrapper)) { + return; + } + NativeMessagePort* port = PortFromWrapper(info.GetIsolate(), wrapper); + if (port != nullptr) { + // The keepalive outlives the registry erase inside Close. + std::shared_ptr self = port->shared_from_this(); + self->Close(); + } +} + +void DrainOneCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local wrapper; + if (!PortArg(info, 0, &wrapper)) { + return; + } + // Null, not a sentinel: the box is what says a message was there at all, so + // a message whose value is undefined stays distinguishable from none. + info.GetReturnValue().SetNull(); + NativeMessagePort* port = PortFromWrapper(isolate, wrapper); + if (port == nullptr || port->IsDetached()) { + return; + } + Local context = isolate->GetCurrentContext(); + std::shared_ptr self = port->shared_from_this(); + Local message; + bool received = false; + if (!self->ReceiveOne(context, &message).To(&received) || !received) { + return; + } + Local box = Object::New(isolate); + if (box->Set(context, ArgConverter::ConvertToV8String(isolate, "message"), message) + .FromMaybe(false)) { + info.GetReturnValue().Set(box); + } +} + +void IsDetachedCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + Local wrapper; + if (!PortArg(info, 0, &wrapper)) { + return; + } + NativeMessagePort* port = PortFromWrapper(isolate, wrapper); + info.GetReturnValue().Set(port == nullptr || port->IsDetached()); +} + +void SetEmitMessageCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + MessagingState* state = State(isolate); + if (state == nullptr || info.Length() < 1 || !info[0]->IsFunction()) { + return; + } + state->emitMessage.Reset(isolate, info[0].As()); +} + +void SetEnvironmentDataCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1) { + return; + } + Local context = isolate->GetCurrentContext(); + std::string key = ArgConverter::ToString(isolate, info[0]); + if (info.Length() < 2 || info[1]->IsUndefined()) { + std::lock_guard lock(g_environmentDataMutex); + g_environmentData.erase(key); + return; + } + // Cloned on the way in, so a later mutation of the value the caller kept is + // not visible to the threads that read it. + auto stored = std::make_shared(); + if (stored->Serialize(isolate, context, info[1], v8::Undefined(isolate), + serialization::HostObjectPolicy::kReject) + .IsNothing()) { + return; + } + std::lock_guard lock(g_environmentDataMutex); + g_environmentData[key] = std::move(stored); +} + +void GetEnvironmentDataCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1) { + return; + } + std::string key = ArgConverter::ToString(isolate, info[0]); + std::shared_ptr stored; + { + std::lock_guard lock(g_environmentDataMutex); + auto entry = g_environmentData.find(key); + if (entry == g_environmentData.end()) { + return; + } + stored = entry->second; + } + // Read back outside the lock: the read runs JS, and a value stored without a + // transfer list can be read any number of times, on any isolate. + Local value; + if (stored->Deserialize(isolate, isolate->GetCurrentContext()).ToLocal(&value)) { + info.GetReturnValue().Set(value); + } +} + +void MarkAsUntransferableCallback(const FunctionCallbackInfo& info) { + StampBrand(info, UntransferableBrand); +} + +void MarkAsUncloneableCallback(const FunctionCallbackInfo& info) { + StampBrand(info, UncloneableBrand); +} + +void IsMarkedAsUntransferableCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsObject()) { + info.GetReturnValue().Set(false); + return; + } + bool marked = false; + if (IsMarkedUntransferable(isolate, info[0].As()).To(&marked)) { + info.GetReturnValue().Set(marked); + } +} + +} // namespace + +MaybeLocal CreateBinding(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + if (State(isolate) == nullptr) { + return MaybeLocal(); + } + Local binding = Object::New(isolate); + + // Constant for the lifetime of the isolate, so they are values rather than + // calls. Node numbers the main thread 0; this runtime numbers its workers + // from 1 and leaves the main runtime's own id unset. + WorkerWrapper* worker = WorkerWrapper::FromIsolate(isolate); + bool isWorker = worker != nullptr; + if (!binding + ->Set(context, ArgConverter::ConvertToV8String(isolate, "isMainThread"), + v8::Boolean::New(isolate, !isWorker)) + .FromMaybe(false) || + !binding + ->Set(context, ArgConverter::ConvertToV8String(isolate, "threadId"), + v8::Integer::New(isolate, isWorker ? worker->WorkerId() : 0)) + .FromMaybe(false)) { + return MaybeLocal(); + } + + tns::SetMethod(context, binding, "createChannel", CreateChannelCallback); + tns::SetMethod(context, binding, "createBroadcastPort", CreateBroadcastPortCallback); + tns::SetMethod(context, binding, "postMessage", PostMessageCallback); + tns::SetMethod(context, binding, "start", StartCallback); + tns::SetMethod(context, binding, "stop", StopCallback); + tns::SetMethod(context, binding, "close", CloseCallback); + tns::SetMethod(context, binding, "drainOne", DrainOneCallback); + tns::SetMethodNoSideEffect(context, binding, "isDetached", IsDetachedCallback); + tns::SetMethod(context, binding, "setEmitMessage", SetEmitMessageCallback); + tns::SetMethod(context, binding, "setEnvironmentData", SetEnvironmentDataCallback); + tns::SetMethod(context, binding, "getEnvironmentData", GetEnvironmentDataCallback); + tns::SetMethod(context, binding, "markAsUntransferable", MarkAsUntransferableCallback); + tns::SetMethodNoSideEffect(context, binding, "isMarkedAsUntransferable", + IsMarkedAsUntransferableCallback); + tns::SetMethod(context, binding, "markAsUncloneable", MarkAsUncloneableCallback); + return binding; +} + +MaybeLocal GetMessageChannelExports(Local context) { + return BuiltinLoader::GetExports(context, BuiltinId::kMessageChannel, CreateBinding); +} + +MaybeLocal GetBroadcastChannelExports(Local context) { + return BuiltinLoader::GetExports(context, BuiltinId::kBroadcastChannel, CreateBinding); +} + +} // namespace messaging +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/Messaging.h b/test-app/runtime/src/main/cpp/Messaging.h new file mode 100644 index 000000000..78019e57c --- /dev/null +++ b/test-app/runtime/src/main/cpp/Messaging.h @@ -0,0 +1,216 @@ +#ifndef MESSAGING_H_ +#define MESSAGING_H_ + +#include +#include +#include +#include +#include + +#include "v8.h" + +namespace tns { + +class EventLoop; + +namespace serialization { +class SerializedValue; +} + +namespace messaging { + +class NativeMessagePort; +class SiblingGroup; + +// What a port's group did with a message handed to it. +enum class DispatchResult { + // Queued on at least one destination. + kDelivered, + // The group has no other member; the message is dropped. + kNoDestination, + // Nothing was queued and the caller must not treat the send as done: the + // port is not entangled, or the message carries transferables and the group + // has more than one destination. The out-parameter says which. + kFailed, +}; + +// Everything about a port that is not tied to an isolate, so it can be moved +// into a message and adopted on the receiving side. +// +// `mutex_` is the only lock a producer on a foreign thread ever takes. Lock +// order across the whole subsystem is SiblingGroup's lock FIRST, a port's +// mutex_ second; never the reverse. Every path that needs both — dispatch, +// entangle, disentangle — is entered through the group. +class PortData { +public: + explicit PortData(NativeMessagePort* owner); + ~PortData(); + + PortData(const PortData&) = delete; + PortData& operator=(const PortData&) = delete; + + // The one cross-thread entry point. Appends `message` and wakes the owning + // port while STILL holding the mutex, so a port detaching concurrently + // either takes the mutex first and is never woken, or waits and observes the + // queued message. + void AddToIncomingQueue(std::shared_ptr message); + + // Hands `message` to every other member of this port's group. + DispatchResult Dispatch(std::shared_ptr message, + std::string* error); + + // Connects the two ends of a fresh channel. Neither end may already belong + // to a group. + static void Entangle(PortData* a, PortData* b); + + // Leaves the group, queueing a close sentinel on this port and — for an + // anonymous pair — on the sibling left behind. Once this returns, no other + // thread can reach this object through the group. Owner thread only. + void Disentangle(); + +private: + friend class NativeMessagePort; + friend class SiblingGroup; + + std::mutex mutex_; + std::deque> incoming_; + NativeMessagePort* owner_ = nullptr; + std::shared_ptr group_; +}; + +// The isolate-bound half of a port: the JS wrapper, the delivery callout and +// the drain that runs on the owning runtime's event loop. Home-thread only, +// TriggerAsync excepted. +class NativeMessagePort : public std::enable_shared_from_this { +public: + ~NativeMessagePort(); + + NativeMessagePort(const NativeMessagePort&) = delete; + NativeMessagePort& operator=(const NativeMessagePort&) = delete; + + // Creates a port and its JS wrapper. With `data` the port adopts an + // in-flight port — the group travels with the data — and schedules a drain + // of whatever queued up while it was in transit; with `group` it joins that + // named group; with neither it is one unentangled end of a new channel. + // Null with an exception pending when the wrapper or the JS tier could not + // be built. + static std::shared_ptr New(v8::Local context, + std::unique_ptr data = nullptr, + std::shared_ptr group = nullptr); + + // Schedules a drain. Any thread; the caller must hold this port's data + // mutex, which is what keeps the port from detaching underneath the post. + void TriggerAsync(); + + // HTML's port message queue enable/disable. Starting a port with a backlog + // schedules a drain for it. + void Start(); + void Stop(); + + // Detaches the data, sentinels the sibling, drops the JS wrapper and fires + // the tier's close event on it. Safe to call on an already-closed port, and + // safe to call from inside that event. + void Close(); + + // Drops the data out of the port and out of its group, so nothing can reach + // it any more. What the teardown sweep does to a port app code never closed. + void OrphanData(); + + // Pops one message regardless of whether the port was started + // (receiveMessageOnPort). Just(false) when the queue holds nothing + // deliverable, Just(true) with `out` set otherwise, Nothing when the value + // could not be read. + v8::Maybe ReceiveOne(v8::Local context, v8::Local* out); + + // The [[Detached]] internal slot. + bool IsDetached() const { + return this->data_ == nullptr; + } + + // Moves the data into a message. The handle side closes, but the data keeps + // its group membership and its queue: senders keep queueing into it while it + // is in flight, and with no owner nothing is woken. + std::unique_ptr TransferForMessaging(); + + // Empty once the port has been closed. + v8::Local Wrapper(v8::Isolate* isolate) const; + + PortData* Data() const { + return this->data_.get(); + } + +private: + NativeMessagePort(v8::Isolate* isolate, v8::Local wrapper); + + std::unique_ptr Detach(); + void CloseHandle(); + void EmitClose(v8::Local wrapper); + void Drain(); + std::shared_ptr TakeMessage(bool force); + bool Emit(v8::Local context, v8::Local receiver, + v8::Local emitMessage, v8::Local data, + v8::Local ports, const char* type); + + std::unique_ptr data_; + bool receiving_ = false; + // Set while a drain is queued, so a burst of messages costs one post. + // Atomic because producers flip it from their own threads. + std::atomic scheduled_{false}; + // Strong on purpose: a port and its JS wrapper stay alive until the port is + // closed, which is the lifetime model HTML and Node specify — reachability + // plays no part in it. + v8::Global wrapper_; + // Only ever dereferenced through Runtime::TryGetRuntime, which answers null + // once the runtime behind it is gone; the raw pointer is never touched + // otherwise. + v8::Isolate* isolate_ = nullptr; + // Held by shared_ptr so a drain posted from a foreign thread can never race + // the loop's own teardown. + std::shared_ptr loop_; +}; + +// The port behind a JS wrapper, or null when `object` is not a port wrapper or +// its port has been closed. +NativeMessagePort* PortFromWrapper(v8::Isolate* isolate, v8::Local object); + +// Whether `object` is a MessagePort wrapper at all, closed or not. The +// serializer needs the distinction: a closed port in a transfer list is a +// different error from a value that was never transferable. +bool IsPortWrapper(v8::Isolate* isolate, v8::Local object); + +// Adopts an in-flight port on this isolate and returns its fresh wrapper. +v8::MaybeLocal AdoptPort(v8::Local context, + std::unique_ptr data); + +// The markAsUntransferable / markAsUncloneable brands. Both answer Just(false) +// without creating anything when this isolate has never stamped one. +v8::Maybe IsMarkedUntransferable(v8::Isolate* isolate, v8::Local object); +v8::Maybe IsMarkedUncloneable(v8::Isolate* isolate, v8::Local object); + +// The markAsUncloneable brand itself, empty when this isolate has never +// stamped one. For the serializer, which is asked about every object in a +// claimed graph and hoists the lookup out of that loop. +v8::Local UncloneableBrandIfAny(v8::Isolate* isolate); + +// The natives behind the message-channel builtin: channel and port +// primitives, the two registration hooks the JS tier calls once per isolate, +// and the transfer brands. +v8::MaybeLocal CreateBinding(v8::Local context); + +// The two builtins' exports with that binding attached. GetExports consults +// the factory only on the run that populates the cache, so every call site for +// these builtins must go through here — a site passing a different factory +// would win or lose by init order. +v8::MaybeLocal GetMessageChannelExports(v8::Local context); +v8::MaybeLocal GetBroadcastChannelExports(v8::Local context); + +// Force-closes every port this isolate still owns: the data is orphaned and +// disentangled, so siblings on other isolates get their close sentinels and +// nothing can reach this isolate's ports afterwards. Must run after the event +// loop has stopped and while the isolate is still locked. +void CloseAllPorts(v8::Isolate* isolate); + +} // namespace messaging +} // namespace tns + +#endif /* MESSAGING_H_ */ diff --git a/test-app/runtime/src/main/cpp/NativeScriptException.cpp b/test-app/runtime/src/main/cpp/NativeScriptException.cpp index 8d9f1e1e0..16fde7c03 100644 --- a/test-app/runtime/src/main/cpp/NativeScriptException.cpp +++ b/test-app/runtime/src/main/cpp/NativeScriptException.cpp @@ -816,10 +816,12 @@ void PromiseRejectionTracker::ScheduleDrain() { * Gives a worker's global `onerror` a chance to handle a rejected reason, * mirroring CallbackHandlers::CallWorkerScopeOnErrorHandle (which passes the * message as a string). Returns true when the handler signalled it consumed - * the error (truthy return). + * the error (truthy return). A handler that throws replaces the reason: + * `thrown` receives its exception, and the caller forwards that instead of the + * original, the way the other worker error paths do. */ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local context, - const string& message) { + const string& message, Local* thrown) { auto global = context->Global(); Local onErrorVal; if (!global->Get(context, ArgConverter::ConvertToV8String(isolate, "onerror")) @@ -834,7 +836,13 @@ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local context, TryCatch tc(isolate); bool success = onError->Call(context, Undefined(isolate), 1, args).ToLocal(&result); - return success && !result.IsEmpty() && result->BooleanValue(isolate); + if (!success) { + if (tc.HasCaught() && !tc.HasTerminated()) { + *thrown = tc.Exception(); + } + return false; + } + return !result.IsEmpty() && result->BooleanValue(isolate); } void PromiseRejectionTracker::Drain() { @@ -905,10 +913,24 @@ void PromiseRejectionTracker::Drain() { reason)) { string message = "Unhandled promise rejection: " + ToDetailString(isolate, reason); - if (!GiveWorkerOnErrorAChance(isolate, context, message) && + Local thrown; + if (!GiveWorkerOnErrorAChance(isolate, context, message, &thrown) && !workerWrapper->IsTerminating() && !workerWrapper->IsDisposed()) { + string forwarded = message; + string forwardedStack = stackTrace; + if (!thrown.IsEmpty()) { + forwarded = ToDetailString(isolate, thrown); + // The handler's own stack replaces the reason's; an accessor + // that throws costs the stack, never the forward. + forwardedStack = ""; + auto stack = Exception::GetStackTrace(thrown); + if (!stack.IsEmpty()) { + forwardedStack = + NativeScriptException::GetErrorStackTrace(stack); + } + } workerWrapper->PassUncaughtExceptionFromWorkerToParent( - message, "", stackTrace, 0); + forwarded, "", forwardedStack, 0); } } } else { diff --git a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp index 1e1d2bba3..ee5e81f7a 100644 --- a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp +++ b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp @@ -6,6 +6,7 @@ #include "ArgConverter.h" #include "BuiltinLoader.h" +#include "Messaging.h" #include "ModuleInternalCallbacks.h" #include "NativeScriptAssert.h" #include "Runtime.h" @@ -59,9 +60,14 @@ constexpr Registration kRegistry[] = { {"node:module", BuiltinId::kNodeModule, nullptr}, {"node:url", BuiltinId::kNodeUrl, nullptr}, {"node:util", BuiltinId::kNodeUtil, nullptr}, + {"node:worker_threads", BuiltinId::kNodeWorkerThreads, messaging::CreateBinding}, + {"internal/broadcast-channel", BuiltinId::kBroadcastChannel, messaging::CreateBinding, + true}, {"internal/dom-exception", BuiltinId::kDomException, serialization::DomExceptionBinding, true}, {"internal/events", BuiltinId::kEvents, nullptr, true}, + {"internal/message-channel", BuiltinId::kMessageChannel, messaging::CreateBinding, true}, + {"internal/message-event", BuiltinId::kMessageEvent, nullptr, true}, }; constexpr const char* kDebugKey = "debug"; diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index d7ab015e8..e8eb2a87a 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -28,6 +28,7 @@ #include "JsArgToArrayConverter.h" #include "LazyGlobals.h" #include "ManualInstrumentation.h" +#include "Messaging.h" #include "MetadataNode.h" #include "ModuleBinding.h" #include "ModuleInternal.h" @@ -49,6 +50,7 @@ #include "V8StringConstants.h" #include "Version.h" #include "WeakRef.h" +#include "WorkerEvents.h" #include "include/libplatform/libplatform.h" #include "sys/system_properties.h" @@ -1009,6 +1011,10 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, // PromiseRejectionEvent, reportError and the native dispatch closures) - // installed for both the main and worker isolates. Events::Init(context); + // Worker and the worker global scope as EventTargets, on top of those + // primitives. Before ErrorEvents::Init, whose ErrorEvent constructor the + // parent-side error callout picks up lazily on first use. + WorkerEvents::Init(context); ErrorEvents::Init(context); StructuredClone::Init(context); @@ -1201,6 +1207,12 @@ void Runtime::DestroyRuntime() { m_objectManager->ReleaseAllRegistered(); } + // After the loop stopped: a port force-closed here can no longer be woken, + // and the disentangle both delivers the close sentinels this isolate's + // siblings are owed and puts each port's queue beyond the reach of the + // threads that were filling it. + messaging::CloseAllPorts(m_isolate); + // Everything below still needs the isolate alive -- the caller disposes it // only after this returns -- but runs after the hooks above so nothing they // touch is pulled out from under them. diff --git a/test-app/runtime/src/main/cpp/StructuredSerialization.cpp b/test-app/runtime/src/main/cpp/StructuredSerialization.cpp index 75a5c2877..11461c77c 100644 --- a/test-app/runtime/src/main/cpp/StructuredSerialization.cpp +++ b/test-app/runtime/src/main/cpp/StructuredSerialization.cpp @@ -122,25 +122,31 @@ namespace { /* * Every host object's payload starts with one of these, so the reader can - * dispatch. kHostObjectDegraded carries nothing further; kHostObjectDomException - * carries a uint32 index into the SerializedValue's out-of-band payload list. - * The bytes never outlive the process (structuredClone round-trips in one - * isolate, worker messages cross isolates in the same binary), so the format can - * evolve freely with this file. + * dispatch. kHostObjectDegraded carries nothing further; the other two carry a + * uint32 index into one of the SerializedValue's out-of-band lists. The bytes + * never outlive the process (structuredClone round-trips in one isolate, worker + * messages cross isolates in the same binary), so the format can evolve freely + * with this file. */ constexpr uint32_t kHostObjectDegraded = 0; constexpr uint32_t kHostObjectDomException = 1; +constexpr uint32_t kHostObjectMessagePort = 2; + +using PortList = std::vector>; class SerializerDelegate : public ValueSerializer::Delegate { public: SerializerDelegate(Isolate* isolate, HostObjectPolicy hostObjectPolicy, std::vector>* sharedBuffers, - std::vector* domExceptions) + std::vector* domExceptions, + const PortList* transferPorts) : isolate_(isolate), hostObjectPolicy_(hostObjectPolicy), sharedBuffers_(sharedBuffers), domExceptions_(domExceptions), - domExceptionBrand_(DomExceptionBrand(isolate)) {} + transferPorts_(transferPorts), + domExceptionBrand_(DomExceptionBrand(isolate)), + uncloneableBrand_(messaging::UncloneableBrandIfAny(isolate)) {} void SetSerializer(ValueSerializer* serializer) { serializer_ = serializer; } @@ -166,6 +172,16 @@ class SerializerDelegate : public ValueSerializer::Delegate { if (object->InternalFieldCount() > 0) { return Just(true); } + if (!uncloneableBrand_.IsEmpty()) { + bool uncloneable = false; + if (!object->HasPrivate(isolate->GetCurrentContext(), uncloneableBrand_) + .To(&uncloneable)) { + return Nothing(); + } + if (uncloneable) { + return Just(true); + } + } if (domExceptionBrand_.IsEmpty()) { return Just(false); } @@ -173,6 +189,21 @@ class SerializerDelegate : public ValueSerializer::Delegate { } Maybe WriteHostObject(Isolate* isolate, Local object) override { + // Ports are claimed ahead of every policy: transferring one is explicit + // intent, so a port in the graph is either in the transfer list or an + // error — degrading it under kDegrade would strand its sibling forever. + if (messaging::IsPortWrapper(isolate, object)) { + return WritePort(isolate, object); + } + bool uncloneable = false; + if (!messaging::IsMarkedUncloneable(isolate, object).To(&uncloneable)) { + return Nothing(); + } + if (uncloneable) { + serialization::ThrowDataCloneError(isolate, + "Cannot clone object of unsupported type."); + return Nothing(); + } // DOMException serializes under both policies: it is [Serializable] in // the IDL, and it is a plain JS object with no native half to lose. bool isDomException = false; @@ -222,6 +253,32 @@ class SerializerDelegate : public ValueSerializer::Delegate { } private: + /* + * A port is written as its position in the transfer list; the port itself + * travels out of band. Nothing is detached here — the whole graph has to + * write successfully before anything changes hands. + */ + Maybe WritePort(Isolate* isolate, Local object) { + messaging::NativeMessagePort* port = messaging::PortFromWrapper(isolate, object); + if (port == nullptr || port->IsDetached()) { + serialization::ThrowDataCloneError(isolate, + "Cannot clone object of unsupported type."); + return Nothing(); + } + for (size_t i = 0; i < transferPorts_->size(); i++) { + if ((*transferPorts_)[i].get() == port) { + serializer_->WriteUint32(kHostObjectMessagePort); + serializer_->WriteUint32(static_cast(i)); + return Just(true); + } + } + serialization::ThrowDataCloneError( + isolate, + "Object that needs transfer was found in message but not listed in " + "transferList"); + return Nothing(); + } + /* * Web IDL's DOMException serialization steps (name and message), plus the * stack, matching Node. The payload travels out-of-band and only an index @@ -258,30 +315,42 @@ class SerializerDelegate : public ValueSerializer::Delegate { HostObjectPolicy hostObjectPolicy_; std::vector>* sharedBuffers_; std::vector* domExceptions_; + const PortList* transferPorts_; // Resolved once per serializer: V8 asks about every object in the graph, // and each lookup would otherwise re-resolve the state slot and push a // fresh handle into the caller's scope. Local domExceptionBrand_; + Local uncloneableBrand_; ValueSerializer* serializer_ = nullptr; }; class DeserializerDelegate : public ValueDeserializer::Delegate { public: DeserializerDelegate(const std::vector>* sharedBuffers, - const std::vector>* domExceptions) - : sharedBuffers_(sharedBuffers), domExceptions_(domExceptions) {} + const std::vector>* domExceptions, + const std::vector>* ports, + std::vector* portsRead) + : sharedBuffers_(sharedBuffers), + domExceptions_(domExceptions), + ports_(ports), + portsRead_(portsRead) {} + + // Set when the stream named a host object this side cannot hand out (an + // unknown tag or an index past the out-of-band lists). No JS may run inside + // the read, so the DataCloneError for it is raised by the caller afterwards. + bool HostObjectReadFailed() const { return hostObjectReadFailed_; } void SetDeserializer(ValueDeserializer* deserializer) { deserializer_ = deserializer; } // No JS may run in here (V8 forbids it during a read); DOMException - // instances were constructed by Deserialize before ReadValue started, and - // this only hands them out. + // instances and port wrappers were built by Deserialize before ReadValue + // started, and this only hands them out. MaybeLocal ReadHostObject(Isolate* isolate) override { uint32_t tag; if (!deserializer_->ReadUint32(&tag)) { - return MaybeLocal(); + return Failed(); } switch (tag) { case kHostObjectDegraded: @@ -292,12 +361,20 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { case kHostObjectDomException: { uint32_t index; if (!deserializer_->ReadUint32(&index) || index >= domExceptions_->size()) { - return MaybeLocal(); + return Failed(); } return (*domExceptions_)[index]; } + case kHostObjectMessagePort: { + uint32_t index; + if (!deserializer_->ReadUint32(&index) || index >= ports_->size()) { + return Failed(); + } + (*portsRead_)[index] = true; + return (*ports_)[index]; + } default: - return MaybeLocal(); + return Failed(); } } @@ -310,20 +387,47 @@ class DeserializerDelegate : public ValueDeserializer::Delegate { } private: + MaybeLocal Failed() { + hostObjectReadFailed_ = true; + return MaybeLocal(); + } + const std::vector>* sharedBuffers_; const std::vector>* domExceptions_; + const std::vector>* ports_; + std::vector* portsRead_; ValueDeserializer* deserializer_ = nullptr; + bool hostObjectReadFailed_ = false; }; /* - * Validates the transfer list and collects it in registration order. The - * detached and detachable checks are load-bearing rather than defensive: - * ArrayBuffer::Detach() aborts the process on a non-detachable buffer instead - * of reporting failure. + * Closes adopted ports that nothing will ever reach: a port lives in the + * isolate's registry until it is closed, so one without a JS handle would be + * pinned for the isolate's lifetime with its sibling queueing into it. + */ +void CloseUnreachablePorts(Isolate* isolate, const std::vector>& ports, + const std::vector* reachable) { + for (size_t i = 0; i < ports.size(); i++) { + if (reachable != nullptr && (*reachable)[i]) { + continue; + } + messaging::NativeMessagePort* port = messaging::PortFromWrapper(isolate, ports[i]); + if (port != nullptr) { + port->Close(); + } + } +} + +/* + * Validates the transfer list and splits it, each half in registration order, + * because the two are handed over by different mechanisms: buffers by id in the + * stream, ports by index into an out-of-band list. The detached and detachable + * checks are load-bearing rather than defensive: ArrayBuffer::Detach() aborts + * the process on a non-detachable buffer instead of reporting failure. */ bool CollectTransferList(Isolate* isolate, Local context, - Local transferList, - std::vector>& transfers) { + Local transferList, Local sourcePort, + std::vector>& transfers, PortList& ports) { if (transferList.IsEmpty() || transferList->IsUndefined() || transferList->IsNull()) { return true; @@ -331,7 +435,7 @@ bool CollectTransferList(Isolate* isolate, Local context, if (!transferList->IsArray()) { isolate->ThrowException(Exception::TypeError(ArgConverter::ConvertToV8String( - isolate, "The transfer list must be an array of ArrayBuffers"))); + isolate, "The transfer list must be an array"))); return false; } @@ -342,29 +446,76 @@ bool CollectTransferList(Isolate* isolate, Local context, if (!list->Get(context, i).ToLocal(&item)) { return false; } - if (!item->IsArrayBuffer()) { - ThrowDataCloneError(isolate, - "A value in the transfer list is not transferable"); + if (!item->IsObject()) { + ThrowDataCloneError(isolate, "Found invalid value in transferList."); + return false; + } + Local entry = item.As(); + + bool untransferable = false; + if (!messaging::IsMarkedUntransferable(isolate, entry).To(&untransferable)) { + return false; + } + if (untransferable) { + ThrowDataCloneError(isolate, "Cannot transfer object of unsupported type."); return false; } - Local buffer = item.As(); - for (const Local& existing : transfers) { - if (existing == buffer) { - ThrowDataCloneError( - isolate, - "The transfer list contains the same ArrayBuffer twice"); + if (entry->IsArrayBuffer()) { + Local buffer = entry.As(); + for (const Local& existing : transfers) { + if (existing == buffer) { + ThrowDataCloneError( + isolate, + "The transfer list contains the same ArrayBuffer twice"); + return false; + } + } + if (buffer->WasDetached() || !buffer->IsDetachable()) { + ThrowDataCloneError(isolate, + "An ArrayBuffer in the transfer list is detached and " + "cannot be transferred"); return false; } + transfers.push_back(buffer); + continue; } - if (buffer->WasDetached() || !buffer->IsDetachable()) { - ThrowDataCloneError(isolate, - "An ArrayBuffer in the transfer list is detached and " - "cannot be transferred"); - return false; + + if (messaging::IsPortWrapper(isolate, entry)) { + // Ports transfer under every policy: the receiving-side plumbing + // lives in Deserialize itself, so kDegrade callers + // (Worker.postMessage) carry ports just as structuredClone does. + // A port cannot travel on itself: the message would arrive on a + // channel its own delivery destroyed. + if (!sourcePort.IsEmpty() && entry == sourcePort) { + ThrowDataCloneError(isolate, "Transfer list contains source port"); + return false; + } + messaging::NativeMessagePort* port = messaging::PortFromWrapper(isolate, entry); + if (port == nullptr || port->IsDetached()) { + ThrowDataCloneError(isolate, + "MessagePort in transfer list is already detached"); + return false; + } + for (const std::shared_ptr& existing : ports) { + if (existing.get() == port) { + ThrowDataCloneError( + isolate, + "Transfer list contains duplicate " + + ArgConverter::ToString(isolate, + entry->GetConstructorName())); + return false; + } + } + // Held strongly for the duration of the write: writing the graph + // runs user getters, and one of them closing a listed port would + // otherwise leave the delegate with a dangling pointer. + ports.push_back(port->shared_from_this()); + continue; } - transfers.push_back(buffer); + ThrowDataCloneError(isolate, "Found invalid value in transferList."); + return false; } return true; } @@ -374,17 +525,20 @@ bool CollectTransferList(Isolate* isolate, Local context, Maybe SerializedValue::Serialize(Isolate* isolate, Local context, Local input, Local transferList, - HostObjectPolicy hostObjectPolicy) { + HostObjectPolicy hostObjectPolicy, + Local sourcePort) { HandleScope handleScope(isolate); Context::Scope contextScope(context); NS_DCHECK(buffer_ == nullptr); std::vector> transfers; - if (!CollectTransferList(isolate, context, transferList, transfers)) { + PortList ports; + if (!CollectTransferList(isolate, context, transferList, sourcePort, transfers, ports)) { return Nothing(); } - SerializerDelegate delegate(isolate, hostObjectPolicy, &sharedBuffers_, &domExceptions_); + SerializerDelegate delegate(isolate, hostObjectPolicy, &sharedBuffers_, &domExceptions_, + &ports); ValueSerializer serializer(isolate, &delegate); delegate.SetSerializer(&serializer); for (size_t i = 0; i < transfers.size(); i++) { @@ -403,6 +557,28 @@ Maybe SerializedValue::Serialize(Isolate* isolate, Local context, return Nothing(); } + // Revalidated after the write, not before it: writing the graph runs user + // getters, and one of them may have closed a listed port or detached a + // listed buffer (V8 still writes such a buffer as a transfer, and detaching + // it again below would succeed on zero bytes). Checked while nothing has + // changed hands yet, so a message that cannot be completed leaves every + // buffer and every port exactly as it found them. + for (const std::shared_ptr& port : ports) { + if (port->IsDetached()) { + ThrowDataCloneError(isolate, + "MessagePort in transfer list is already detached"); + return Nothing(); + } + } + for (const Local& buffer : transfers) { + if (buffer->WasDetached() || !buffer->IsDetachable()) { + ThrowDataCloneError(isolate, + "An ArrayBuffer in the transfer list is detached and " + "cannot be transferred"); + return Nothing(); + } + } + // Only once the value is safely written does the memory change hands: // claim each backing store before detaching, since detaching drops the // buffer's own reference to it. @@ -421,15 +597,47 @@ Maybe SerializedValue::Serialize(Isolate* isolate, Local context, transferredBuffers_.push_back(std::move(backingStore)); } + // Each port's handle side closes here and its data joins the message, + // keeping its group and its queue: senders on the far end go on queueing + // into it while it is in flight, and the receiving port adopts the backlog. + for (const std::shared_ptr& port : ports) { + transferredPorts_.push_back(port->TransferForMessaging()); + } + buffer_ = std::move(owned); bufferSize_ = data.second; return Just(true); } -MaybeLocal SerializedValue::Deserialize(Isolate* isolate, - Local context) { +bool SerializedValue::TransfersPort(const messaging::PortData* data) const { + for (const std::unique_ptr& port : transferredPorts_) { + if (port.get() == data) { + return true; + } + } + return false; +} + +MaybeLocal SerializedValue::Deserialize(Isolate* isolate, Local context, + Local* portList) { Context::Scope contextScope(context); - EscapableHandleScope handleScope(isolate); + // No handle scope of its own: `portList` hands a second handle back to the + // caller, and only one can escape an EscapableHandleScope. Every caller + // opens a scope per message already. + + // A BroadcastChannel hands one message to every listener, which is only + // sound because a fan-out message carries nothing that can be handed over. + // Such a message may be read here from several isolates at once, so the + // consumed flag is written only on the single-receiver path. + if (consumed_) { + ThrowDataCloneError( + isolate, + "A message carrying transferred objects can only be read once."); + return MaybeLocal(); + } + if (HasTransferables()) { + consumed_ = true; + } std::vector> sharedBuffers; for (const std::shared_ptr& backingStore : sharedBuffers_) { @@ -480,7 +688,36 @@ MaybeLocal SerializedValue::Deserialize(Isolate* isolate, } } - DeserializerDelegate delegate(&sharedBuffers, &domExceptions); + // Ports are adopted before the read starts, for the same reason the + // exceptions above are: adopting one runs the JS tier's per-wrapper setup, + // and ReadHostObject may not run JS. The array doubles as what a message + // event hands out as its `ports`. + std::vector> ports; + if (!transferredPorts_.empty()) { + Local list = + v8::Array::New(isolate, static_cast(transferredPorts_.size())); + for (size_t i = 0; i < transferredPorts_.size(); i++) { + Local wrapper; + if (!messaging::AdoptPort(context, std::move(transferredPorts_[i])) + .ToLocal(&wrapper)) { + CloseUnreachablePorts(isolate, ports, nullptr); + return MaybeLocal(); + } + // Recorded before anything else can fail: an adopted port that is + // not in this list would never be closed. + ports.push_back(wrapper); + if (!list->Set(context, static_cast(i), wrapper).FromMaybe(false)) { + CloseUnreachablePorts(isolate, ports, nullptr); + return MaybeLocal(); + } + } + if (portList != nullptr) { + *portList = list; + } + } + + std::vector portsRead(ports.size(), false); + DeserializerDelegate delegate(&sharedBuffers, &domExceptions, &ports, &portsRead); ValueDeserializer deserializer(isolate, buffer_.get(), bufferSize_, &delegate); delegate.SetDeserializer(&deserializer); @@ -490,15 +727,39 @@ MaybeLocal SerializedValue::Deserialize(Isolate* isolate, static_cast(i), ArrayBuffer::New(isolate, std::move(transferredBuffers_[i]))); } + // Handed over above; the vectors would otherwise keep reporting + // transferables that are no longer here. + transferredBuffers_.clear(); + transferredPorts_.clear(); - if (deserializer.ReadHeader(context).IsNothing()) { - return MaybeLocal(); - } Local result; - if (!deserializer.ReadValue(context).ToLocal(&result)) { - return MaybeLocal(); + { + TryCatch tc(isolate); + if (deserializer.ReadHeader(context).IsNothing() || + !deserializer.ReadValue(context).ToLocal(&result)) { + CloseUnreachablePorts(isolate, ports, nullptr); + if (delegate.HostObjectReadFailed() && !tc.HasTerminated()) { + // V8 reports a failed read with its own generic error; a host + // object this side could not hand out is a clone failure like + // every other. + tc.Reset(); + ThrowDataCloneError(isolate, + "A transferred object in the message could not be " + "read on this side."); + } else { + tc.ReThrow(); + } + return MaybeLocal(); + } + } + // A caller that takes no port list (structuredClone, receiveMessageOnPort) + // surfaces a transferred port only through the value itself; a listed port + // the graph never named has no other way out and is closed here, the way an + // unreferenced transferred port is collected on the web. + if (portList == nullptr) { + CloseUnreachablePorts(isolate, ports, &portsRead); } - return handleScope.Escape(result); + return result; } } // namespace serialization diff --git a/test-app/runtime/src/main/cpp/StructuredSerialization.h b/test-app/runtime/src/main/cpp/StructuredSerialization.h index ec7f4fb2c..8d095e6d2 100644 --- a/test-app/runtime/src/main/cpp/StructuredSerialization.h +++ b/test-app/runtime/src/main/cpp/StructuredSerialization.h @@ -6,6 +6,7 @@ #include #include +#include "Messaging.h" #include "v8.h" namespace tns { @@ -66,24 +67,50 @@ class SerializedValue { SerializedValue& operator=(const SerializedValue&) = delete; /* - * Serializes `input`, moving out of this isolate every ArrayBuffer named - * by `transferList` (an Array, or undefined/null for none). Returns - * Nothing with an exception pending: a TypeError when the transfer list is - * not an Array, a DataCloneError for anything wrong with its entries or - * with the value. + * Serializes `input`, moving out of this isolate every ArrayBuffer and + * every MessagePort named by `transferList` (an Array, or undefined/null + * for none). `sourcePort` is the port a message is being posted on, which + * the spec forbids transferring with its own message. Returns Nothing with + * an exception pending: a TypeError when the transfer list is not an + * Array, a DataCloneError for anything wrong with its entries or with the + * value. */ - v8::Maybe Serialize(v8::Isolate* isolate, - v8::Local context, - v8::Local input, - v8::Local transferList, - HostObjectPolicy hostObjectPolicy); + v8::Maybe Serialize( + v8::Isolate* isolate, v8::Local context, + v8::Local input, v8::Local transferList, + HostObjectPolicy hostObjectPolicy, + v8::Local sourcePort = v8::Local()); /* - * Reads the value back into `context`. Transferred buffers are consumed, - * so this runs once per serialized value. + * Reads the value back into `context`, filling `portList` (when given) + * with the wrappers of the ports the message transferred. A value carrying + * anything transferred can be read exactly once — the memory and the ports + * change hands; one carrying only clones may be read any number of times, + * which is what lets a BroadcastChannel fan one message out. */ - v8::MaybeLocal Deserialize(v8::Isolate* isolate, - v8::Local context); + v8::MaybeLocal Deserialize( + v8::Isolate* isolate, v8::Local context, + v8::Local* portList = nullptr); + + /* + * The close sentinel a sibling group queues when a channel goes away: a + * message with no payload at all. + */ + bool IsCloseMessage() const { return this->buffer_ == nullptr; } + + /* + * Whether anything in here can only be handed over once, which is what + * makes a message undeliverable to more than one destination. + */ + bool HasTransferables() const { + return !this->transferredBuffers_.empty() || !this->transferredPorts_.empty(); + } + + /* + * Whether `data` is one of the ports this message carries — a message + * transferring its own destination destroys the channel it travels on. + */ + bool TransfersPort(const messaging::PortData* data) const; /* * Web IDL's DOMException serialization steps (name, message) plus the @@ -113,6 +140,13 @@ class SerializedValue { std::vector> transferredBuffers_; // Backing stores shared with — not moved from — the sending isolate. std::vector> sharedBuffers_; + // Ports moved out of the sending isolate, in transfer-list order: the wire + // carries the index, the port itself travels here. Each keeps its group and + // its queue, so senders can go on queueing into it while it is in flight. + std::vector> transferredPorts_; + // Set by the first read of a message that had something to hand over, so a + // second read is caught rather than handing out emptied slots. + bool consumed_ = false; // One entry per distinct DOMException in the graph, in write order (a // repeated reference is an object id in the stream, not a second entry). std::vector domExceptions_; diff --git a/test-app/runtime/src/main/cpp/WorkerEvents.cpp b/test-app/runtime/src/main/cpp/WorkerEvents.cpp new file mode 100644 index 000000000..aea42802a --- /dev/null +++ b/test-app/runtime/src/main/cpp/WorkerEvents.cpp @@ -0,0 +1,125 @@ +#include "WorkerEvents.h" + +#include "ArgConverter.h" +#include "BuiltinLoader.h" +#include "NativeScriptException.h" +#include "Runtime.h" +#include "RuntimeState.h" + +using namespace v8; + +namespace tns { + +namespace { + +/* + * The worker-events builtin's delivery callouts for this isolate. Both message + * directions share emitMessage; only the receiver differs. emitError is + * parent-side only. + */ +struct WorkerEventsState { + Global emitMessage; + Global emitError; +}; + +Local CalloutOf(Local exports, Isolate* isolate, const char* name) { + Local callout; + if (!exports->Get(isolate->GetCurrentContext(), + ArgConverter::ConvertToV8String(isolate, name)) + .ToLocal(&callout) || + !callout->IsFunction()) { + throw NativeScriptException(std::string("WorkerEvents::Init: the worker-events " + "bootstrap did not return ") + + name); + } + return callout.As(); +} + +} // namespace + +void WorkerEvents::Init(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + + Local exports; + if (!BuiltinLoader::GetExports(context, BuiltinId::kWorkerEvents, nullptr) + .ToLocal(&exports)) { + throw NativeScriptException("WorkerEvents::Init: the worker-events bootstrap failed"); + } + + Local emitMessage = CalloutOf(exports, isolate, "emitMessage"); + Local emitError = CalloutOf(exports, isolate, "emitError"); + + auto* state = RuntimeState::For(isolate); + if (state == nullptr) { + throw NativeScriptException("WorkerEvents::Init: no runtime state for isolate"); + } + state->emitMessage.Reset(isolate, emitMessage); + state->emitError.Reset(isolate, emitError); +} + +void WorkerEvents::EmitMessage(Isolate* isolate, Local receiver, + const std::shared_ptr& message) { + auto* state = RuntimeState::For(isolate); + if (state == nullptr || state->emitMessage.IsEmpty()) { + return; + } + Runtime* runtime = Runtime::TryGetRuntime(isolate); + if (runtime == nullptr) { + return; + } + Local context = runtime->GetContext(); + + Local data; + Local ports; + const char* type = "message"; + { + TryCatch tc(isolate); + if (!message->Deserialize(isolate, context, &ports).ToLocal(&data)) { + if (tc.HasTerminated()) { + return; + } + // HTML: a message that cannot be read still reaches its target, as + // a `messageerror` event carrying nothing. + tc.Reset(); + data = v8::Undefined(isolate); + ports = Local(); + type = "messageerror"; + } + } + + Local args[3]{data, + ports.IsEmpty() ? v8::Undefined(isolate).As() : ports, + ArgConverter::ConvertToV8String(isolate, type)}; + Local result; + // A throw here is left pending on purpose: on the worker side the drain's + // TryCatch turns it into the scope's error event, and on the parent side + // the internal-lane entry runs with no TryCatch of its own, so V8's + // uncaught-message listener reports it. + (void)state->emitMessage.Get(isolate)->Call(context, receiver, 3, args).ToLocal(&result); +} + +bool WorkerEvents::EmitError(Isolate* isolate, Local receiver, + const std::string& message, const std::string& source, + const std::string& stackTrace, int lineNumber) { + auto* state = RuntimeState::For(isolate); + if (state == nullptr || state->emitError.IsEmpty()) { + return false; + } + Runtime* runtime = Runtime::TryGetRuntime(isolate); + if (runtime == nullptr) { + return false; + } + Local context = runtime->GetContext(); + + Local args[4]{ArgConverter::ConvertToV8String(isolate, message), + ArgConverter::ConvertToV8String(isolate, source), + Number::New(isolate, lineNumber), + ArgConverter::ConvertToV8String(isolate, stackTrace)}; + Local result; + if (!state->emitError.Get(isolate)->Call(context, receiver, 4, args).ToLocal(&result)) { + return false; + } + return result->BooleanValue(isolate); +} + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/WorkerEvents.h b/test-app/runtime/src/main/cpp/WorkerEvents.h new file mode 100644 index 000000000..9a011df69 --- /dev/null +++ b/test-app/runtime/src/main/cpp/WorkerEvents.h @@ -0,0 +1,56 @@ +#ifndef WORKEREVENTS_H_ +#define WORKEREVENTS_H_ + +#include +#include + +#include "WorkerMessage.h" +#include "v8.h" + +namespace tns { + +/* + * The Worker object and the worker global scope as EventTargets. The JS tier + * (internal/worker-events.js) grafts Worker.prototype onto EventTarget's, + * defines the handler attributes on both it and the target backing the global + * scope, and exports the two callouts native delivery goes through. + */ +class WorkerEvents { +public: + /* + * Runs the worker-events builtin and caches its callouts for this isolate. + * Evaluated once per isolate during PrepareV8Runtime, after Events::Init + * has installed the event primitives it builds on and before + * ErrorEvents::Init - the ErrorEvent constructor it needs is taken lazily, + * on the first error delivery. + */ + static void Init(v8::Local context); + + /* + * Builds a MessageEvent out of `message` and dispatches it on `receiver` - + * the Worker object for worker-to-parent traffic, the global scope's + * EventTarget for parent-to-worker. A message that cannot be read arrives + * as a `messageerror` event carrying nothing. A handler that throws leaves + * the exception pending for the caller's TryCatch, which owns the worker's + * error chain. No-op before Init has run. + */ + static void EmitMessage(v8::Isolate* isolate, v8::Local receiver, + const std::shared_ptr& message); + + /* + * Dispatches a cancelable `error` ErrorEvent on `receiver` (the Worker + * object, on the parent isolate) and returns whether a handler took + * ownership of it - either by returning truthy from the `onerror` + * attribute or by calling preventDefault(). Only primitives cross the + * isolate boundary, so the event carries no error object. A listener that + * throws leaves the exception pending for the caller's TryCatch and + * reports as unhandled. False before Init has run. + */ + static bool EmitError(v8::Isolate* isolate, v8::Local receiver, + const std::string& message, const std::string& source, + const std::string& stackTrace, int lineNumber); +}; + +} // namespace tns + +#endif /* WORKEREVENTS_H_ */ diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index f53648480..7bc272b24 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -8,6 +8,8 @@ #include "ArgConverter.h" #include "CallbackHandlers.h" #include "CrashBreadcrumbs.h" +#include "ErrorEvents.h" +#include "EventLoop.h" #include "JEnv.h" #include "JniLocalRef.h" #include "ModuleInternal.h" @@ -15,6 +17,7 @@ #include "NativeScriptException.h" #include "NativeScriptPlatform.h" #include "Runtime.h" +#include "WorkerEvents.h" #include @@ -29,6 +32,24 @@ namespace tns { namespace { +/* + * An uncaught exception from a JS callback the event loop's internal lane + * drove. While a pump is on the stack, returning to Java is not the next act + * and arming a pending Java exception would be illegal, so the loop raises it + * from its next ordered dispatch instead. + */ +void ReportFromEventLoopEntry(Isolate* isolate, TryCatch& tc) { + if (EventLoop::IsPumping()) { + auto runtime = Runtime::TryGetRuntime(isolate); + auto eventLoop = runtime == nullptr ? nullptr : runtime->GetEventLoop(); + if (eventLoop != nullptr) { + eventLoop->DeferJavaThrow(std::make_shared(tc)); + } + return; + } + NativeScriptException(tc).ReThrowToJava(); +} + /* * Reports a worker entry that failed to evaluate, with the web's order: the * worker scope's own `onerror` gets first refusal (a truthy return consumes the @@ -62,13 +83,26 @@ void ReportEntryRejection(Isolate* isolate, Local reason, onError->IsFunction()) { Local args[] = {ArgConverter::ConvertToV8String(isolate, message)}; Local result; - // A handler that throws has not consumed anything - the failure falls - // through to the parent, as if no handler had been installed. TryCatch tc(isolate); - if (onError.As() - ->Call(context, Undefined(isolate), 1, args) - .ToLocal(&result) && - !result.IsEmpty() && result->BooleanValue(isolate)) { + bool called = onError.As() + ->Call(context, Undefined(isolate), 1, args) + .ToLocal(&result); + if (called && !result.IsEmpty() && result->BooleanValue(isolate)) { + // Truthy return means handled, which is where the web stops + // propagation. + return; + } + if (!called && tc.HasCaught() && !tc.HasTerminated()) { + // A handler that threw replaces the reason it was offered: its own + // error reaches the parent, and the original does not. + Local thrown = tc.Exception(); + std::string thrownStack; + auto stack = Exception::GetStackTrace(thrown); + if (!stack.IsEmpty()) { + thrownStack = NativeScriptException::GetErrorStackTrace(stack); + } + wrapper->PassUncaughtExceptionFromWorkerToParent( + ArgConverter::ToString(isolate, thrown), "", thrownStack, 0); return; } } @@ -252,7 +286,6 @@ int WorkerWrapper::DrainPendingTasks() { HandleScope handle_scope(isolate); auto context = runtime_->GetContext(); Context::Scope context_scope(context); - auto globalObject = context->Global(); // WHATWG parity: the implicit port's message queue starts disabled and is // enabled once the entry script has finished evaluating (including after a @@ -263,6 +296,15 @@ int WorkerWrapper::DrainPendingTasks() { return 0; } + // Messages dispatch on the EventTarget backing the global scope's listener + // methods rather than on globalThis, so app code replacing + // globalThis.dispatchEvent cannot intercept delivery. + auto& globalEventTarget = runtime_->GlobalEventTarget(); + if (globalEventTarget.IsEmpty()) { + return 0; + } + auto globalTarget = globalEventTarget.Get(isolate); + auto messages = queue_.PopAll(); if (messages.empty()) { return 0; @@ -277,23 +319,7 @@ int WorkerWrapper::DrainPendingTasks() { TryCatch tc(isolate); - Local callback; - if (!globalObject->Get(context, ArgConverter::ConvertToV8String(isolate, "onmessage")) - .ToLocal(&callback) || - !callback->IsFunction()) { - DEBUG_WRITE( - "WORKER: couldn't fire a worker's `onmessage` callback because it isn't implemented!"); - continue; - } - - Local data; - if (message->Deserialize(isolate, context).ToLocal(&data)) { - auto event = Object::New(isolate); - event->DefineOwnProperty(context, ArgConverter::ConvertToV8String(isolate, "data"), - data, PropertyAttribute::ReadOnly); - Local args[] = {event}; - callback.As()->Call(context, Undefined(isolate), 1, args); - } + WorkerEvents::EmitMessage(isolate, globalTarget, message); if (tc.HasCaught() && !isTerminating_) { CallbackHandlers::CallWorkerScopeOnErrorHandle(isolate, tc); @@ -322,7 +348,7 @@ void WorkerWrapper::FireMessageOnParentWorkerObject(int workerId, if (wrapper->poWorker_ == nullptr || wrapper->poWorker_->IsEmpty()) { DEBUG_WRITE( - "MAIN: couldn't fire a worker(id=%d) object's `onmessage` callback because the worker has been cleared.", + "MAIN: couldn't deliver a worker(id=%d) message because the worker has been cleared.", workerId); return; } @@ -332,37 +358,14 @@ void WorkerWrapper::FireMessageOnParentWorkerObject(int workerId, Context::Scope context_scope(context); try { - Local callback; - if (!worker->Get(context, ArgConverter::ConvertToV8String(isolate, "onmessage")) - .ToLocal(&callback) || - !callback->IsFunction()) { - DEBUG_WRITE( - "MAIN: couldn't fire a worker(id=%d) object's `onmessage` callback because it isn't implemented.", - workerId); - return; - } - - Local data; - { - // Reading runs JS (a DOMException is rebuilt through its - // constructor), so a failure here must not stay pending on the - // isolate past this callout. - TryCatch tc(isolate); - if (!message->Deserialize(isolate, context).ToLocal(&data)) { - if (!tc.HasTerminated() && tc.HasCaught()) { - DEBUG_WRITE_FORCE("MAIN: worker(id=%d) message could not be read: %s", - workerId, - ArgConverter::ToString(isolate, tc.Exception()).c_str()); - } - return; - } + // A listener that throws has no JS frame below it to unwind into, so it + // is reported here the way a timer callback's exception is. + TryCatch tc(isolate); + WorkerEvents::EmitMessage(isolate, worker, message); + if (tc.HasCaught() && + !NativeScriptException::ContainUncaughtCallbackException(isolate, tc)) { + ReportFromEventLoopEntry(isolate, tc); } - - auto event = Object::New(isolate); - event->DefineOwnProperty(context, ArgConverter::ConvertToV8String(isolate, "data"), data, - PropertyAttribute::ReadOnly); - Local args[] = {event}; - callback.As()->Call(context, Undefined(isolate), 1, args); } catch (NativeScriptException& ex) { ex.ReThrowToV8(); } @@ -410,7 +413,7 @@ void WorkerWrapper::FireErrorOnParentWorkerObject(int workerId, const std::strin try { if (wrapper->poWorker_ == nullptr || wrapper->poWorker_->IsEmpty()) { DEBUG_WRITE( - "MAIN: couldn't fire a worker(id=%d) object's `onerror` callback because the worker has been cleared.", + "MAIN: couldn't deliver a worker(id=%d) error because the worker has been cleared.", workerId); return; } @@ -418,38 +421,38 @@ void WorkerWrapper::FireErrorOnParentWorkerObject(int workerId, const std::strin auto worker = Local::New(isolate, *wrapper->poWorker_); auto context = Runtime::GetRuntime(isolate)->GetContext(); - Local callback; - bool hasOnError = - worker->Get(context, ArgConverter::ConvertToV8String(isolate, "onerror")) - .ToLocal(&callback) && - callback->IsFunction(); - - if (hasOnError) { - auto errEvent = Object::New(isolate); - errEvent->Set(context, ArgConverter::ConvertToV8String(isolate, "message"), - ArgConverter::ConvertToV8String(isolate, message)); - errEvent->Set(context, ArgConverter::ConvertToV8String(isolate, "stackTrace"), - ArgConverter::ConvertToV8String(isolate, stackTrace)); - errEvent->Set(context, ArgConverter::ConvertToV8String(isolate, "filename"), - ArgConverter::ConvertToV8String(isolate, filename)); - errEvent->Set(context, ArgConverter::ConvertToV8String(isolate, "lineno"), - Number::New(isolate, lineno)); - - Local args[] = {errEvent}; - - // If the handler returns a truthy value the exception is handled - // and must not be raised to application level - Local result; - callback.As()->Call(context, Undefined(isolate), 1, args).ToLocal(&result); - if (!result.IsEmpty() && result->BooleanValue(isolate)) { - return; + TryCatch tc(isolate); + bool handled = WorkerEvents::EmitError(isolate, worker, message, filename, stackTrace, + lineno); + if (tc.HasCaught()) { + // A listener that threw replaces the error it was handed; nothing + // further is reported for the original. + if (!NativeScriptException::ContainUncaughtCallbackException(isolate, tc)) { + ReportFromEventLoopEntry(isolate, tc); } + return; + } + if (handled) { + return; } - DEBUG_WRITE( - "Unhandled exception in '%s' thread. file: %s, line %d, message: %s\nStackTrace: %s", - threadName.c_str(), filename.c_str(), lineno, message.c_str(), - stackTrace.c_str()); + // HTML: an error the Worker object leaves unhandled is reported to the + // parent's global scope. Only primitives crossed the isolate boundary, + // so the error object is rebuilt from them here. + Local error = + Exception::Error(ArgConverter::ConvertToV8String(isolate, message)); + if (error->IsObject() && !stackTrace.empty()) { + (void)error.As() + ->Set(context, ArgConverter::ConvertToV8String(isolate, "stack"), + ArgConverter::ConvertToV8String(isolate, stackTrace)) + .FromMaybe(false); + } + if (!ErrorEvents::DispatchError(isolate, error, message, stackTrace)) { + DEBUG_WRITE_FORCE( + "Unhandled exception in '%s' thread. file: %s, line %d, message: %s\nStackTrace: %s", + threadName.c_str(), filename.c_str(), lineno, message.c_str(), + stackTrace.c_str()); + } } catch (NativeScriptException& ex) { ex.ReThrowToV8(); } diff --git a/test-app/runtime/src/main/cpp/js/README.md b/test-app/runtime/src/main/cpp/js/README.md index 8e0b5f5a0..580ce108c 100644 --- a/test-app/runtime/src/main/cpp/js/README.md +++ b/test-app/runtime/src/main/cpp/js/README.md @@ -34,7 +34,13 @@ module.exports = somethingTheCallSiteNeeds; cross-builtin capabilities that must never leak to app code (the `kListenerChanged` hook key abort-signal.js takes from events.js, the `setListenerErrorReporter` setter error-events.js calls): the producer puts - the capability in its `module.exports`, the consumer requires it. + the capability in its `module.exports`, the consumer requires it. The + `internal/events` bag publishes `globalEventTarget`, `CustomEvent`, + `kListenerChanged`, `setListenerErrorReporter`, `Event`, `EventTarget`, + `defineEventHandler` and `dispatchEventRethrowing` — the base classes and the + handler-attribute helper are there because a lazy builtin may not read live + globals (see the rule two sections down), so this is the sanctioned door to + them. `require("internal/…")` at first use runs the file through the shared exports cache — for a consumer of an eager producer that is a cache hit, and a miss runs the producer on demand. A consumer can therefore never @@ -80,8 +86,17 @@ the property with a plain data property. That cache is the same one the interfaces (`ns:util`'s `TextEncoder`) hands out the objects the globals hold, in either access order. Until then nothing of it exists — no compile, no run, no allocation. `text-encoding.js` (`TextEncoder`/`TextDecoder`), `base64.js` -(`atob`/`btoa`) and `dom-exception.js` (`DOMException`) are the current ones; -new globals join by adding a row to `kLazyGlobals`. +(`atob`/`btoa`), `dom-exception.js` (`DOMException`), `message-event.js` +(`MessageEvent`), `message-channel.js` (`MessagePort`/`MessageChannel`) and +`broadcast-channel.js` (`BroadcastChannel`) are the current ones; new globals +join by adding a row to `kLazyGlobals`. + +Two neighbours of that set are deliberately not in it. `worker-events.js` is +**eager**: it defines the handler attributes on `Worker.prototype` and the +worker global scope, which have to exist before app code assigns one. +`node-worker-threads.js` is a **public builtin module** (`node:worker_threads`) +rather than a lazy global — it is reached by specifier, so nothing places a +name for it. An **eager** file can also feed the tier: `events.js` (eager, `Events::Init`) exports `CustomEvent`, and the `CustomEvent` row reads it through the same @@ -105,6 +120,12 @@ The two extra rules a lazy builtin lives by: (`URLSearchParams`, …) capture it into a file-level `const`. A lazy builtin gets the same pristine `primordials`, but the live globals it would capture are whatever user code left behind, so it should not reach for them at all. +- The per-instance wrappers `defineEventHandler` creates live on the target's + **own listener bag**, under a private symbol — never in a WeakMap keyed by + the target. An ObjectManager-registered object (a `Worker`) can be + resurrected by its finalizer while its thread is alive, and a resurrected + object's weak-collection entries are already gone, so a WeakMap would hand + the revived object a fresh, empty handler map. - No `import`/`export` — these are classic function bodies, not modules. - ESLint (`eslint.config.mjs` at the repo root, `npm run lint`) declares `exports`, `require`, `module`, `binding`, `primordials` and the reachable diff --git a/test-app/runtime/src/main/cpp/js/abort-signal.js b/test-app/runtime/src/main/cpp/js/abort-signal.js index 2936e0d66..5f3929265 100644 --- a/test-app/runtime/src/main/cpp/js/abort-signal.js +++ b/test-app/runtime/src/main/cpp/js/abort-signal.js @@ -49,12 +49,12 @@ const Event = g.Event; const setTimeout = g.setTimeout; const clearTimeout = g.clearTimeout; const dispatchEvent = EventTarget.prototype.dispatchEvent; -const addEventListener = EventTarget.prototype.addEventListener; -const removeEventListener = EventTarget.prototype.removeEventListener; // Published by events.js: the symbol under which EventTargetImpl looks up -// the listener-mutation hook. events.js already ran (Events::Init), so this -// require is a cache hit; a miss would run it on demand rather than fail. -const { kListenerChanged } = require("internal/events"); +// the listener-mutation hook, and the shared event-handler-attribute helper +// (whose wrapper registration routes through the same hook). events.js +// already ran (Events::Init), so this require is a cache hit; a miss would +// run it on demand rather than fail. +const { defineEventHandler, kListenerChanged } = require("internal/events"); // Construction token: AbortSignal instances come only from the factories in // this module (the controller, and the abort/timeout/any statics). @@ -102,11 +102,6 @@ let sourcePruneRegistry; class AbortSignal extends EventTarget { #aborted = false; #reason = undefined; - // Event handler attribute state (HTML semantics: registered as a plain - // listener on the first non-null assignment, so its slot in the listener - // order is where it was first set; cleared assignments free the slot). - #onabort = null; - #onabortWrapper = null; #isTimeout = false; // any() linkage, all WeakRefs. #sources: the plain sources a live // composite follows (null on plain signals and once aborted — composites @@ -147,45 +142,6 @@ class AbortSignal extends EventTarget { } } - get onabort() { - return this.#onabort; - } - - set onabort(handler) { - // TreatNonObjectAsNull: objects and functions are stored, any other value - // clears the handler; only a function is invoked at dispatch time. - const value = - typeof handler === "function" || - (handler !== null && typeof handler === "object") - ? handler - : null; - if (value !== null && this.#onabort === null) { - if (this.#onabortWrapper === null) { - const self = this; - this.#onabortWrapper = function (event) { - const cb = self.#onabort; - if (typeof cb === "function") { - FunctionPrototypeCall(cb, self, event); - } - }; - } - FunctionPrototypeCall( - addEventListener, - this, - "abort", - this.#onabortWrapper - ); - } else if (value === null && this.#onabort !== null) { - FunctionPrototypeCall( - removeEventListener, - this, - "abort", - this.#onabortWrapper - ); - } - this.#onabort = value; - } - static abort(reason) { return createAbortSignal( true, @@ -445,6 +401,8 @@ ObjectDefineProperty(AbortSignal.prototype, kListenerChanged, { configurable: false, }); +defineEventHandler(AbortSignal.prototype, "abort"); + class AbortController { #signal = createAbortSignal(false, undefined); diff --git a/test-app/runtime/src/main/cpp/js/broadcast-channel.js b/test-app/runtime/src/main/cpp/js/broadcast-channel.js new file mode 100644 index 000000000..3f4aa9980 --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/broadcast-channel.js @@ -0,0 +1,119 @@ +"use strict"; +// BroadcastChannel (HTML Standard §9.5): every channel constructed with the +// same name joins one process-wide group, workers included — "same user agent" +// is the app process here. +// +// A channel owns a hidden MessagePort in that named group. The port is started +// and strongly held from construction (native holds the wrapper, the wrapper +// holds the relay listener, the relay holds the channel), so an unclosed +// channel stays deliverable whether or not app code keeps a reference — and +// close() is what ends that. +const { createBroadcastPort, postMessage: postMessageToPort, close: closePort } = + binding; + +const { + FunctionPrototypeCall, + ObjectDefineProperty, + SymbolToStringTag, + TypeError, +} = primordials; + +const { EventTarget, defineEventHandler } = require("internal/events"); +const { adoptPort } = require("internal/message-channel"); + +const addEventListener = EventTarget.prototype.addEventListener; +const dispatchEvent = EventTarget.prototype.dispatchEvent; + +let MessageEvent; +function getMessageEvent() { + if (MessageEvent === undefined) { + ({ MessageEvent } = require("internal/message-event")); + } + return MessageEvent; +} + +let DOMException; +function getDOMException() { + if (DOMException === undefined) { + ({ DOMException } = require("internal/dom-exception")); + } + return DOMException; +} + +class BroadcastChannel extends EventTarget { + #name; + #port; + + constructor(name) { + if (arguments.length < 1) { + throw new TypeError("BroadcastChannel: 1 argument required, but only 0 present"); + } + super(); + ObjectDefineProperty(this, "_listeners", { + __proto__: null, + value: this._listeners, + writable: true, + enumerable: false, + configurable: true, + }); + this.#name = `${name}`; + const port = adoptPort(createBroadcastPort(this.#name)); + this.#port = port; + const channel = this; + const relay = function (event) { + FunctionPrototypeCall( + dispatchEvent, + channel, + new (getMessageEvent())(event.type, { data: event.data }) + ); + }; + FunctionPrototypeCall(addEventListener, port, "message", relay); + FunctionPrototypeCall(addEventListener, port, "messageerror", relay); + } + + get name() { + return this.#name; + } + + postMessage(message) { + if (arguments.length < 1) { + throw new TypeError("postMessage: 1 argument required, but only 0 present"); + } + if (this.#port === undefined) { + throw new (getDOMException())( + "BroadcastChannel is closed.", + "InvalidStateError" + ); + } + // No transfer list: the spec's postMessage takes the message alone, and a + // fan-out message could not hand one object to every destination anyway. + postMessageToPort(this.#port, message, undefined); + } + + close() { + if (this.#port === undefined) { + return; + } + const port = this.#port; + this.#port = undefined; + closePort(port); + } +} + +defineEventHandler(BroadcastChannel.prototype, "message"); +defineEventHandler(BroadcastChannel.prototype, "messageerror"); + +for (const key of ["name", "postMessage", "close"]) { + ObjectDefineProperty(BroadcastChannel.prototype, key, { + __proto__: null, + enumerable: true, + }); +} + +ObjectDefineProperty(BroadcastChannel.prototype, SymbolToStringTag, { + __proto__: null, + value: "BroadcastChannel", + configurable: true, +}); + +module.exports = { BroadcastChannel }; diff --git a/test-app/runtime/src/main/cpp/js/events.js b/test-app/runtime/src/main/cpp/js/events.js index 7cb43312d..22ba21cc4 100644 --- a/test-app/runtime/src/main/cpp/js/events.js +++ b/test-app/runtime/src/main/cpp/js/events.js @@ -42,19 +42,63 @@ function setListenerErrorReporter(fn) { reportListenerError = fn; } -// Internal listener-mutation hook. A target (in practice: AbortSignal, on -// its prototype) may carry a function under this symbol; it is called with -// (target, type, newCount) from every path that changes a listener list — -// add, remove, and the once-splice inside dispatch. The key travels only -// through require("internal/events"), so the accounting cannot be bypassed -// the way an overridable addEventListener could. +// Event name -> handler-attribute wrapper (see defineEventHandler), stored on +// the target's own listener bag under a symbol so it cannot collide with an +// event type. Deliberately NOT a WeakMap keyed by the target: a Worker is an +// ObjectManager-registered object whose finalizer resurrects it while its +// thread is alive, and a resurrected object's weak-collection entries are +// already gone. Each wrapper carries a `delta` that the listener count is +// corrected by: the wrapper occupies one slot in the listener list from its +// first assignment onwards, but a cleared handler is not a listener. +var kHandlers = Symbol("handlers"); + +function handlersOf(target) { + var bag = target._listeners; + return bag === undefined ? undefined : bag[kHandlers]; +} + +// Internal listener-mutation hook. A target (in practice: AbortSignal and +// MessagePort, on their prototypes) may carry a function under this symbol; +// it is called with (target, type, newCount) from every path that changes a +// listener list — add, remove, the once-splice inside dispatch, and a handler +// attribute going active or inert. The key travels only through +// require("internal/events"), so the accounting cannot be bypassed the way an +// overridable addEventListener could. var kListenerChanged = Symbol("listenerChanged"); +// Called on the first handler-attribute assignment, whatever the value: HTML +// enables a MessagePort the first time onmessage is set, even to null, which +// the listener count cannot express. Later assignments only move the count. +var kHandlerAssigned = Symbol("handlerAssigned"); function notifyListenerChanged(target, type, count) { var hook = target[kListenerChanged]; - if (hook !== undefined) { hook(target, type, count); } + if (hook === undefined) { return; } + var wrappers = handlersOf(target); + if (wrappers !== undefined) { + var wrapper = wrappers[type]; + if (wrapper !== undefined) { count += wrapper.delta; } + } + hook(target, type, count); } function EventTargetImpl() { this._listeners = ObjectCreate(null); } + +// A target whose prototype was grafted onto EventTarget.prototype rather than +// built by the constructor — Worker, MessagePort — has no bag until it needs +// one. Non-enumerable, because those are platform objects. +function listenersOf(target) { + var bag = target._listeners; + if (bag === undefined) { + bag = ObjectCreate(null); + ObjectDefineProperty(target, "_listeners", { + value: bag, + writable: true, + enumerable: false, + configurable: true, + }); + } + return bag; +} + EventTargetImpl.prototype.addEventListener = function (type, callback, options) { if (callback === null || callback === undefined) { return; } type = String(type); @@ -65,8 +109,9 @@ EventTargetImpl.prototype.addEventListener = function (type, callback, options) capture = !!options.capture; once = !!options.once; } - var list = this._listeners[type]; - if (!list) { list = this._listeners[type] = []; } + var bag = listenersOf(this); + var list = bag[type]; + if (!list) { list = bag[type] = []; } for (var i = 0; i < list.length; i++) { if (list[i].callback === callback && list[i].capture === capture) { return; } } @@ -81,7 +126,8 @@ EventTargetImpl.prototype.removeEventListener = function (type, callback, option } else if (options && typeof options === "object") { capture = !!options.capture; } - var list = this._listeners[type]; + var bag = this._listeners; + var list = bag === undefined ? undefined : bag[type]; if (!list) { return; } for (var i = 0; i < list.length; i++) { if (list[i].callback === callback && list[i].capture === capture) { @@ -91,10 +137,13 @@ EventTargetImpl.prototype.removeEventListener = function (type, callback, option } } }; -EventTargetImpl.prototype.dispatchEvent = function (event) { - event.target = this; - event.currentTarget = this; - var list = this._listeners[event.type]; +function dispatch(target, event, rethrow) { + event.target = target; + event.currentTarget = target; + var thrown; + var hasThrown = false; + var bag = target._listeners; + var list = bag === undefined ? undefined : bag[event.type]; if (list) { // Snapshot so listeners added during dispatch are not invoked and // registration order is preserved. @@ -105,25 +154,43 @@ EventTargetImpl.prototype.dispatchEvent = function (event) { if (idx === -1) { continue; } // removed since snapshot if (entry.once) { ArrayPrototypeSplice(list, idx, 1); - notifyListenerChanged(this, event.type, list.length); + notifyListenerChanged(target, event.type, list.length); } var cb = entry.callback; try { if (typeof cb === "function") { - FunctionPrototypeCall(cb, this, event); + FunctionPrototypeCall(cb, target, event); } else if (cb && typeof cb.handleEvent === "function") { cb.handleEvent(event); } } catch (e) { - reportListenerError(e); + if (rethrow && !hasThrown) { + thrown = e; + hasThrown = true; + } else { + reportListenerError(e); + } } if (event._stopImmediate) { break; } } } event.currentTarget = null; + if (hasThrown) { throw thrown; } return !event.defaultPrevented; +} + +EventTargetImpl.prototype.dispatchEvent = function (event) { + return dispatch(this, event, false); }; +// Dispatch whose first listener exception reaches the caller instead of the +// uncaught-error reporter. Worker message delivery needs it: the native frame +// that called in owns the worker's error chain (the scope's `onerror`, then +// the parent's), and throwing back into it is the only way there. +function dispatchEventRethrowing(target, event) { + return dispatch(target, event, true); +} + // Internal EventTarget instance backing the global. globalThis's prototype // is intentionally NOT made an EventTarget; only the three methods are // bound onto it. @@ -146,6 +213,87 @@ EventTarget.prototype.dispatchEvent = EventTargetImpl.prototype.dispatchEvent; g.Event = Event; g.EventTarget = EventTarget; +// Event handler IDL attributes (HTML §8.1.7.2), Node's defineEventHandler. +// The handler is never registered directly: a wrapper listener takes its slot +// on the first assignment and stays there, so `onfoo` fires at the position it +// was FIRST set at even after being replaced or cleared, interleaved correctly +// with addEventListener registrations. A cleared handler leaves the wrapper in +// place but inert, which is why the wrapper carries the count correction the +// listener-changed hook applies. +var addListener = EventTargetImpl.prototype.addEventListener; + +function makeEventHandler(handler, cancelOnTruthy) { + function eventHandler(event) { + if (typeof eventHandler.handler !== "function") { return; } + var result = FunctionPrototypeCall(eventHandler.handler, this, event); + // Special error event handling (HTML §8.1.7.3): only for `onerror`, a + // truthy return cancels the event. It is the one way a handler + // attribute's return value is observable, so it is also how "the worker + // error was handled" leaves dispatch. + if (cancelOnTruthy && result) { event.preventDefault(); } + return result; + } + eventHandler.handler = handler; + // A wrapper holds one listener slot for good; an inactive handler cancels + // its own slot out of the count the listener-changed hook receives. + eventHandler.delta = typeof handler === "function" ? 0 : -1; + return eventHandler; +} + +function defineEventHandler(target, name, event, cancelOnTruthy) { + if (event === undefined) { event = name; } + var propName = "on" + name; + + function get() { + var wrappers = handlersOf(this); + if (wrappers === undefined) { return null; } + var wrapper = wrappers[event]; + return wrapper === undefined ? null : wrapper.handler; + } + + function set(value) { + // [LegacyTreatNonObjectAsNull]: anything neither callable nor an object + // clears the handler. + if (typeof value !== "function" && (typeof value !== "object" || value === null)) { + value = null; + } + var bag = listenersOf(this); + var wrappers = bag[kHandlers]; + if (wrappers === undefined) { + wrappers = bag[kHandlers] = ObjectCreate(null); + } + var wrapper = wrappers[event]; + if (wrapper === undefined) { + // First assignment ever, `null` included: the slot is claimed now and + // kept, interleaved with addEventListener registrations at this point. + wrapper = wrappers[event] = makeEventHandler(value, cancelOnTruthy); + FunctionPrototypeCall(addListener, this, event, wrapper); + var assigned = this[kHandlerAssigned]; + if (assigned !== undefined) { assigned(this, event); } + return; + } + var wasActive = typeof wrapper.handler === "function"; + var isActive = typeof value === "function"; + wrapper.handler = value; + if (wasActive === isActive) { return; } + // Absolute, never cumulative: the correction is the whole slot or nothing, + // so the count the hook sees returns to zero when the last active listener + // goes. + wrapper.delta = isActive ? 0 : -1; + var list = bag[event]; + notifyListenerChanged(this, event, list ? list.length : 0); + } + + ObjectDefineProperty(get, "name", { value: "get " + propName, configurable: true }); + ObjectDefineProperty(set, "name", { value: "set " + propName, configurable: true }); + ObjectDefineProperty(target, propName, { + get: get, + set: set, + enumerable: true, + configurable: true, + }); +} + // CustomEvent (DOM Standard §2.4): Event carrying an app-supplied `detail`. // Defined here so it extends the same Event the globals hold, but NOT // installed eagerly — the lazy-global tier (LazyGlobals) places it from this @@ -180,5 +328,12 @@ module.exports = { globalEventTarget: globalTarget, CustomEvent: CustomEvent, kListenerChanged: kListenerChanged, + kHandlerAssigned: kHandlerAssigned, setListenerErrorReporter: setListenerErrorReporter, + // The base classes and the handler-attribute helper, for the lazy builtins + // that may not read them off the globals user code can replace. + Event: Event, + EventTarget: EventTarget, + defineEventHandler: defineEventHandler, + dispatchEventRethrowing: dispatchEventRethrowing, }; diff --git a/test-app/runtime/src/main/cpp/js/message-channel.js b/test-app/runtime/src/main/cpp/js/message-channel.js new file mode 100644 index 000000000..7d49cac85 --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/message-channel.js @@ -0,0 +1,266 @@ +"use strict"; +// MessagePort / MessageChannel (HTML Standard §9.4) over the native messaging +// core (Messaging.cpp). +// +// The wrappers native code hands out — from createChannel, and from the +// deserializer for every port that arrives in a message — are bare objects +// carrying an internal field. `adoptPort` is what turns one into a +// MessagePort, and it is the only way an instance comes into being, which is +// why the constructor throws. Native must run it over every port wrapper it +// materializes that does not reach JS through emitMessage. +// +// Port enabling is HTML's: a port starts delivering when it gets its first +// 'message' listener — addEventListener or the onmessage attribute, including +// an `onmessage = null` first write — and stops when the last one goes. The +// events builtin's kListenerChanged hook is what reports those transitions. +// 'close' is delivered even to a port that was never started, so a port whose +// sibling died always learns about it. +const { + createChannel, + postMessage: postMessageToPort, + start: startPort, + stop: stopPort, + close: closePort, + drainOne, + setEmitMessage, +} = binding; + +const { + ArrayIsArray, + ArrayPrototypePush, + FunctionPrototypeCall, + ObjectCreate, + ObjectDefineProperty, + ObjectPrototypeHasOwnProperty, + ObjectSetPrototypeOf, + SymbolIterator, + SymbolToStringTag, + TypeError, + WeakSet, + WeakSetPrototypeAdd, + WeakSetPrototypeDelete, + WeakSetPrototypeHas, +} = primordials; + +const { + Event, + EventTarget, + defineEventHandler, + kHandlerAssigned, + kListenerChanged, +} = require("internal/events"); + +const addEventListener = EventTarget.prototype.addEventListener; +const dispatchEvent = EventTarget.prototype.dispatchEvent; + +let MessageEvent; +function getMessageEvent() { + if (MessageEvent === undefined) { + ({ MessageEvent } = require("internal/message-event")); + } + return MessageEvent; +} + +// WebIDL sequence. Entries are handed to the native transfer-list +// collector unexamined: it owns the transferability rules and the +// DataCloneError messages that go with them. +function toTransferList(value) { + if (value === undefined || value === null) { + return undefined; + } + if (ArrayIsArray(value)) { + return value; + } + if (typeof value !== "object" && typeof value !== "function") { + throw new TypeError("postMessage: transfer is not iterable"); + } + // The HTML overload: a second argument that is not itself iterable is the + // StructuredSerializeOptions dictionary carrying the sequence. + const source = + typeof value[SymbolIterator] === "function" ? value : value.transfer; + if (source === undefined || source === null) { + return undefined; + } + if (ArrayIsArray(source)) { + return source; + } + if (typeof source !== "object" && typeof source !== "function") { + throw new TypeError("postMessage: transfer is not iterable"); + } + const method = source[SymbolIterator]; + if (typeof method !== "function") { + throw new TypeError("postMessage: transfer is not iterable"); + } + return drainIterable(method, source); +} + +function drainIterable(method, value) { + const iterator = FunctionPrototypeCall(method, value); + if (iterator === null || typeof iterator !== "object") { + throw new TypeError("postMessage: transfer is not iterable"); + } + const next = iterator.next; + if (typeof next !== "function") { + throw new TypeError("postMessage: transfer is not iterable"); + } + const list = []; + for (;;) { + const step = FunctionPrototypeCall(next, iterator); + if (step === null || typeof step !== "object") { + throw new TypeError("postMessage: transfer iterator returned a non-object"); + } + if (step.done) { + break; + } + ArrayPrototypePush(list, step.value); + } + return list; +} + +// Ports the native side is currently delivering to. The set is the idempotence +// guard for start/stop: the hook below sees every count transition, an explicit +// start() sees none. +const startedPorts = new WeakSet(); + +function listenerChanged(port, type, count) { + if (type !== "message") { + return; + } + if (count > 0) { + if (!WeakSetPrototypeHas(startedPorts, port)) { + WeakSetPrototypeAdd(startedPorts, port); + startPort(port); + } + } else if (WeakSetPrototypeHas(startedPorts, port)) { + WeakSetPrototypeDelete(startedPorts, port); + stopPort(port); + } +} + +// HTML: the first time onmessage is set the port is enabled, as if start() had +// been called, even when the value assigned is null and adds no listener. +function handlerAssigned(port, type) { + if (type !== "message" || WeakSetPrototypeHas(startedPorts, port)) { + return; + } + WeakSetPrototypeAdd(startedPorts, port); + startPort(port); +} + +class MessagePort extends EventTarget { + constructor() { + throw new TypeError("Illegal constructor"); + } + + postMessage(value, transfer) { + postMessageToPort(this, value, toTransferList(transfer)); + } + + start() { + if (!WeakSetPrototypeHas(startedPorts, this)) { + WeakSetPrototypeAdd(startedPorts, this); + startPort(this); + } + } + + close(callback) { + if (typeof callback === "function") { + FunctionPrototypeCall(addEventListener, this, "close", callback, { once: true }); + } + closePort(this); + } +} + +defineEventHandler(MessagePort.prototype, "message"); +defineEventHandler(MessagePort.prototype, "messageerror"); +defineEventHandler(MessagePort.prototype, "close"); + +ObjectDefineProperty(MessagePort.prototype, kHandlerAssigned, { + __proto__: null, + value: handlerAssigned, + writable: false, + enumerable: false, + configurable: false, +}); + +ObjectDefineProperty(MessagePort.prototype, kListenerChanged, { + __proto__: null, + value: listenerChanged, + writable: false, + enumerable: false, + configurable: false, +}); + +ObjectDefineProperty(MessagePort.prototype, SymbolToStringTag, { + __proto__: null, + value: "MessagePort", + configurable: true, +}); + +for (const key of ["postMessage", "start", "close"]) { + ObjectDefineProperty(MessagePort.prototype, key, { + __proto__: null, + enumerable: true, + }); +} + +function adoptPort(port) { + if (ObjectPrototypeHasOwnProperty(port, "_listeners")) { + return port; + } + ObjectSetPrototypeOf(port, MessagePort.prototype); + // The EventTarget base would install this as an own enumerable field; a port + // is a platform object, so keep it out of Object.keys(port). + ObjectDefineProperty(port, "_listeners", { + __proto__: null, + value: ObjectCreate(null), + writable: true, + enumerable: false, + configurable: true, + }); + return port; +} + +class MessageChannel { + constructor() { + const pair = createChannel(); + this.port1 = adoptPort(pair[0]); + this.port2 = adoptPort(pair[1]); + } +} + +ObjectDefineProperty(MessageChannel.prototype, SymbolToStringTag, { + __proto__: null, + value: "MessageChannel", + configurable: true, +}); + +function receiveMessageOnPort(port) { + const result = drainOne(port); + return result === null ? undefined : result; +} + +// The per-isolate delivery callout. Native invokes it with the receiving port +// wrapper as the receiver; `type` is "message", "messageerror" or "close". +function emitMessage(data, ports, type) { + if (type === "close") { + FunctionPrototypeCall(dispatchEvent, this, new Event("close")); + return; + } + const list = []; + if (ports !== undefined && ports !== null) { + for (let i = 0; i < ports.length; i++) { + ArrayPrototypePush(list, adoptPort(ports[i])); + } + } + const MessageEventCtor = getMessageEvent(); + FunctionPrototypeCall( + dispatchEvent, + this, + new MessageEventCtor(type, { data, ports: list }) + ); +} + +setEmitMessage(emitMessage); + +module.exports = { MessagePort, MessageChannel, receiveMessageOnPort, adoptPort }; diff --git a/test-app/runtime/src/main/cpp/js/message-event.js b/test-app/runtime/src/main/cpp/js/message-event.js new file mode 100644 index 000000000..0ea1e99b1 --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/message-event.js @@ -0,0 +1,153 @@ +"use strict"; +// MessageEvent (HTML Standard §9.2.5), the event every messaging surface in +// the runtime delivers: MessagePort, BroadcastChannel, Worker and the worker +// global scope. +// +// Lazy builtin: LazyGlobals places the global and the messaging builtins +// require this file at first delivery, so an app that never receives a message +// never runs it. Event/EventTarget come from require("internal/events") rather +// than the globals, which by then are whatever user code left behind. +const { + ArrayPrototypePush, + ArrayPrototypeSlice, + FunctionPrototypeCall, + ObjectDefineProperty, + ObjectFreeze, + SymbolIterator, + SymbolToStringTag, + TypeError, +} = primordials; + +const { Event } = require("internal/events"); + +// WebIDL sequence. Entry types are not checked here: the ports an +// event carries come from the native deserializer, and a hand-built event's +// `ports` is inert data. +function toPortSequence(value) { + if (value === null || (typeof value !== "object" && typeof value !== "function")) { + throw new TypeError("MessageEvent: ports is not iterable"); + } + const method = value[SymbolIterator]; + if (typeof method !== "function") { + throw new TypeError("MessageEvent: ports is not iterable"); + } + const iterator = FunctionPrototypeCall(method, value); + if (iterator === null || typeof iterator !== "object") { + throw new TypeError("MessageEvent: ports is not iterable"); + } + const next = iterator.next; + if (typeof next !== "function") { + throw new TypeError("MessageEvent: ports is not iterable"); + } + const list = []; + for (;;) { + const step = FunctionPrototypeCall(next, iterator); + if (step === null || typeof step !== "object") { + throw new TypeError("MessageEvent: ports iterator returned a non-object"); + } + if (step.done) { + break; + } + ArrayPrototypePush(list, step.value); + } + return list; +} + +class MessageEvent extends Event { + #data; + #origin; + #lastEventId; + #source; + #ports; + + constructor(type, init = undefined) { + if (arguments.length < 1) { + throw new TypeError("MessageEvent: 1 argument required, but only 0 present"); + } + if (init !== undefined && init !== null && + typeof init !== "object" && typeof init !== "function") { + throw new TypeError("MessageEvent: eventInitDict is not an object"); + } + super(type, init); + const options = init === undefined || init === null ? {} : init; + this.#data = options.data !== undefined ? options.data : null; + this.#origin = options.origin !== undefined ? `${options.origin}` : ""; + this.#lastEventId = + options.lastEventId !== undefined ? `${options.lastEventId}` : ""; + this.#source = options.source !== undefined ? options.source : null; + this.#ports = + options.ports !== undefined && options.ports !== null + ? toPortSequence(options.ports) + : []; + } + + get data() { + return this.#data; + } + + get origin() { + return this.#origin; + } + + get lastEventId() { + return this.#lastEventId; + } + + get source() { + return this.#source; + } + + get ports() { + // A frozen copy per read: freezing the backing array in place would let a + // caller's reference alias the event's own state. + return ObjectFreeze(ArrayPrototypeSlice(this.#ports)); + } + + initMessageEvent( + type, + bubbles = false, + cancelable = false, + data = null, + origin = "", + lastEventId = "", + source = null, + ports = [] + ) { + if (arguments.length < 1) { + throw new TypeError("initMessageEvent: 1 argument required, but only 0 present"); + } + // Event's initialize steps are a no-op while the event is being + // dispatched; currentTarget is what marks that window. + if (this.currentTarget !== null) { + return; + } + this.type = `${type}`; + this.bubbles = !!bubbles; + this.cancelable = !!cancelable; + this.defaultPrevented = false; + this.target = null; + this._stopPropagation = false; + this._stopImmediate = false; + this.#data = data; + this.#origin = `${origin}`; + this.#lastEventId = `${lastEventId}`; + this.#source = source; + this.#ports = ports === null ? [] : toPortSequence(ports); + } +} + +// Class members are non-enumerable; the IDL attributes and operations are not. +for (const key of ["data", "origin", "lastEventId", "source", "ports", "initMessageEvent"]) { + ObjectDefineProperty(MessageEvent.prototype, key, { + __proto__: null, + enumerable: true, + }); +} + +ObjectDefineProperty(MessageEvent.prototype, SymbolToStringTag, { + __proto__: null, + value: "MessageEvent", + configurable: true, +}); + +module.exports = { MessageEvent }; diff --git a/test-app/runtime/src/main/cpp/js/node-worker-threads.js b/test-app/runtime/src/main/cpp/js/node-worker-threads.js new file mode 100644 index 000000000..da60893b3 --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/node-worker-threads.js @@ -0,0 +1,355 @@ +"use strict"; + +// The `node:worker_threads` compatibility shim. The channel half — +// MessagePort, MessageChannel, BroadcastChannel, receiveMessageOnPort — is the +// real thing, shared with the globals of the same name. The thread half is a +// bridge over the runtime's own Worker: this runtime has no thread pool, no +// stdio plumbing and no per-thread environment, so what cannot be honoured +// throws with the option or function named rather than degrading silently. +// See docs/worker-threads.md for the real-vs-shim table. + +const { + isMainThread, + threadId, + markAsUntransferable, + isMarkedAsUntransferable, + markAsUncloneable, + setEnvironmentData, + getEnvironmentData, +} = binding; + +const { + ArrayPrototypeIndexOf, + ArrayPrototypePush, + ArrayPrototypeSlice, + ArrayPrototypeSplice, + Error, + FunctionPrototypeCall, + ObjectCreate, + ObjectDefineProperty, + ObjectFreeze, + PromisePrototypeThen, + PromiseResolve, + SymbolFor, + SymbolToStringTag, + TypeError, +} = primordials; + +const { + MessagePort, + MessageChannel, + receiveMessageOnPort, +} = require("internal/message-channel"); +const { BroadcastChannel } = require("internal/broadcast-channel"); +const { + EventTarget, + defineEventHandler, + globalEventTarget, +} = require("internal/events"); + +let MessageEvent; +function getMessageEvent() { + if (MessageEvent === undefined) { + ({ MessageEvent } = require("internal/message-event")); + } + return MessageEvent; +} + +const g = globalThis; +// The platform constructor this shim wraps, and the worker scope's channel +// back to its parent. +const NativeWorker = g.Worker; +const globalPostMessage = g.postMessage; + +const addEventListener = EventTarget.prototype.addEventListener; +const removeEventListener = EventTarget.prototype.removeEventListener; +const dispatchEvent = EventTarget.prototype.dispatchEvent; + +// Runs `fn` after the caller returns. Node reports 'online' and 'exit' from +// the thread's own lifecycle; the runtime's Worker has no equivalent signal, +// so both are reported off a microtask instead. +function soon(fn) { + PromisePrototypeThen(PromiseResolve(), fn); +} + +function notSupported(name) { + throw new Error(`${name} is not supported in this runtime`); +} + +// Worker options that carry meaning this runtime cannot honour. The three +// stdio ones default to false, so only an explicit request is an error. +const rejectedOptions = ["workerData", "env", "eval", "transferList"]; +const rejectedStdio = ["stdin", "stdout", "stderr"]; + +class WorkerEmitter { + #listeners = ObjectCreate(null); + + on(type, listener) { + if (typeof listener !== "function") { + throw new TypeError('The "listener" argument must be of type function'); + } + const key = `${type}`; + const list = this.#listeners[key] || (this.#listeners[key] = []); + ArrayPrototypePush(list, { listener, once: false }); + return this; + } + + once(type, listener) { + if (typeof listener !== "function") { + throw new TypeError('The "listener" argument must be of type function'); + } + const key = `${type}`; + const list = this.#listeners[key] || (this.#listeners[key] = []); + ArrayPrototypePush(list, { listener, once: true }); + return this; + } + + // Node removes the most recently added registration of a listener. + removeListener(type, listener) { + const list = this.#listeners[`${type}`]; + if (list === undefined) { + return this; + } + for (let i = list.length - 1; i >= 0; i--) { + if (list[i].listener === listener) { + ArrayPrototypeSplice(list, i, 1); + return this; + } + } + return this; + } + + off(type, listener) { + return this.removeListener(type, listener); + } + + emit(type, arg) { + const list = this.#listeners[type]; + if (list === undefined) { + return; + } + const snapshot = ArrayPrototypeSlice(list); + for (let i = 0; i < snapshot.length; i++) { + const entry = snapshot[i]; + if (entry.once) { + const index = ArrayPrototypeIndexOf(list, entry); + if (index !== -1) { + ArrayPrototypeSplice(list, index, 1); + } + } + FunctionPrototypeCall(entry.listener, this, arg); + } + } +} + +class Worker extends WorkerEmitter { + #worker; + #exited = false; + + constructor(filename, options) { + super(); + if (options !== undefined && options !== null) { + for (let i = 0; i < rejectedOptions.length; i++) { + if (options[rejectedOptions[i]] !== undefined) { + throw new TypeError( + `Worker option '${rejectedOptions[i]}' is not supported in this runtime` + ); + } + } + for (let i = 0; i < rejectedStdio.length; i++) { + if (options[rejectedStdio[i]]) { + throw new TypeError( + `Worker option '${rejectedStdio[i]}' is not supported in this runtime` + ); + } + } + } + + // The runtime's own options (androidPriority, resourceLimits) ride + // along; the native constructor ignores keys it does not know. + const worker = + options === undefined || options === null + ? new NativeWorker(`${filename}`) + : new NativeWorker(`${filename}`, options); + this.#worker = worker; + const self = this; + worker.onmessage = function (event) { + self.emit("message", event.data); + }; + worker.onmessageerror = function (event) { + self.emit("messageerror", event.data); + }; + worker.onerror = function (error) { + self.emit("error", error); + }; + soon(function () { + self.emit("online", undefined); + }); + } + + postMessage(value, transfer) { + this.#worker.postMessage(value, transfer); + } + + terminate() { + this.#worker.terminate(); + const self = this; + return PromisePrototypeThen(PromiseResolve(), function () { + if (!self.#exited) { + self.#exited = true; + self.emit("exit", 0); + } + return 0; + }); + } +} + +ObjectDefineProperty(Worker.prototype, SymbolToStringTag, { + __proto__: null, + value: "Worker", + configurable: true, +}); + +// The worker scope's end of the parent channel. Not a MessagePort: it is not +// transferable and it has no queue of its own, it forwards to the worker +// globals the runtime already provides. close() is a no-op — a worker ends +// through its own close()/terminate(). +class ParentPort extends EventTarget { + // Node's parentPort is an EventEmitter as well: on("message") receives the + // payload, not the event. Each listener is registered through its own + // wrapper so removal by the original function still works. + #wrappers = ObjectCreate(null); + + postMessage(value, transfer) { + FunctionPrototypeCall(globalPostMessage, g, value, transfer); + } + + start() {} + + close() {} + + on(type, listener) { + return this.#add(`${type}`, listener, false); + } + + addListener(type, listener) { + return this.#add(`${type}`, listener, false); + } + + once(type, listener) { + return this.#add(`${type}`, listener, true); + } + + off(type, listener) { + this.#remove(`${type}`, listener); + return this; + } + + removeListener(type, listener) { + this.#remove(`${type}`, listener); + return this; + } + + #add(type, listener, once) { + if (typeof listener !== "function") { + throw new TypeError('The "listener" argument must be of type function'); + } + const self = this; + const entry = { listener, wrapper: undefined }; + // A once registration detaches its own entry, not whichever entry happens + // to hold the same listener: the same function may be on() and once() at + // the same time. + entry.wrapper = function (event) { + if (once) { + self.#removeEntry(type, entry); + } + const arg = type === "message" || type === "messageerror" ? event.data : event; + FunctionPrototypeCall(listener, self, arg); + }; + const list = this.#wrappers[type] || (this.#wrappers[type] = []); + ArrayPrototypePush(list, entry); + FunctionPrototypeCall(addEventListener, this, type, entry.wrapper); + return this; + } + + // Node removes the most recently added registration of a listener. + #remove(type, listener) { + const list = this.#wrappers[type]; + if (list === undefined) { + return; + } + for (let i = list.length - 1; i >= 0; i--) { + if (list[i].listener === listener) { + this.#removeEntry(type, list[i]); + return; + } + } + } + + #removeEntry(type, entry) { + const list = this.#wrappers[type]; + if (list === undefined) { + return; + } + const index = ArrayPrototypeIndexOf(list, entry); + if (index === -1) { + return; + } + ArrayPrototypeSplice(list, index, 1); + FunctionPrototypeCall(removeEventListener, this, type, entry.wrapper); + } +} + +defineEventHandler(ParentPort.prototype, "message"); +defineEventHandler(ParentPort.prototype, "messageerror"); + +ObjectDefineProperty(ParentPort.prototype, SymbolToStringTag, { + __proto__: null, + value: "MessagePort", + configurable: true, +}); + +let parentPort = null; +if (!isMainThread) { + parentPort = new ParentPort(); + const relay = function (event) { + FunctionPrototypeCall( + dispatchEvent, + parentPort, + new (getMessageEvent())(event.type, { data: event.data, ports: event.ports }) + ); + }; + FunctionPrototypeCall(addEventListener, globalEventTarget, "message", relay); + FunctionPrototypeCall(addEventListener, globalEventTarget, "messageerror", relay); +} + +module.exports = ObjectFreeze({ + BroadcastChannel, + MessageChannel, + MessagePort, + // Exported so an `options.env === SHARE_ENV` spelling still resolves; this + // runtime has one environment and never copies it. + SHARE_ENV: SymbolFor("nodejs.worker_threads.SHARE_ENV"), + Worker, + getEnvironmentData, + isInternalThread: false, + isMainThread, + isMarkedAsUntransferable, + markAsUncloneable, + markAsUntransferable, + moveMessagePortToContext() { + notSupported("moveMessagePortToContext"); + }, + parentPort, + postMessageToThread() { + notSupported("postMessageToThread"); + }, + receiveMessageOnPort, + resourceLimits: {}, + setEnvironmentData, + threadId, + threadName: undefined, + // No workerData: the Worker constructor rejects the option that would carry + // it, so there is never anything to hand a worker. + workerData: null, +}); diff --git a/test-app/runtime/src/main/cpp/js/primordials.js b/test-app/runtime/src/main/cpp/js/primordials.js index 51b5600b6..53ac8fe94 100644 --- a/test-app/runtime/src/main/cpp/js/primordials.js +++ b/test-app/runtime/src/main/cpp/js/primordials.js @@ -25,6 +25,7 @@ const intrinsics = { FinalizationRegistry, Map, Number, + Promise, RangeError, Set, String, @@ -33,8 +34,10 @@ const intrinsics = { Uint32Array, URL, WeakRef, + WeakSet, // Well-known symbols. + SymbolFor: Symbol.for, SymbolIterator: Symbol.iterator, SymbolToStringTag: Symbol.toStringTag, @@ -62,6 +65,8 @@ const intrinsics = { ObjectIs: Object.is, ObjectKeys: Object.keys, ObjectSetPrototypeOf: Object.setPrototypeOf, + // Promise.resolve reads its receiver to pick the species to construct. + PromiseResolve: Promise.resolve.bind(Promise), // Instance methods, uncurried. ArrayPrototypeForEach: uncurryThis(Array.prototype.forEach), @@ -81,8 +86,11 @@ const intrinsics = { MapPrototypeEntries: uncurryThis(Map.prototype.entries), MapPrototypeGet: uncurryThis(Map.prototype.get), MapPrototypeSet: uncurryThis(Map.prototype.set), + ObjectPrototypeHasOwnProperty: uncurryThis(Object.prototype.hasOwnProperty), + ObjectPrototypeIsPrototypeOf: uncurryThis(Object.prototype.isPrototypeOf), ObjectPrototypePropertyIsEnumerable: uncurryThis(Object.prototype.propertyIsEnumerable), ObjectPrototypeToString: uncurryThis(Object.prototype.toString), + PromisePrototypeThen: uncurryThis(Promise.prototype.then), RegExpPrototypeTest: uncurryThis(RegExp.prototype.test), RegExpPrototypeToString: uncurryThis(RegExp.prototype.toString), SetPrototypeAdd: uncurryThis(Set.prototype.add), @@ -97,6 +105,9 @@ const intrinsics = { StringPrototypeStartsWith: uncurryThis(String.prototype.startsWith), SymbolPrototypeToString: uncurryThis(Symbol.prototype.toString), WeakRefPrototypeDeref: uncurryThis(WeakRef.prototype.deref), + WeakSetPrototypeAdd: uncurryThis(WeakSet.prototype.add), + WeakSetPrototypeDelete: uncurryThis(WeakSet.prototype.delete), + WeakSetPrototypeHas: uncurryThis(WeakSet.prototype.has), // Iterator-protocol escape hatches: the captured `next` of the live map/set // iterator prototypes, so entries can be walked with early exit even after diff --git a/test-app/runtime/src/main/cpp/js/structured-clone.js b/test-app/runtime/src/main/cpp/js/structured-clone.js index ff25ec285..511762d15 100644 --- a/test-app/runtime/src/main/cpp/js/structured-clone.js +++ b/test-app/runtime/src/main/cpp/js/structured-clone.js @@ -4,10 +4,10 @@ // WebIDL sequence handling for `transfer`; the clone itself is native // (v8::ValueSerializer round-tripped in this isolate). // -// Deviation from the HTML spec, forced by the platform: only ArrayBuffers are -// transferable. MessagePort, ImageBitmap and the native/interop wrapper -// objects have no serialization form in this runtime, so they are rejected -// rather than half-supported. Clone failures are "DataCloneError" +// Deviation from the HTML spec, forced by the platform: ArrayBuffers and +// MessagePorts are transferable, nothing else is. ImageBitmap and the +// native/interop wrapper objects have no serialization form in this runtime, +// so they are rejected rather than half-supported. Clone failures are "DataCloneError" // DOMExceptions, from here and from the native serializer alike // (StructuredSerialization.cpp builds the same class). @@ -16,6 +16,7 @@ const { ArrayBufferPrototypeGetByteLength, ArrayPrototypePush, FunctionPrototypeCall, + ObjectPrototypeIsPrototypeOf, SymbolIterator, TypeError, } = primordials; @@ -46,6 +47,19 @@ function isArrayBuffer(value) { } } +// The port check runs only for entries the ArrayBuffer test already rejected, +// so a transfer list of buffers never runs the messaging builtin. +let MessagePort; +function isMessagePort(value) { + if (value === null || typeof value !== "object") { + return false; + } + if (MessagePort === undefined) { + ({ MessagePort } = require("internal/message-channel")); + } + return ObjectPrototypeIsPrototypeOf(MessagePort.prototype, value); +} + // WebIDL `sequence` conversion: only an object with a callable // @@iterator qualifies, which is why a string primitive is a TypeError even // though strings are iterable. @@ -80,7 +94,7 @@ function toTransferList(value) { break; } var item = step.value; - if (!isArrayBuffer(item)) { + if (!isArrayBuffer(item) && !isMessagePort(item)) { throw dataCloneError("structuredClone: value in transfer list is not transferable"); } ArrayPrototypePush(list, item); diff --git a/test-app/runtime/src/main/cpp/js/worker-events.js b/test-app/runtime/src/main/cpp/js/worker-events.js new file mode 100644 index 000000000..73536fc44 --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/worker-events.js @@ -0,0 +1,102 @@ +"use strict"; +// Worker (HTML Standard §10.2.6) and the worker global scope (§10.2.1) as +// EventTargets: both deliver MessageEvents instead of the runtime's historical +// direct call of an `onmessage` property, and the Worker object receives the +// worker's unhandled errors as ErrorEvents. The worker global scope's own +// `onerror` stays a direct call with the error — a documented NativeScript +// contract, not the web's event. +// +// Eager, because the handler attributes have to exist before app code assigns +// one. MessageEvent itself is pulled in on the first delivery, so a worker +// nobody talks to never runs that builtin. +const { ObjectDefineProperty, ObjectSetPrototypeOf } = primordials; + +const { + EventTarget, + defineEventHandler, + dispatchEventRethrowing, + globalEventTarget, +} = require("internal/events"); + +const g = globalThis; + +let MessageEvent; +function getMessageEvent() { + if (MessageEvent === undefined) { + ({ MessageEvent } = require("internal/message-event")); + } + return MessageEvent; +} + +// ErrorEvent is installed by the error-events builtin, which +// Runtime::PrepareV8Runtime runs AFTER this one — so the constructor can +// only be taken on the first error delivery, not at init. +let ErrorEvent; +function getErrorEvent() { + if (ErrorEvent === undefined) { + ErrorEvent = g.ErrorEvent; + } + return ErrorEvent; +} + +// The delivery callout, invoked by native with the receiving target as `this`: +// the Worker object on the parent isolate, the global scope's EventTarget +// inside a worker. `ports` is the array of MessagePorts the message +// transferred, or undefined when it carried none. +// +// A handler that throws propagates back into the calling native frame: that +// is what feeds the worker's onerror chain — the worker scope's handler +// first, then the parent's — which the cross-runtime worker suite asserts. +function emitMessage(data, ports, type) { + const MessageEventCtor = getMessageEvent(); + dispatchEventRethrowing(this, new MessageEventCtor(type, { data, ports })); +} + +// The parent-side error delivery callout, invoked by native with the Worker +// object as `this` once the worker scope has left the error unhandled. Only +// primitives cross the isolate boundary, so the event carries no `error` +// object; `stackTrace` is this runtime's addition to the ErrorEvent fields. +// +// Returns whether the error was handled: a truthy return from the `onerror` +// attribute cancels the event (HTML §8.1.7.3), as does preventDefault() from +// any listener. +function emitError(message, filename, lineno, stackTrace) { + const ErrorEventCtor = getErrorEvent(); + const event = new ErrorEventCtor("error", { + message, + filename, + lineno, + cancelable: true, + }); + event.stackTrace = stackTrace; + dispatchEventRethrowing(this, event); + return event.defaultPrevented; +} + +ObjectSetPrototypeOf(g.Worker.prototype, EventTarget.prototype); +defineEventHandler(g.Worker.prototype, "message"); +defineEventHandler(g.Worker.prototype, "messageerror"); +defineEventHandler(g.Worker.prototype, "error", "error", true); + +// The global scope's handler attributes are defined against the EventTarget +// backing the global listener methods, which is what native dispatches on and +// what globalThis.addEventListener registers with — so a handler and an +// addEventListener registration interleave in assignment order. globalThis +// only forwards. +defineEventHandler(globalEventTarget, "message"); +defineEventHandler(globalEventTarget, "messageerror"); +for (const name of ["onmessage", "onmessageerror"]) { + ObjectDefineProperty(g, name, { + __proto__: null, + get() { + return globalEventTarget[name]; + }, + set(value) { + globalEventTarget[name] = value; + }, + enumerable: true, + configurable: true, + }); +} + +module.exports = { emitMessage, emitError };