Skip to content

worker_threads parity (epic #996): terminate teardown, events, transfer/detach, env-data, stdio, introspection#1066

Merged
nickna merged 8 commits into
mainfrom
wrk/epic-996-worker-threads-parity
Jun 29, 2026
Merged

worker_threads parity (epic #996): terminate teardown, events, transfer/detach, env-data, stdio, introspection#1066
nickna merged 8 commits into
mainfrom
wrk/epic-996-worker-threads-parity

Conversation

@nickna

@nickna nickna commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Completes epic #996 — brings worker_threads to near-100% Node parity. Eight stacked commits, one per child issue.

Full xUnit suite: 14513 / 0. worker_threads tests grew 88 → 115. Every behavior is verified dual-mode (interpreter + compiled) unless noted; the compiled parent reaches the C# SharpTSWorker/stream objects via the runtime dispatch path.

Children

Issue Commit Summary
#997 a71d7cc terminate() teardown + exit code (the one real bug). terminate() now wakes a worker parked in Atomics.wait (a cancellation hook pulses the Monitor.Wait; the worker unwinds via a non-catchable WorkerTerminatedException) and stops an idle event-loop worker promptly via Interpreter.Shutdown(). exit reports code 1 on terminate/uncaught error; the terminate promise resolves the real exit code.
#998 51ecb93 'online' event — emitted once the worker's JS starts, before its first 'message'.
#999 3ee31c6 ArrayBuffer transfer + detachtransferList now accepts ArrayBuffer (interp + emitted $ArrayBuffer); the source is detached (byteLength → 0). Added the missing interpreter ArrayBuffer payload-clone arm.
#1000 22b68a2 Real environment-data store (not process.env) for get/setEnvironmentData; receiveMessageOnPort returns undefined (not null) on an empty port.
#1001 d8fede5 'messageerror' event — a clone failure on postMessage is delivered to the receiver as 'messageerror' (BroadcastChannel's receiver-side model) instead of throwing on the sender.
#1002 7d79c5d markAsUntransferable — marked objects are cloned (not transferred), so they survive on the sender.
#1003 abcb0fb stdout/stderr streams + resourceLimits echostdout/stderr: true diverts worker console output into per-worker Readable streams (worker.stdout/worker.stderr); worker.resourceLimits echoes the passed object.
#1004 b897b7f IntrospectiongetHeapSnapshot() (clear not-supported error), worker.performance.eventLoopUtilization() (best-effort), moveMessagePortToContext() (clear not-supported error).

Documented .NET-model ceilings / deviations

  • CPU-bound while(true){} worker can't be force-killed (no Thread.Abort); cooperative termination only.
  • MessagePort source-neuter on transfer is intentionally not done — SharpTS's single-process two-thread model hands the same port object to both threads (unlike V8's separate serialize/deserialize), so neutering the sender would neuter the receiver. Documented in TransferMessagePort.
  • getHeapSnapshot() has no .NET equivalent (V8 snapshot format) → clear error. resourceLimits is echoed but not enforced (no per-thread V8 heap/stack sizing). moveMessagePortToContext() needs V8 vm isolates → clear error.
  • worker.stdin (parent→worker process.stdin bridge) and the compiled main-thread MessageChannel messageerror/synchronous receiveMessageOnPort (the emitted clone doesn't throw / the $MessagePort type is emitted after the worker-threads helpers) remain follow-ups; documented in the relevant commits.

Closes #997, #998, #999, #1000, #1001, #1002, #1003, #1004.

nickna added 8 commits June 29, 2026 00:06
…r + exit code 1 (#997)

worker.terminate() could not wake a worker parked in Atomics.wait: the
Monitor.Wait(Infinite) waiter registry had no cancellation hook, so the 5s
join timed out, the worker thread's finally never ran, no 'exit' event
fired, and the OS thread leaked until process exit. The 'exit' code was
also hardcoded 0, whereas Node uses 1 for a terminated/errored worker.

- New WorkerTerminatedException: a non-catchable control-flow exception
  (mirrors GeneratorReturnException) re-thrown ahead of the generic
  catch (Exception) at every block/try frame in the interpreter, so a
  worker cannot catch its own termination and it propagates silently to
  the worker host loop.
- Interpreter gains a worker-termination CancellationToken (distinct from
  the vm-timeout token) that the worker sets to its _cts.Token.
- Atomics.wait registers a cancel hook on that token: terminate() pulses
  the parked Monitor.Wait awake and the wait throws WorkerTerminatedException
  to unwind the thread. Lock ordering is safe (Monitor.Wait releases the
  lock while parked; the already-cancelled case fires synchronously and is
  re-checked before parking). Main-thread waits get a non-cancelable token
  (no behavior change).
- SharpTSWorker captures its interpreter and calls Shutdown() on terminate()
  to stop an idle event-loop worker promptly (mirrors SharpTSClusterWorker
  Kill/Disconnect); WorkerThreadMain reports exit code 1 on terminate/uncaught
  error (no 'error' event on terminate) and the terminate() promise resolves
  with the real exit code.

The 'exit' event is emitted only from the worker thread's finally, so a test
asserting the parent receives exit:1 after terminating an Atomics-parked
worker directly proves the thread unwound rather than leaked.

Out of scope (documented .NET ceiling): a CPU-bound while(true){} worker
still can't be force-killed (no Thread.Abort).

Dual-mode tests (interp + compiled); full suite 14486/0.
Node emits 'online' on a Worker once the worker's JS starts executing,
before any 'message' it posts. SharpTS emitted no such event.

RunWorkerScript now enqueues 'online' right after the worker environment
is set up (interpreter + globals + message handler) and before any user
code runs, via the existing ScheduleOnMainThread → EmitEventOnMainThread
path used by 'message'/'error'/'exit'. Because it is scheduled before the
script executes, it is always delivered ahead of the worker's first
'message'. It is not emitted if the worker fails to start (e.g. missing
script), since the worker never came online.

A no-payload EmitEventOnMainThread(eventName) overload emits 'online' with
zero arguments (interp: emit(name); compiled: EmitDirect(name)) to match
Node rather than passing a spurious trailing null.

Dual-mode test asserts 'online' is delivered before the first 'message' in
both interpreter and compiled modes. Worker suite 90/90; full suite green
(the lone failure is the pre-existing live-DNS smoke flake, unrelated).
…ansferList (#999)

StructuredClone's transfer-list processing accepted only MessagePort; passing
an ArrayBuffer threw DataCloneError, and a plain ArrayBuffer in the payload
had no interpreter clone arm at all (it hit the default and threw). Node
transfers an ArrayBuffer and detaches (neuters) the source.

- StructuredClone.Clone now accepts ArrayBuffer in the transfer list
  (interpreter SharpTSArrayBuffer + emitted compiled $ArrayBuffer) and
  detaches each transferred buffer on the sender AFTER the payload is cloned
  (Node neuters the source; byteLength becomes 0). Detach is applied whether
  or not the buffer also appears in the payload.
- Added the missing SharpTSArrayBuffer payload-clone arm (deep byte copy) and
  registered it in CanClone/ValidateCloneable. The receiver always gets an
  independent copy — no cross-thread aliasing of a mutable buffer (sound by
  construction; copy rather than zero-copy is deliberate).
- SharpTSArrayBuffer gains _detached/IsDetached/Detach(); byteLength reports 0
  and AsSpan/indexer/Slice/GetBackingArray throw once detached.
- Emitted compiled $ArrayBuffer gains a _detached field, a Detach() method, and
  a byteLength getter that returns 0 when detached — pure BCL IL, standalone.

The Worker constructor clones workerData with the transfer list synchronously
(SharpTSWorker is C# in both modes), so the transferList option is the
dual-mode path exercised by the tests: detaching a transferred ArrayBuffer
and copying a non-transferred one work in both interpreter and compiled
parents. The full byte round-trip into the worker is verified in interpreter
mode (a compiled parent hands the interpreting worker an emitted $ArrayBuffer
the interpreter's TypedArray ctor doesn't bridge yet — a separate cross-mode
gap).

Note: compiled structuredClone()/$MessagePort.postMessage use a separate
emitted clone that ignores its transfer arg; those paths are unchanged here.

Worker suite 95/95; full suite 14493/0.
…rt returns undefined (#1000)

getEnvironmentData/setEnvironmentData were backed by process.env
(Environment.Get/SetEnvironmentVariable) — string-only and leaking into the OS
environment. Node keeps a separate internal environment-data store keyed by
arbitrary values. receiveMessageOnPort also returned CLR null on an empty port
where Node returns undefined.

- New WorkerEnvironmentData: a process-global, thread-safe store keyed by
  arbitrary values, independent of process.env. Values are deep-copied on set
  (structured clone) so stored objects are independent; setting null/undefined
  deletes the key (so an absent key reads back as undefined). A worker child
  runs in-process under its own interpreter, so data set on the parent is
  visible to the worker through this shared store.
- Interp getEnvironmentData/setEnvironmentData now use the store; an absent key
  returns undefined.
- receiveMessageOnPort returns undefined (not null) when the port is
  empty/closed; a present message is still wrapped as { message }.
- Compiled parent support: getEnvironmentData/setEnvironmentData are emitted in
  WorkerThreadsModuleEmitter, routing to the C# store via $Runtime reflection
  helpers (RequireSharpTSRuntime recorded only at the call sites, so a program
  not using them stays standalone). The compiled receiveMessageOnPort main-thread
  stub now returns undefined instead of null.

Note: the compiled main-thread receiveMessageOnPort cannot synchronously drain a
$MessagePort queue — that type is emitted after the worker_threads helpers, so
the present-{ message } case there remains a stub. A worker drives
receiveMessageOnPort through the interpreter, which is fully functional and
covered dual-mode by the existing transferred-port test.

Dual-mode tests: env data set on the parent is visible in the worker via
getEnvironmentData and does not leak into process.env; receiveMessageOnPort on
an empty port is undefined. Worker suite 99/99; full suite 14497/0.
Node fires 'messageerror' on the receiving Worker/MessagePort when an incoming
message fails to materialize. SharpTS cloned eagerly on the sender and threw
DataCloneError synchronously back into postMessage, so 'messageerror' never
fired (only BroadcastChannel emitted it). This adopts BroadcastChannel's
receiver-side model for Worker/MessagePort postMessage: a clone failure is
delivered to the receiver as 'messageerror' instead of thrown on the sender.

- ClonedMessage gains an IsError marker. When postMessage's StructuredClone
  throws DataCloneError, an error marker is enqueued to the receiver; the
  delivery loops dispatch 'messageerror' (no payload) instead of 'message'.
- Covered in SharpTSMessagePort.PostMessage/DeliverPendingMessages and
  SharpTSWorker.PostMessage (parent→worker) / PostMessageToParent
  (worker→parent) / DeliverMessagesToParent / WorkerMessageHandler.PollMessages.
- A 'messageerror' listener now also implicitly starts a MessagePort.

The workerData clone at Worker construction still throws synchronously (it has
no receiver to deliver to before the worker starts) — existing rejection tests
are unchanged.

Dual-mode: both Worker directions go through the C# SharpTSWorker (its clone
throws for an uncloneable value in both interpreter and compiled parents), so
worker→parent and parent→worker 'messageerror' work in both modes. The
MessageChannel/$MessagePort path is interpreter-only: the compiled emitted
clone returns uncloneable values by reference (it does not throw), so a
compiled $MessagePort has no clone-failure point — making it throw would change
compiled structuredClone semantics and is out of scope.

Tests: worker→parent and parent→worker 'messageerror' (dual-mode), MessageChannel
port 'messageerror' (interpreter). Worker suite 104/104; full suite 14502/0.
…ter deviation (#1002)

markAsUntransferable was a no-op (marking had no runtime effect). Now it records
the object so StructuredClone never transfers it: an object marked
untransferable that appears in a postMessage transfer list is ignored — cloned
in the payload instead of transferred — matching Node.

- StructuredClone gains a ConditionalWeakTable-backed registry with
  MarkUntransferable/IsUntransferable (reference-keyed, weak so marking does not
  pin the object). The transfer-list loop skips marked items, so a marked
  ArrayBuffer is copied (not detached) and stays usable on the sender.
- Interp markAsUntransferable marks via the registry; compiled emits it through a
  $Runtime reflection helper to StructuredClone.MarkUntransferable
  (RequireSharpTSRuntime recorded only at the call site, standalone-safe).

MessagePort source-neutering: documented as an intentional deviation. SharpTS's
single-process two-thread model hands the SAME port object to both threads
(unlike V8's separate serialize/deserialize), so neutering the sender would
neuter the receiver too. The markAsUntransferable + ArrayBuffer-detach paths are
spec-faithful; only this port-neuter case is the model-dictated exception.

Dual-mode test: a markAsUntransferable'd ArrayBuffer in a Worker transferList is
NOT detached (byteLength preserved), in both interpreter and compiled parents —
contrast #999 where an unmarked transferred buffer detaches to 0. Worker suite
106/106; full suite 14504/0.
…cho (#1003)

The Node stdout/stderr/stdin and resourceLimits worker options were dropped
(the worker shared the parent's Console; resourceLimits was unsupported). Now:

- stdout/stderr: true diverts the worker's console output off the shared Console
  into per-worker Readable streams (worker.stdout / worker.stderr). The worker
  interpreter is created with a WorkerStreamWriter that marshals each chunk onto
  the parent loop and pushes it into the stream, so the stream is only ever
  touched — and 'data'/'end' delivered — on the parent's thread (no cross-thread
  access to stream state). SharpTSReadable gains a public PushFromHost that emits
  'data' via the parent interpreter (interp parent) or EmitDirect (compiled
  parent, no interpreter); EOF is signalled on worker exit.
- resourceLimits: stored and echoed on worker.resourceLimits (cosmetic — .NET
  exposes no per-thread V8 heap/stack sizing, an epic ceiling).
- worker.stdout/stderr/stdin/resourceLimits exposed via SharpTSWorker.GetMember;
  a compiled parent reads them through the runtime GetProperty→GetMember path.

Also fixed a latent bug: ReadOption returned `undefined` (not null) for a missing
SharpTSObject property, so omitting workerData while passing other options tried
to clone `undefined` and threw. ReadOption now normalizes undefined→null.

stdin remains unsupported (worker.stdin is null) — a parent→worker process.stdin
bridge is a separate piece. The previously-"ignored" stdio/resourceLimits test is
renamed and now asserts the options are honored.

Dual-mode tests: stdout:true routes worker console output to worker.stdout (read
via 'data'); worker.resourceLimits echoes the passed object. Worker suite
110/110; full suite 14508/0.
… moveMessagePortToContext (#1004)

The introspection surface is mostly V8-specific and .NET-limited; this adds the
methods so they are callable with honest, well-worded behavior instead of
"undefined is not a function":

- worker.getHeapSnapshot(): throws a clear "not supported" error — a V8-format
  heap snapshot has no .NET equivalent (epic ceiling).
- worker.performance.eventLoopUtilization(): returns a best-effort
  { idle, active, utilization } ({ idle: 0, active: ms-since-spawn,
  utilization: 1 }) — SharpTS has no precise idle/active loop accounting.
- moveMessagePortToContext(): keeps the not-supported error, now worded to
  explain why (it needs V8 vm contexts/isolates, which SharpTS's single-process
  threading model does not provide).

getHeapSnapshot/performance are on the C# SharpTSWorker (GetMember), so a
compiled parent reaches them via the runtime dispatch path — dual-mode.
moveMessagePortToContext is the interpreter module function (not exposed by the
compiled emitter), tested in interpreter mode.

This completes epic #996 (worker_threads parity): #997#1004 all shipped.

Dual-mode tests for performance shape + getHeapSnapshot error; interpreter test
for moveMessagePortToContext. Worker suite 115/115; full suite 14513/0.
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.

worker_threads: terminate() leaks a worker parked in Atomics.wait + wrong exit code

1 participant