Repository navigation
Workers: the Worker constructor, the two façades and the connection lifecycle - #3222
Merged
Merged
Conversation
…ifecycle
The engine side of `new Worker(...)`: the interface object and its construction
steps, the two hidden ports neither side ever sees, the worker's global scope,
the start job, and the two ways a connection ends.
Three pieces of the messaging primitives had to move first.
`JsMessagePort` gains a retarget hook — HTML's "all messages received by that
port must immediately be retargeted at the Worker object" — the sibling of the
`InternalMessageHandler` a transferred stream uses, and the event-creating
variant of it. The parent's hidden port retargets at the `Worker` object, the
worker's at the global scope's listener list, so `worker.onmessage` and
`addEventListener('message', …)` are the same list on both façades.
`MessagePortEndpoint` gains a half-closed *draining* state, which is what
`close()` leaves the parent's side in: it takes no further message, what it
already holds stays deliverable, and it closes when that queue runs dry. It is
deliberately not expressed as `Closed`, because that flag is what a transferred
stream reads through `IsChannelExhausted` to decide its channel is finished —
and a draining side still holding the stream's own `close` message is exactly
where that answer would be wrong.
And `DrainOne` now arms the next delivery job *after* the dispatch rather than
before it, in a finally so the throw-resilience the old order was written for is
kept. The order is observable and is HTML's: a message is a task, so everything
a listener queued runs before the next message is looked at. Pinned for a plain
`MessageChannel` as well as for a worker.
`close()` and `terminate()` are the specification's split rather than one
mechanism with a flag: terminate closes both endpoints, discards what the parent
had posted and cancels the token; `close()` disentangles the worker's own side,
leaves the parent's draining, and ends the connection on a job queued on the
worker's own loop so the turn that called it runs to completion.
A load failure ends the connection as `StartupFailed` carrying a CLR exception —
the `ModuleResolutionException` behind it where there is one, and a summary
built from own data properties where there is not, because a worker-realm
`JsValue` may not cross to a host on another thread. The parent-side error
*event* is wave 3; the connection surface is this wave's observable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`Jint.Tests.PublicInterface` has no `InternalsVisibleTo`, so these pins prove a host can reach the whole feature: implement a `WorkerProvider`, build the worker engine from `request.CreateDefaultOptions()`, load its module through an ordinary `IModuleLoader`, and pump it — cooperatively from one loop, or on a thread of its own with a full `postMessage` round trip across it. `TypeofWorkerIsUndefinedEvenWithTheFlagAndProvider` is replaced deliberately. That pin asserted no script surface at all, which was true exactly while the constructor did not exist; the same question now has a three-way answer and `TypeofWorkerAnswersOnlyWithBothTheFlagAndAProvider` asserts it — the absent-with-flag-alone and absent-with-provider-alone halves survive unchanged, and the interface objects that would make `self instanceof WorkerGlobalScope` lie stay absent either way. `AWorkerDoesNotInheritNetworkAccess` is the one the foundation could only assert about an options object; it now asks the worker's own global. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both were written as "does not throw" assertions over a race the test did not control, so the mutations they exist to catch — the end sequence reaching into the worker engine, and the hand-off made while the construction still owns it — went undetected: the worker's thread simply happened not to be inside the engine at the moment that mattered. Each now rendezvous instead. `EndingTheConnectionFromTheParentWhileTheWorkerPumps DoesNotThrow` parks the worker's listener in a host callback, so its thread provably owns the engine when `End()` is called; `OnWorkerStartedSeesAPumpable Engine` starts the host's pump thread from inside the callback and waits for its first pump, because pumping from the callback's own thread proves nothing — same-thread re-entry is always allowed. Still no wall-clock assertion in either: the ceilings only stop a deadlock from hanging the run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ruthfully The wave-1 docs promised the token cancels whenever the connection ends; the close() path deliberately does not cancel it, and the reason deserves stating where a host will read it: the teardown a close() queues runs on the pumping thread itself, so that loop observes IsEnded on its next iteration with nothing to be woken from — while cancelling would turn any straggler job a host pumps after a clean close() into an ExecutionCanceledException erupting from its pump. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PnghUC3EKrYa12xQ2LaYyd
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.
Step 2–3 of the Web Workers plan (#3167 §14):
new Worker(specifier, { type: 'module' })now runs a realworker — a second
Enginethe host'sWorkerProviderbuilt, pumped on whichever thread the host chose,entangled with its parent by a
MessagePortpair neither side ever sees.The constructor (issue §5, ten steps in order): WebIDL first and in the dictionary's lexicographic
member order (
credentialsvalidated asRequestCredentialsthen ignored;'classic'— the spec's owndefault — refused with a
TypeErrorwhose message names the fix; an unknowntypeis the enum-conversionTypeError); the per-engineMaxWorkersbackstop as aQuotaExceededErrorwithquota/requested; thetermination source minted before the provider is called;
nullfrom the provider a synchronousSecurityError, a throw propagated unchanged; then validation of the returned engine —InvalidOperationExceptionnaming the fix for the parent-engine mistake, missingMessaging, an enginealready running a worker, and an engine that does not observe
request.TerminationToken(thedeaf-but-running worker becomes a construction-time error). Steps 6–10 run under
EnterHostCallon theworker engine, so a pre-warmed engine another thread is already pumping is refused with the engine's own
admission error instead of being corrupted; ownership is released before
OnWorkerStarted, and theconnection is registered before the host is told to pump.
Three messaging primitives carry the façades, and one is a deliberate shipped-behavior change:
JsMessagePort.RetargetTo— the event-creating sibling of Web streams: transfer a ReadableStream, a WritableStream or a TransformStream #3215'sInternalMessageHandler: the hiddenports dispatch their
messageevents at theWorkerobject and at the worker's global scope (HTML's"immediately retargeted"), with
event.targetanswering what a browser answers. Both façades enableeagerly — the
onmessage-implies-start()rule is scoped to theMessagePortinterface, and on aworker
addEventListener('message', …)alone must receive.Closed: a worker'sclose()leaves theparent-side queue deliverable-but-unjoinable (HTML's close a worker has no counterpart to terminate's
empty-the-queue step), so
postMessage(result); close()still delivers — whileIsChannelExhaustedstays truthful for a transferred stream whose own
closemessage is still queued, which is exactly thecase that predicate exists for.
DrainOnenow arms the next delivery after the dispatch, in afinally. Arming first put the nextdrain job ahead of the reactions the listener queued, delivering two messages back to back — the order is
observable and is HTML's (a message is a task; everything it queued runs before the next message is
looked at). This changes shipped
MessagePortbehavior and is pinned for a plainMessageChanneltoo.The start job runs on the worker's own loop with the worker's generation — no part of the worker's
script ever runs on the parent's thread, pinned. It calls
Modules.StartImportand attaches reactions:fulfil enables the inner queue (messages posted before the module evaluated arrive buffered, in order);
reject ends the connection as
StartupFailedwith a CLR exception onWorkerConnection.Error— theCLR exception behind the failure when there is one, otherwise a summary built from own data properties
only, so no getter runs during teardown and no worker-realm
JsValuecrosses a thread. The parent-sideerrorevent is wave 3; the connection surface needs no sink at all.terminate()andclose()split exactly as the spec splits them (§6.1/§6.2): terminate closes bothendpoints immediately (still serializing afterwards, so
DataCloneErrorstill throws and transfers stilldetach — pinned), cancels the token, reports
Terminated;close()disentangles the worker's side now,lets the current turn finish (the teardown is a job on the worker's own loop — which is also why it alone
does not cancel the token: the teardown runs on the pumping thread, which sees
IsEndedon its nextiteration, and cancelling would make straggler jobs erupt from the host's pump after a clean close), and
drains-then-closes the parent side.
MaxQueuedMessagesis enforced per direction before serialization.The worker global scope carries §8's table —
postMessage(both overloads, transfer list verbatim, so aMessagePortor a transferred stream rides back untouched),close,name, theonmessage/onmessageerror/onerror/onunhandledrejection/onrejectionhandledattributes over the synthetic globallistener list (WinterTC §5.3's exact set), and
importScriptspresent-and-throwingTypeError— thespec's own step 1 for a module worker, the one place the family's absent-rather-than-throwing convention
loses to the standard prescribing the throw. No
WorkerGlobalScopeinterface object. The wave-1 pin thatasserted no script surface at all is replaced by the three-way
typeof Workerpin (flag and providertogether, and only together).
Tests: 36 mechanism pins + 6 PublicInterface + 2
MessagingTests(the drain-order change on a plainchannel), 27 mutations applied to shipped code with every named pin verified to fail, threading pins
rendezvousing on events (no wall-clock assertions), mechanism and PublicInterface worker suites run ×5
green. Full matrix green on both TFMs plus both
JINT_HOST_CONTRACT_VERIFICATION=1legs.Wave 3 (the parent-side error channels with
error: nulland the notHandled relay, restore/dispose onboth sides) follows on the seams left for it:
WorkerRegistry.Snapshot(),WebApiEngineState.OwningWorkerLink,and the notes on
WorkerLink.OnModuleFailed/WorkerPrototype.Part of #3167.
🤖 Generated with Claude Code
https://claude.ai/code/session_01PnghUC3EKrYa12xQ2LaYyd