Skip to content

Commit 42a8bcf

Browse files
committed
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.
1 parent 84fc2b6 commit 42a8bcf

41 files changed

Lines changed: 3948 additions & 306 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
and the lazy-global tier that runs their builtins only on first use.
2020
- [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.
2121
- [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.
22+
- [Messaging and `node:worker_threads`](worker-threads.md)`MessagePort`,
23+
`MessageChannel`, `BroadcastChannel` and `MessageEvent`, the
24+
`node:worker_threads` real-vs-shim table and its documented deviations,
25+
the strong-until-closed port lifetime, HTML port enabling, and the
26+
transfer support matrix with its `DataCloneError` messages.
2227
- [Implementing additional Chrome DevTools protocol Domains](extending-inspector.md)
2328

2429
## Knowledge

docs/ns-builtin-modules.md

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -380,14 +380,17 @@ npm packages that require Node builtins by their prefixed names can run
380380
unmodified where a shim exists:
381381
382382
- A shim implements a documented **subset** of the corresponding Node module's
383-
API, backed by `ns:` modules. Unimplemented members are simply absent
383+
API, backed by the runtime's own modules. Unimplemented members are simply absent
384384
(so `typeof util.promisify === "function"` feature-checks behave
385-
correctly); they are never present-but-throwing.
385+
correctly); they are never present-but-throwing. The one exception is a
386+
member whose silent absence would read as a delivery bug rather than as a
387+
missing feature — it may be present and throw, and the table below names
388+
every such member.
386389
- **One source file per specifier.** A shim is its own module that consumes
387-
the `ns:` module it adapts through the internal require, and it owns *all*
388-
the adaptation — argument shapes, option names, aliases, anything that has
389-
to track Node. A standard `ns:` module never contains compatibility code
390-
and never knows a shim exists.
390+
the module it adapts through the internal require, and it owns *all* the
391+
adaptation — argument shapes, option names, aliases, anything that has to
392+
track Node. A standard `ns:` module never contains compatibility code and
393+
never knows a shim exists.
391394
- Shims are **lazy**: a shim's source is only evaluated when its specifier is
392395
first resolved, so an app that never touches the `node:` scheme never pays
393396
for one.
@@ -411,6 +414,7 @@ unmodified where a shim exists:
411414
| `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. |
412415
| `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`. |
413416
| `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. |
417+
| `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. |
414418
415419
`node:url`'s parsing goes through the URL intrinsic, so `file://localhost/x` is
416420
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.
719723
Android-only) note in between.
720724
- Internal runtime machinery must never be reachable through the scheme.
721725
722-
That last rule holds because public modules and internal builtins are **two
723-
separate loading paths**, not one registry with a per-entry flag:
724-
725-
- The **public registry** is a table mapping specifier → builtin, and it is the
726-
only thing the `ns:`/`node:` resolver consults. A specifier absent from it
727-
does not resolve, full stop. Today it holds six entries: `ns:module`,
728-
`ns:runtime`, `ns:util`, `node:module`, `node:url`, `node:util`.
729-
- **Internal builtins** (the intrinsics snapshot, the require factory, the
730-
console formatter, and so on) are invoked directly from their own native call
731-
sites. They are never named in the public registry, so there is no specifier
732-
that could reach them and nothing to mark private.
733-
734-
Adding an internal builtin therefore cannot accidentally expose it; exposing
735-
one is an explicit registry entry, which is also the change this document has
736-
to describe.
726+
That last rule holds because every registry row carries its tier, and the two
727+
resolvers read the same table differently:
728+
729+
- The **`ns:`/`node:` resolver** — the app-facing one, behind `require()`,
730+
`import` and `import()` — serves only rows *not* marked internal-only. An
731+
internal-only specifier fails exactly as a name absent from the table does.
732+
Seven rows are public today: `ns:module`, `ns:runtime`, `ns:util`,
733+
`node:module`, `node:url`, `node:util`, `node:worker_threads`.
734+
- The **internal require** builtins receive (previous section) is the only
735+
thing that can name an internal-only row. Five rows are marked that way:
736+
`internal/broadcast-channel`, `internal/dom-exception`, `internal/events`,
737+
`internal/message-channel`, `internal/message-event`. Their exports carry
738+
capabilities app code must not hold — listener-accounting hook keys, the
739+
error-reporter setter, base classes that must be the runtime's own rather
740+
than whatever a global currently names.
741+
- Builtins with **no row at all** (the intrinsics snapshot, the require
742+
factory, the console formatter) are invoked straight from their native call
743+
sites. There is no specifier that could reach them and nothing to mark.
744+
745+
So a builtin is unreachable from app code unless a registry row says
746+
otherwise, and exposing one means editing that row's tier — which is also the
747+
change this document has to describe.
737748
738749
## Source-text modules: deliberately not supported
739750

docs/structured-clone.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# structuredClone
22

3-
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`.
3+
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`.
44

55
```js
66
const clone = structuredClone({ when: new Date(), tags: new Set(["a"]) });
@@ -12,7 +12,7 @@ buffer.byteLength; // 0 — the memory now belongs to `moved`
1212

1313
## Surface
1414

15-
`structuredClone(value)` returns a clone of `value`. `structuredClone(value, { transfer })` additionally transfers every `ArrayBuffer` in `transfer`.
15+
`structuredClone(value)` returns a clone of `value`. `structuredClone(value, { transfer })` additionally transfers every `ArrayBuffer` and `MessagePort` in `transfer`.
1616

1717
- `value` is required; calling with no arguments throws a `TypeError`.
1818
- `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
2424

2525
`SharedArrayBuffer` is **shared, not copied**: the clone is a second `SharedArrayBuffer` over the same memory, so writes through either are visible through the other.
2626

27-
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.
27+
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.
2828

2929
## Transfer semantics
3030

31-
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.
31+
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.
3232

3333
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.
3434

35+
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.
36+
3537
## Worker `postMessage`
3638

3739
`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,
4446
Two differences are intentional:
4547

4648
- **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.
47-
- **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.
49+
- **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.
4850

4951
## Deviations from the specification
5052

5153
- **`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.)
52-
- **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`.
54+
- **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.
5355
- **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.
5456

5557
`SharedArrayBuffer` follows the spec: it is shared rather than copied, and it is not transferable (listing one throws a `DataCloneError`).

0 commit comments

Comments
 (0)