Skip to content

Workers: the Worker constructor, the two façades and the connection lifecycle - #3222

Merged
lahma merged 4 commits into
sebastienros:mainfrom
lahma:webapi/workers-core
Aug 21, 2026
Merged

lahma merged 4 commits into
sebastienros:mainfrom
lahma:webapi/workers-core

Conversation

@lahma

@lahma lahma commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Step 2–3 of the Web Workers plan (#3167 §14): new Worker(specifier, { type: 'module' }) now runs a real
worker — a second Engine the host's WorkerProvider built, pumped on whichever thread the host chose,
entangled with its parent by a MessagePort pair neither side ever sees.

The constructor (issue §5, ten steps in order): WebIDL first and in the dictionary's lexicographic
member order (credentials validated as RequestCredentials then ignored; 'classic' — the spec's own
default — refused with a TypeError whose message names the fix; an unknown type is the enum-conversion
TypeError); the per-engine MaxWorkers backstop as a QuotaExceededError with quota/requested; the
termination source minted before the provider is called; null from the provider a synchronous
SecurityError, a throw propagated unchanged; then validation of the returned engine —
InvalidOperationException naming the fix for the parent-engine mistake, missing Messaging, an engine
already running a worker, and an engine that does not observe request.TerminationToken (the
deaf-but-running worker becomes a construction-time error). Steps 6–10 run under EnterHostCall on the
worker 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 the
connection 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's InternalMessageHandler: the hidden
    ports dispatch their message events at the Worker object and at the worker's global scope (HTML's
    "immediately retargeted"), with event.target answering what a browser answers. Both façades enable
    eagerly — the onmessage-implies-start() rule is scoped to the MessagePort interface, and on a
    worker addEventListener('message', …) alone must receive.
  • A draining endpoint state that is deliberately not Closed: a worker's close() leaves the
    parent-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 — while IsChannelExhausted
    stays truthful for a transferred stream whose own close message is still queued, which is exactly the
    case that predicate exists for.
  • DrainOne now arms the next delivery after the dispatch, in a finally. Arming first put the next
    drain 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 MessagePort behavior and is pinned for a plain MessageChannel too.

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.StartImport and attaches reactions:
fulfil enables the inner queue (messages posted before the module evaluated arrive buffered, in order);
reject ends the connection as StartupFailed with a CLR exception on WorkerConnection.Error — the
CLR 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 JsValue crosses a thread. The parent-side
error event is wave 3; the connection surface needs no sink at all.

terminate() and close() split exactly as the spec splits them (§6.1/§6.2): terminate closes both
endpoints immediately (still serializing afterwards, so DataCloneError still throws and transfers still
detach — 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 IsEnded on its next
iteration, and cancelling would make straggler jobs erupt from the host's pump after a clean close), and
drains-then-closes the parent side. MaxQueuedMessages is enforced per direction before serialization.

The worker global scope carries §8's table — postMessage (both overloads, transfer list verbatim, so a
MessagePort or a transferred stream rides back untouched), close, name, the onmessage/
onmessageerror/onerror/onunhandledrejection/onrejectionhandled attributes over the synthetic global
listener list (WinterTC §5.3's exact set), and importScripts present-and-throwing TypeError — the
spec'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 WorkerGlobalScope interface object. The wave-1 pin that
asserted no script surface at all is replaced by the three-way typeof Worker pin (flag and provider
together, and only together).

Tests: 36 mechanism pins + 6 PublicInterface + 2 MessagingTests (the drain-order change on a plain
channel), 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=1 legs.

Wave 3 (the parent-side error channels with error: null and the notHandled relay, restore/dispose on
both 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

lahma and others added 4 commits August 22, 2026 00:51
…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
@lahma
lahma merged commit c98cc82 into sebastienros:main Aug 21, 2026
5 checks passed
@lahma
lahma deleted the webapi/workers-core branch August 23, 2026 10:00
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