worker_threads parity (epic #996): terminate teardown, events, transfer/detach, env-data, stdio, introspection#1066
Merged
Conversation
…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.
This was referenced Jun 29, 2026
Closed
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.
Completes epic #996 — brings
worker_threadsto near-100% Node parity. Eight stacked commits, one per child issue.Full xUnit suite: 14513 / 0.
worker_threadstests 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
a71d7ccterminate()now wakes a worker parked inAtomics.wait(a cancellation hook pulses theMonitor.Wait; the worker unwinds via a non-catchableWorkerTerminatedException) and stops an idle event-loop worker promptly viaInterpreter.Shutdown().exitreports code1on terminate/uncaught error; the terminate promise resolves the real exit code.51ecb93'online'event — emitted once the worker's JS starts, before its first'message'.3ee31c6transferListnow acceptsArrayBuffer(interp + emitted$ArrayBuffer); the source is detached (byteLength → 0). Added the missing interpreter ArrayBuffer payload-clone arm.22b68a2process.env) forget/setEnvironmentData;receiveMessageOnPortreturnsundefined(not null) on an empty port.d8fede5'messageerror'event — a clone failure onpostMessageis delivered to the receiver as'messageerror'(BroadcastChannel's receiver-side model) instead of throwing on the sender.7d79c5dmarkAsUntransferable— marked objects are cloned (not transferred), so they survive on the sender.abcb0fbstdout/stderrstreams +resourceLimitsecho —stdout/stderr: truediverts worker console output into per-workerReadablestreams (worker.stdout/worker.stderr);worker.resourceLimitsechoes the passed object.b897b7fgetHeapSnapshot()(clear not-supported error),worker.performance.eventLoopUtilization()(best-effort),moveMessagePortToContext()(clear not-supported error).Documented
.NET-model ceilings / deviationswhile(true){}worker can't be force-killed (noThread.Abort); cooperative termination only.TransferMessagePort.getHeapSnapshot()has no .NET equivalent (V8 snapshot format) → clear error.resourceLimitsis echoed but not enforced (no per-thread V8 heap/stack sizing).moveMessagePortToContext()needs V8 vm isolates → clear error.worker.stdin(parent→workerprocess.stdinbridge) and the compiled main-threadMessageChannelmessageerror/synchronousreceiveMessageOnPort(the emitted clone doesn't throw / the$MessagePorttype is emitted after the worker-threads helpers) remain follow-ups; documented in the relevant commits.Closes #997, #998, #999, #1000, #1001, #1002, #1003, #1004.