feat: MessagePort, MessageChannel, BroadcastChannel, and node:worker_threads - #2043
Draft
edusperoni wants to merge 1 commit into
Draft
feat: MessagePort, MessageChannel, BroadcastChannel, and node:worker_threads#2043edusperoni wants to merge 1 commit into
edusperoni wants to merge 1 commit into
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
edusperoni
added this pull request to stack #2046
September 11, 2026 23:27
…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.
edusperoni
force-pushed
the
feat/worker-threads
branch
from
September 12, 2026 17:11
2b5d893 to
42a8bcf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_messagingdesign without libuv: an isolate-freePortData(mutex-guarded queue, sibling-group entanglement) under a per-isolateNativeMessagePortwhose wake primitive is a coalescedEventLoop::PostInternal— producers never take a foreign isolate's Locker. Pairwise channels and named broadcast groups share oneSiblingGroupmechanism (the pairwise-vs-broadcast close difference is a single guard, as in Node). Ports transfer throughpostMessage(includingWorker.postMessage) andstructuredCloneas host-object tag 2: index in-stream,PortDataout-of-band, nothing detached until the whole graph has serialized, received ports pre-constructed beforeReadValue. A transferred port carries its queued backlog and drains after adoption on a later turn, per spec.worker.onmessage/ scopeonmessageare now HTML event-handler IDL attributes (defineEventHandler, position-fixed ordering interleaving withaddEventListener), and delivery dispatches realMessageEvents withevent.portspopulated. Firstmessagelistener starts a port;receiveMessageOnPortdoes forced sync drains.docs/worker-threads.mdhas the full real-vs-shim table and every documented deviation. Highlights: realMessageChannel/MessagePort/BroadcastChannel/receiveMessageOnPort/threadId/isMainThread/set-/getEnvironmentData/markAsUntransferable/markAsUncloneable;parentPortis a bridge;Workeris a thin emitter wrapper that rejects unsupported options loudly and forwards the rest of the option bag (soandroidPriorityreaches the runtime's own constructor);postMessageToThread/moveMessagePortToContextthrow;locksabsent.Fixed in passing
CallWorkerScopeOnErrorHandleforwarded 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 inWorkerWrapper.cpp, and the unhandled-rejection tracker inNativeScriptException.cpp. A worker with no scopeonerrorstill reaches the parent.ErrorEventon the Worker EventTarget, soworker.addEventListener('error')works, in registration order; handled =preventDefault()or a truthyonerrorreturn.errorisnull(only primitives cross isolates);stackTraceis a documented NS extension. An error the Worker object leaves unhandled is dispatched as anErrorEventon the parent's global scope per HTML, and logged if nothing handles it there.EventLoop::Shutdownmoves 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::Terminateempties 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 receivedclose.AbortSignal#onabortrefactored onto the shareddefineEventHandler(−42 lines).ArrayBufferwhile the graph was written used to hand the receiver zero bytes silently.Deserializerecords 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 lintclean.All five shared messaging suites ran (confirmed in the results XML, not pending):
MessageChannel45,MessageEvent29,NodeWorkerThreads36,BroadcastChannel17,WorkerEvents15. Plus 17 Android-only specs intests/testMessaging.js(transfer-list edges, handler-attribute enabling,MessagePort.onclose, the empty-nameBroadcastChannelgroup, theparentPortemitter surface, the two worker error paths, and theAbortSignalhandler-attribute GC accounting) and 3 messaging canary specs intestRuntimeImplementedAPIs.js. The 4 skips are the pre-existing ones; the known__timeflake did not fire.Deviations from NativeScript/ios#454
g_statesregistry inMessaging.cpp. iOS needs a process-wideIsolate* -> MessagingState*map because itsCachesis invalidated beforeCloseAllPortsruns. On AndroidRuntime::DestroyRuntimereleasesRuntimeStatein its very last statement, long afterCloseAllPorts, soRuntimeState::For<MessagingState>answers there and the registry (plus theisolatefield and the registry-erasing half of~MessagingState) is dropped.IsolateWrapper-> a rawv8::Isolate*guarded byRuntime::TryGetRuntime, which is Android's "is this runtime still alive" primitive.ContainUncaughtCallbackException+EventLoop::IsPumping()/DeferJavaThrow/ReThrowToJavatail, mirroringTimers.cpp, rather than iOS'sReportToJsHandlersAndLog. 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/OnMessageCallbacklive in a newWorkerEvents.{h,cpp}— Android has noWorker.{h,mm}counterpart;WorkerWrapperkeeps thread lifecycle only.restrictedGlobalsgainsPromiseandWeakSetbut notWeakMap(iOS added all three): Android's primordials exports noWeakMapand no builtin uses one, so the rule would be unsatisfiable.androidPriority: "turbo"with.toThrow()where iOS usesresourceLimitswithtoThrowError(TypeError). Android's nativeWorkerraises aNativeScriptException, not aTypeError; the assertion's intent (the bag reaches the native constructor) is unchanged.structuredClone's native half needed no change:Deserializewith a null port list already closes ports that arrive with no way out.Remaining follow-ups (out of scope)
messageerrorrelay is wired but has no end-to-end test (no deterministic way to force a deserialization failure from JS).nsworkerended,terminate()settling) mirroring fix(runtime): strong Worker wrapper lifetime while the thread runs ios#456 is the next PR in this stack.Mirrors NativeScript/ios#454.