Skip to content

feat: MessagePort, MessageChannel, BroadcastChannel, and node:worker_threads - #2043

Draft
edusperoni wants to merge 1 commit into
feat/dom-exception-serializablefrom
feat/worker-threads
Draft

feat: MessagePort, MessageChannel, BroadcastChannel, and node:worker_threads#2043
edusperoni wants to merge 1 commit into
feat/dom-exception-serializablefrom
feat/worker-threads

Conversation

@edusperoni

@edusperoni edusperoni commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #2040 (feat/dom-exception-serializable) — merge that first.

What this adds

Native MessagePort / MessageChannel / BroadcastChannel / MessageEvent (all lazy globals — zero boot cost), node:worker_threads, and Worker + 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 — producers never take 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 (including Worker.postMessage) and structuredClone as host-object tag 2: index in-stream, PortData out-of-band, nothing detached until the whole graph has serialized, received ports pre-constructed before ReadValue. A transferred port carries its queued backlog and drains after adoption on a later turn, per spec.

worker.onmessage / scope onmessage are now HTML event-handler IDL attributes (defineEventHandler, position-fixed ordering interleaving with addEventListener), and delivery dispatches real MessageEvents with event.ports populated. First message listener starts a port; receiveMessageOnPort does forced sync drains.

docs/worker-threads.md has the full real-vs-shim table and every documented deviation. Highlights: real MessageChannel/MessagePort/BroadcastChannel/receiveMessageOnPort/threadId/isMainThread/set-/getEnvironmentData/markAsUntransferable/markAsUncloneable; parentPort is a bridge; Worker is a thin emitter wrapper that rejects unsupported options loudly and forwards the rest of the option bag (so androidPriority reaches the runtime's own constructor); postMessageToThread/moveMessagePortToContext throw; locks absent.

Fixed in passing

  • Worker error propagation (pre-existing bugs). CallWorkerScopeOnErrorHandle forwarded BOTH the scope handler's thrown error and the original. A throwing scope handler now forwards its own error once and nothing else, in all three worker error paths — the scope handler, the entry-rejection reporter in WorkerWrapper.cpp, and the unhandled-rejection tracker in NativeScriptException.cpp. A worker with no scope onerror still reaches the parent.
  • Parent-side delivery is a real cancelable ErrorEvent on the Worker EventTarget, so worker.addEventListener('error') works, in registration order; handled = preventDefault() or a truthy onerror return. error is null (only primitives cross isolates); stackTrace is a documented NS extension. An error the Worker object leaves unhandled is dispatched as an ErrorEvent on the parent's global scope per HTML, and logged if nothing handles it there.
  • EventLoop::Shutdown moves the dropped lanes out and destroys them after releasing the mutex. A dropped message carrying a transferred port sentinels the port's sibling, which posts to the sibling's loop; when that sibling belonged to the isolate shutting down, the post re-entered the held (non-recursive) mutex. The invariant is recorded in the class comment.
  • ConcurrentQueue::Terminate empties the queue and destroys the messages outside both locks, and a push racing it is dropped under the queue mutex. Ports and buffers transferred to a worker terminated before its entry settled were pinned for the wrapper's lifetime and the sibling never received close.
  • AbortSignal#onabort refactored onto the shared defineEventHandler (−42 lines).
  • Post-write revalidation now covers buffers as well as ports: a getter that detached a listed ArrayBuffer while the graph was written used to hand the receiver zero bytes silently.
  • Deserialize records which adopted ports the stream referenced; callers that surface no port list (structuredClone, receiveMessageOnPort) close the rest on arrival, and a read that fails after adoption closes every port it adopted.

Tests

Full device suite on a Pixel_3a_API_36 arm64 emulator: 1391 specs / 0 failures / 4 skipped (baseline before this change: 1216 / 0 / 4). npm run lint clean.

All five shared messaging suites ran (confirmed in the results XML, not pending): MessageChannel 45, MessageEvent 29, NodeWorkerThreads 36, BroadcastChannel 17, WorkerEvents 15. Plus 17 Android-only specs in tests/testMessaging.js (transfer-list edges, handler-attribute enabling, MessagePort.onclose, the empty-name BroadcastChannel group, the parentPort emitter surface, the two worker error paths, and the AbortSignal handler-attribute GC accounting) and 3 messaging canary specs in testRuntimeImplementedAPIs.js. The 4 skips are the pre-existing ones; the known __time flake did not fire.

Deviations from NativeScript/ios#454

  • No g_states registry in Messaging.cpp. iOS needs a process-wide Isolate* -> MessagingState* map because its Caches is invalidated before CloseAllPorts runs. On Android Runtime::DestroyRuntime releases RuntimeState in its very last statement, long after CloseAllPorts, so RuntimeState::For<MessagingState> answers there and the registry (plus the isolate field and the registry-erasing half of ~MessagingState) is dropped.
  • IsolateWrapper -> a raw v8::Isolate* guarded by Runtime::TryGetRuntime, which is Android's "is this runtime still alive" primitive.
  • Uncaught listener errors from a port drain and from parent-side worker delivery go through Android's ContainUncaughtCallbackException + EventLoop::IsPumping()/DeferJavaThrow/ReThrowToJava tail, mirroring Timers.cpp, rather than iOS's ReportToJsHandlersAndLog. Android's internal lane performs a microtask checkpoint after each entry, so an exception may not be left pending across the return.
  • Worker::InitEvents/EmitError/OnMessageCallback live in a new WorkerEvents.{h,cpp} — Android has no Worker.{h,mm} counterpart; WorkerWrapper keeps thread lifecycle only.
  • eslint restrictedGlobals gains Promise and WeakSet but not WeakMap (iOS added all three): Android's primordials exports no WeakMap and no builtin uses one, so the rule would be unsatisfiable.
  • The "forwards the option bag" spec uses androidPriority: "turbo" with .toThrow() where iOS uses resourceLimits with toThrowError(TypeError). Android's native Worker raises a NativeScriptException, not a TypeError; the assertion's intent (the bag reaches the native constructor) is unchanged.
  • structuredClone's native half needed no change: Deserialize with a null port list already closes ports that arrive with no way out.

Remaining follow-ups (out of scope)

Mirrors NativeScript/ios#454.

@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant