diff --git a/NativeScript/runtime/DataWrapper.h b/NativeScript/runtime/DataWrapper.h index 5ee3c1dd..df49d61a 100644 --- a/NativeScript/runtime/DataWrapper.h +++ b/NativeScript/runtime/DataWrapper.h @@ -19,6 +19,7 @@ class WorkerInspectorClient; namespace tns { class PrimitiveDataWrapper; +struct ObjectWeakCallbackState; enum class WrapperType { Base = 1 << 0, @@ -576,6 +577,20 @@ class WorkerWrapper : public BaseDataWrapper { void Close(); void Terminate(); + // The JS Worker object is a GC root from a successful start until the worker + // ends, so a running worker is reachable the way a browser's is rather than + // depending on its finalizer to keep it. Both of these run on the main + // isolate's thread only -- they re-arm that isolate's global handle -- and + // the unroot is idempotent, since terminate() and the thread-exit + // notification can both reach it. + void RootWorkerObject(); + void UnrootWorkerObject(); + // Dispatches the end-of-worker event and unroots. Main isolate's thread, + // with the isolate entered and locked by the caller. + void EndWrapperLifetime(); + + ~WorkerWrapper(); + const WrapperType Type(); const int Id(); const inline bool isDisposed() { return isDisposed_; } @@ -609,6 +624,15 @@ class WorkerWrapper : public BaseDataWrapper { // thread) and DestroyInspector() (worker thread) agree on liveness. v8_inspector::WorkerInspectorClient* inspector_ = nullptr; std::mutex inspectorMutex_; + // Parked while the Worker object is rooted, so the unroot can re-arm the very + // finalizer ObjectManager::Register installed. Main isolate's thread only. + ObjectWeakCallbackState* weakCallbackState_ = nullptr; + bool workerObjectRooted_ = false; + // Cleared by the destructor, so a task posted from the worker thread can tell + // whether this wrapper still exists once it reaches the main isolate. The + // wrapper is only ever destroyed with that isolate locked, which is what the + // task takes before reading this. + std::shared_ptr> selfRef_; void BackgroundLooper(std::function func); void DrainPendingTasks(); diff --git a/NativeScript/runtime/ObjectManager.mm b/NativeScript/runtime/ObjectManager.mm index 5a0cb2f4..8c7e25e9 100644 --- a/NativeScript/runtime/ObjectManager.mm +++ b/NativeScript/runtime/ObjectManager.mm @@ -278,7 +278,16 @@ void DisposeHandle(v8::Isolate* isolate, case WrapperType::Worker: { WorkerWrapper* worker = static_cast(wrapper); if (!worker->isDisposed()) { - // during final disposal, inform the worker it should delete itself + // A running worker's Worker object is rooted (WorkerWrapper:: + // RootWorkerObject), so a weak callback should not reach a live worker + // at all. This refusal stays as the floor under that: re-arming keeps + // the wrapper alive for another cycle, which is safe, whereas freeing + // it while the thread still posts through it is not. Reaching it is not + // free either -- a re-armed handle that is also a weak-collection key + // can corrupt the collector's ephemeron bookkeeping -- so it is a + // fallback, not a mechanism to rely on. + // + // During final disposal, inform the worker it should delete itself. if (isFinalDisposal) { worker->MakeWeak(); } diff --git a/NativeScript/runtime/Worker.h b/NativeScript/runtime/Worker.h index e4ab1b6e..70c81467 100644 --- a/NativeScript/runtime/Worker.h +++ b/NativeScript/runtime/Worker.h @@ -30,6 +30,13 @@ class Worker { const std::string& message, const std::string& source, const std::string& stackTrace, int lineNumber); + // Dispatches `nsworkerended` on `receiver` (the Worker object, on the parent + // isolate) once the worker's thread has finished. Internal and non-standard: + // the web has no end-of-worker event, and the node:worker_threads shim is + // what turns this into an 'exit'. A listener that throws leaves the exception + // pending for the caller's TryCatch. No-op before InitEvents has run. + static void EmitEnded(v8::Isolate* isolate, v8::Local receiver); + static std::vector GlobalFunctions; private: diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index 97db23f7..125bc284 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -18,11 +18,12 @@ namespace { // The worker-events builtin's delivery callouts for this isolate. Both message -// directions share emitMessage; only the receiver differs. emitError is -// parent-side only. +// directions share emitMessage; only the receiver differs. emitError and +// emitEnded are parent-side only. struct WorkerEventsState { Global emitMessage; Global emitError; + Global emitEnded; }; } // namespace @@ -80,10 +81,16 @@ emitError->IsFunction(); tns::Assert(success, isolate); + Local emitEnded; + success = exports->Get(context, tns::ToV8String(isolate, "emitEnded")).ToLocal(&emitEnded) && + emitEnded->IsFunction(); + tns::Assert(success, isolate); + WorkerEventsState* state = Caches::StateFor(isolate); tns::Assert(state != nullptr, isolate); state->emitMessage.Reset(isolate, emitMessage.As()); state->emitError.Reset(isolate, emitError.As()); + state->emitEnded.Reset(isolate, emitEnded.As()); } void Worker::ConstructorCallback(const FunctionCallbackInfo& info) { @@ -351,6 +358,10 @@ throw NativeScriptException( }); worker->Start(poWorker, func, qos); + // The thread is away, so from here the Worker object is a GC root. The + // parent's loop cannot run before this returns, so the thread-exit + // notification can never overtake this root. + worker->RootWorkerObject(); std::shared_ptr state = std::make_shared(isolate, poWorker, worker); @@ -512,6 +523,16 @@ throw NativeScriptException( return result->BooleanValue(isolate); } +void Worker::EmitEnded(Isolate* isolate, Local receiver) { + WorkerEventsState* state = Caches::StateFor(isolate); + if (state == nullptr || state->emitEnded.IsEmpty()) { + return; + } + Local context = Caches::Get(isolate)->GetContext(); + Local result; + (void)state->emitEnded.Get(isolate)->Call(context, receiver, 0, nullptr).ToLocal(&result); +} + void Worker::CloseWorkerCallback(const FunctionCallbackInfo& info) { Isolate* isolate = info.GetIsolate(); int workerId = Worker::GetWorkerId(isolate, info.This()); @@ -549,6 +570,10 @@ throw NativeScriptException( WorkerWrapper* worker = static_cast(wrapper); worker->Terminate(); + // The root is NOT released here: the wrapper stays strong until the thread + // has actually wound down and the thread-exit notification releases it, so + // no GC can condemn a wrapper whose thread is still draining — the + // ObjectManager resurrection fallback stays unreachable for workers. } void Worker::SetWorkerId(Isolate* isolate, int workerId) { diff --git a/NativeScript/runtime/WorkerWrapper.mm b/NativeScript/runtime/WorkerWrapper.mm index 228f3321..3d137045 100644 --- a/NativeScript/runtime/WorkerWrapper.mm +++ b/NativeScript/runtime/WorkerWrapper.mm @@ -3,6 +3,7 @@ #include "Constants.h" #include "DataWrapper.h" #include "Helpers.h" +#include "ObjectManager.h" #include "Runtime.h" #include "RuntimeConfig.h" #include "Worker.h" @@ -55,7 +56,10 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a isDisposed_(false), isWeak_(false), messagesEnabled_(false), - onMessage_(onMessage) {} + onMessage_(onMessage), + selfRef_(std::make_shared>(this)) {} + +WorkerWrapper::~WorkerWrapper() { this->selfRef_->store(nullptr, std::memory_order_release); } const WrapperType WorkerWrapper::Type() { return WrapperType::Worker; } @@ -91,6 +95,44 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a this->isRunning_ = true; } +void WorkerWrapper::RootWorkerObject() { + if (this->workerObjectRooted_ || this->poWorker_ == nullptr || this->poWorker_->IsEmpty() || + !this->poWorker_->IsWeak()) { + return; + } + this->weakCallbackState_ = this->poWorker_->ClearWeak(); + this->workerObjectRooted_ = true; +} + +void WorkerWrapper::UnrootWorkerObject() { + if (!this->workerObjectRooted_) { + return; + } + this->workerObjectRooted_ = false; + ObjectWeakCallbackState* state = this->weakCallbackState_; + this->weakCallbackState_ = nullptr; + if (state == nullptr || this->poWorker_ == nullptr || this->poWorker_->IsEmpty()) { + return; + } + this->poWorker_->SetWeak(state, ObjectManager::FinalizerCallback, + v8::WeakCallbackType::kFinalizer); +} + +void WorkerWrapper::EndWrapperLifetime() { + Local worker = + this->poWorker_ != nullptr ? this->poWorker_->Get(this->mainIsolate_) : Local(); + if (!worker.IsEmpty() && worker->IsObject()) { + TryCatch tc(this->mainIsolate_); + Worker::EmitEnded(this->mainIsolate_, worker.As()); + if (tc.HasCaught()) { + Local error = tc.Exception(); + Log(@"%s", tns::ToString(this->mainIsolate_, error).c_str()); + this->mainIsolate_->ThrowException(error); + } + } + this->UnrootWorkerObject(); +} + void WorkerWrapper::DrainPendingTasks() { // The drain source is armed (and can be signaled by a main-thread // PostMessage) BEFORE `workerIsolate_` is assigned in BackgroundLooper, and @@ -151,6 +193,33 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a } } +// Hands the parent isolate the end-of-worker notification: the `nsworkerended` +// dispatch and the unroot that makes the Worker object collectable again. +// Takes only primitives plus the liveness token, because the wrapper it acts on +// may already be gone by the time the parent's loop gets here -- and, when the +// parent is shutting down, the post is dropped and the parent's teardown +// cascade owns disposal instead. +static void PostThreadEndedNotification(Isolate* mainIsolate, + std::shared_ptr> selfRef) { + auto runtime = static_cast(mainIsolate->GetData(Constants::RUNTIME_SLOT)); + if (runtime == nullptr) { + return; + } + PostToRuntimeLoop( + runtime, + [mainIsolate, selfRef]() { + v8::Locker locker(mainIsolate); + Isolate::Scope isolate_scope(mainIsolate); + HandleScope handle_scope(mainIsolate); + WorkerWrapper* self = selfRef->load(std::memory_order_acquire); + if (self == nullptr) { + return; + } + self->EndWrapperLifetime(); + }, + true); +} + void WorkerWrapper::BackgroundLooper(std::function func) { if (!this->isTerminating_) { CFRunLoopRef runLoop = CFRunLoopGetCurrent(); @@ -177,6 +246,13 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a this->DestroyInspector(); this->isDisposed_ = true; + + // Read before the Runtime goes: its destructor deletes this wrapper when the + // parent isolate already tore down and handed ownership over, so nothing + // below may touch `this`. + Isolate* mainIsolate = this->mainIsolate_; + std::shared_ptr> selfRef = this->selfRef_; + Runtime* runtime = Runtime::GetCurrentRuntime(); if (runtime != nullptr) { delete runtime; @@ -190,6 +266,8 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a Caches::Workers->Remove(workerId); } } + + PostThreadEndedNotification(mainIsolate, selfRef); } void WorkerWrapper::EnableMessageQueue() { diff --git a/NativeScript/runtime/js/README.md b/NativeScript/runtime/js/README.md index c1bdcf73..90f96478 100644 --- a/NativeScript/runtime/js/README.md +++ b/NativeScript/runtime/js/README.md @@ -117,10 +117,12 @@ The two extra rules a lazy builtin lives by: are whatever user code left behind, so it should not reach for them at all. - The per-instance wrappers `defineEventHandler` creates live on the target's **own listener bag**, under a private symbol — never in a WeakMap keyed by - the target. An ObjectManager-registered object (a `Worker`) can be - resurrected by its finalizer while its thread is alive, and a resurrected - object's weak-collection entries are already gone, so a WeakMap would hand - the revived object a fresh, empty handler map. + the target. Own-instance state is Node's own design for handler attributes, + and it keeps the builtins independent of the patched collector's handling of + resurrected ephemeron keys (`kFinalizer` resurrection interacting with + WeakMaps has been a source of collector bugs, and the patch is re-ported on + every V8 upgrade — builtins not leaning on it means a re-port mistake breaks + app-level tests, not the event system itself). - No `import`/`export` — these are classic function bodies, not modules. - ESLint (`eslint.config.mjs` at the repo root, run by lint-staged) declares `exports`, `require`, `module`, `binding`, `primordials` and the reachable diff --git a/NativeScript/runtime/js/node-worker-threads.js b/NativeScript/runtime/js/node-worker-threads.js index 3f1e49ca..b0225a2f 100644 --- a/NativeScript/runtime/js/node-worker-threads.js +++ b/NativeScript/runtime/js/node-worker-threads.js @@ -175,11 +175,31 @@ class Worker extends WorkerEmitter { worker.onerror = function (error) { self.emit("error", error); }; + // The runtime's end-of-worker event, which a worker's own close() reaches + // as much as a terminate() does — so 'exit' is not the terminate()-only + // signal it used to be. + FunctionPrototypeCall( + addEventListener, + worker, + "nsworkerended", + function () { + self.#reportExit(); + } + ); soon(function () { self.emit("online", undefined); }); } + // Both ends of a worker report through here, and Node emits 'exit' once. + #reportExit() { + if (this.#exited) { + return; + } + this.#exited = true; + this.emit("exit", 0); + } + postMessage(value, transfer) { this.#worker.postMessage(value, transfer); } @@ -188,10 +208,7 @@ class Worker extends WorkerEmitter { this.#worker.terminate(); const self = this; return PromisePrototypeThen(PromiseResolve(), function () { - if (!self.#exited) { - self.#exited = true; - self.emit("exit", 0); - } + self.#reportExit(); return 0; }); } diff --git a/NativeScript/runtime/js/worker-events.js b/NativeScript/runtime/js/worker-events.js index ecc3f089..c64b1cfc 100644 --- a/NativeScript/runtime/js/worker-events.js +++ b/NativeScript/runtime/js/worker-events.js @@ -12,6 +12,7 @@ const { ObjectDefineProperty, ObjectSetPrototypeOf } = primordials; const { + Event, EventTarget, defineEventHandler, dispatchEventRethrowing, @@ -73,6 +74,15 @@ function emitError(message, filename, lineno, stackTrace) { return event.defaultPrevented; } +// The parent-side end-of-worker callout, invoked by native with the Worker +// object as `this` once the worker's thread has finished — its own close() as +// much as a terminate(). `nsworkerended` is internal and non-standard: the web +// has no end-of-worker event, and the node:worker_threads shim is what turns +// this into an 'exit'. +function emitEnded() { + dispatchEventRethrowing(this, new Event("nsworkerended")); +} + ObjectSetPrototypeOf(g.Worker.prototype, EventTarget.prototype); defineEventHandler(g.Worker.prototype, "message"); defineEventHandler(g.Worker.prototype, "messageerror"); @@ -99,4 +109,4 @@ for (const name of ["onmessage", "onmessageerror"]) { }); } -module.exports = { emitMessage, emitError }; +module.exports = { emitMessage, emitError, emitEnded }; diff --git a/TestRunner/app/tests/WorkerLifetimeTests.js b/TestRunner/app/tests/WorkerLifetimeTests.js new file mode 100644 index 00000000..0ee8f88d --- /dev/null +++ b/TestRunner/app/tests/WorkerLifetimeTests.js @@ -0,0 +1,220 @@ +// Worker lifetime under GC. A running worker's JS wrapper is a GC root, so it +// behaves like any other strongly held object: weak collections keyed on it +// keep their entries, and it keeps answering messages nobody holds a reference +// to it for. Once the worker ends — terminate() or its own close() — the root +// is dropped and the wrapper becomes collectable. + +describe("Worker lifetime", function () { + const WORKER_COUNT = 4; + const PAYLOAD_SIZE = 64; + + // A collection per runloop turn: weak-collection clearing needs turns after + // the collect, so nothing here asserts synchronously after __collect(). + function pollGC(predicate, cb) { + let turns = 0; + (function poll() { + __collect(); + if (predicate() || turns >= 100) { + cb(); + return; + } + turns++; + setTimeout(poll, 20); + })(); + } + + // Reached through a call rather than a closure, so the worker it derefs + // cannot end up in a scope the caller's later callbacks keep alive. + function terminateWorker(ref) { + const worker = ref.deref(); + if (worker !== undefined) { + worker.terminate(); + } + } + + function postToWorker(ref, message) { + const worker = ref.deref(); + if (worker !== undefined) { + worker.postMessage(message); + } + } + + // Enough allocation to put V8 part-way through an incremental/concurrent + // mark, so the collection that follows finishes a mark that was already + // running rather than starting an atomic one. + function churn() { + let sink = null; + for (let i = 0; i < 24; i++) { + const block = new Array(8192); + for (let j = 0; j < 8192; j++) { + block[j] = { j: j, s: "churn-" + j }; + } + sink = block; + } + return sink !== null; + } + + function makePayload(id) { + const payload = new Array(PAYLOAD_SIZE); + for (let i = 0; i < PAYLOAD_SIZE; i++) { + payload[i] = "payload-" + id + "-" + i; + } + return payload; + } + + it("a live Worker survives GC as a WeakMap key", function (done) { + // Nothing outside this map holds the values: an entry whose key stays + // alive while its value is not marked is what leaves a dangling value + // slot behind. + const sideTable = new WeakMap(); + const refs = []; + let replies = 0; + + for (let i = 0; i < WORKER_COUNT; i++) { + refs.push((function () { + const worker = new Worker("./eventLoopEchoWorker.js"); + // A second entry reachable only through the first one's value, + // so resolving these takes more than one ephemeron pass. + const link = { id: i }; + sideTable.set(link, { deep: i, payload: makePayload("deep" + i) }); + sideTable.set(worker, { id: i, link: link, payload: makePayload(i) }); + worker.onmessage = function () { replies++; }; + worker.postMessage("ping"); + return new WeakRef(worker); + })()); + } + + let round = 0; + function spin() { + churn(); + // async execution runs the collection from a task, so V8 treats the + // stack as pointer-free and the workers are genuinely unreachable + // for it — a conservative scan of this frame would not let them be. + __collect({ execution: "async" }).then(function () { + __collect(); + + // Only some turns touch the workers: a turn that does not leaves + // them dead for a whole mark cycle. + if (round % 3 === 0) { + for (let i = 0; i < refs.length; i++) { + postToWorker(refs[i], "ping-" + round); + } + } + + round++; + if (round < 15) { + setTimeout(spin, 20); + return; + } + + for (let i = 0; i < refs.length; i++) { + const survivor = refs[i].deref(); + expect(survivor).not.toBeUndefined(); + if (survivor === undefined) { + continue; + } + const entry = sideTable.get(survivor); + expect(entry).not.toBeUndefined(); + if (entry !== undefined) { + expect(entry.id).toBe(i); + expect(entry.payload.length).toBe(PAYLOAD_SIZE); + expect(entry.payload[PAYLOAD_SIZE - 1]).toBe("payload-" + i + "-" + (PAYLOAD_SIZE - 1)); + const deep = sideTable.get(entry.link); + expect(deep).not.toBeUndefined(); + if (deep !== undefined) { + expect(deep.deep).toBe(i); + expect(deep.payload.length).toBe(PAYLOAD_SIZE); + } + } + } + expect(replies).toBeGreaterThan(0); + + for (let i = 0; i < refs.length; i++) { + terminateWorker(refs[i]); + } + done(); + }); + } + spin(); + }); + + it("an unreferenced live Worker still answers messages", function (done) { + let reply = null; + const ref = (function () { + const worker = new Worker("./eventLoopEchoWorker.js"); + worker.onmessage = function (event) { reply = event.data; }; + worker.postMessage("hello"); + return new WeakRef(worker); + })(); + + pollGC(function () { return reply !== null; }, function () { + expect(reply).toBe("hello"); + expect(ref.deref()).not.toBeUndefined(); + terminateWorker(ref); + done(); + }); + }); + + it("a terminated Worker becomes collectable", function (done) { + const ref = (function () { + const worker = new Worker("./eventLoopEchoWorker.js"); + worker.postMessage("ping"); + return new WeakRef(worker); + })(); + + setTimeout(function () { + terminateWorker(ref); + setTimeout(function () { + pollGC(function () { return ref.deref() === undefined; }, function () { + expect(ref.deref()).toBeUndefined(); + done(); + }); + }, 100); + }, 150); + }); + + it("a Worker that closed itself becomes collectable", function (done) { + const ref = (function () { + const worker = new Worker("./workerLifetimeCloseWorker.js"); + worker.postMessage("close"); + return new WeakRef(worker); + })(); + + setTimeout(function () { + pollGC(function () { return ref.deref() === undefined; }, function () { + expect(ref.deref()).toBeUndefined(); + done(); + }); + }, 300); + }); +}); + +describe("node:worker_threads Worker exit", function () { + const wt = require("node:worker_threads"); + + it("emits 'exit' once when the worker closes itself", function (done) { + const worker = new wt.Worker("~/tests/workerLifetimeCloseWorker.js"); + const codes = []; + worker.on("exit", function (code) { codes.push(code); }); + worker.postMessage("go"); + + setTimeout(function () { + expect(codes).toEqual([0]); + done(); + }, 800); + }); + + it("emits 'exit' once on terminate()", function (done) { + const worker = new wt.Worker("~/tests/eventLoopEchoWorker.js"); + const codes = []; + worker.on("exit", function (code) { codes.push(code); }); + + setTimeout(function () { + worker.terminate(); + setTimeout(function () { + expect(codes).toEqual([0]); + done(); + }, 800); + }, 150); + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 4b6558f6..4e3fbdca 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -192,6 +192,9 @@ require("./NapiCoverageTests"); // Worker-isolate scoping of extended objc class names require("./ExtendedClassNamingTests"); +// Worker wrapper reachability across GC (strong while running, collectable after) +require("./WorkerLifetimeTests"); + // Tests common for all runtimes (git submodule of NativeScript/common-runtime-tests-app). require("../shared/index").runAllTests(); diff --git a/TestRunner/app/tests/workerLifetimeCloseWorker.js b/TestRunner/app/tests/workerLifetimeCloseWorker.js new file mode 100644 index 00000000..ed90b183 --- /dev/null +++ b/TestRunner/app/tests/workerLifetimeCloseWorker.js @@ -0,0 +1,6 @@ +// Ends itself on request, so the parent can observe the end-of-worker path +// that does not go through terminate(). +onmessage = function () { + postMessage("closing"); + close(); +}; diff --git a/docs/worker-threads.md b/docs/worker-threads.md index 0dbb04e9..627bf944 100644 --- a/docs/worker-threads.md +++ b/docs/worker-threads.md @@ -54,7 +54,7 @@ means deliberately unsupported. | `threadName` | shim | Always `undefined`. | | `workerData` | shim | Always `null` — see below. | | `parentPort` | shim | `null` on the main isolate. Inside a worker, a `MessagePort`-shaped `EventTarget` over the worker's existing parent channel: `postMessage` forwards to the global `postMessage`, `message`/`messageerror` are re-dispatched from the worker global scope, `start()` and `close()` are no-ops. It is **not** a real port: not transferable, no queue of its own. | -| `Worker` | shim | A class over the runtime's global `Worker` with a small Node-style emitter (`on`/`once`/`off`/`removeListener`) for `message`, `messageerror`, `error`, `online` and `exit`. `postMessage(value, transfer)` and `terminate()` forward. `online` is emitted off a microtask after construction, not from the thread. Unsupported options throw a `TypeError` naming the option: `workerData`, `env`, `eval`, `transferList`, and `stdin`/`stdout`/`stderr` when explicitly truthy. | +| `Worker` | shim | A class over the runtime's global `Worker` with a small Node-style emitter (`on`/`once`/`off`/`removeListener`) for `message`, `messageerror`, `error`, `online` and `exit`. `postMessage(value, transfer)` and `terminate()` forward. `online` is emitted off a microtask after construction, not from the thread. `exit` (always code `0`) fires exactly once, whether the worker was terminated or ended by its own `close()`. Unsupported options throw a `TypeError` naming the option: `workerData`, `env`, `eval`, `transferList`, and `stdin`/`stdout`/`stderr` when explicitly truthy. | | `postMessageToThread` | throws | `Error: postMessageToThread is not supported in this runtime`. | | `moveMessagePortToContext` | throws | `Error: moveMessagePortToContext is not supported in this runtime`. | | `locks` | absent | Web Locks are not implemented; the property does not exist. | @@ -72,12 +72,12 @@ Values are cloned on the way in and deserialized fresh on each read, so mutating the object you passed does not reach a reader, and two readers never share one object. -### `exit` comes only from `terminate()` +### `exit` always carries code `0` -The runtime has no thread-exit signal — nothing reports that a worker's isolate -finished. `terminate()` therefore resolves with `0` and emits `exit` with code -`0` on the way, and that is the only path that emits it. A worker that ends by -its own `close()` produces no `exit`. +Node reports the thread's exit code; this runtime has none to report, so `exit` +is emitted with `0` from both paths that end a worker — `terminate()` (whose +promise also resolves with `0`) and the worker's own `close()`. Whichever the +worker took, `exit` fires exactly once. ### A worker error carries no `error` object, and the worker scope's `onerror` is not an event @@ -253,3 +253,39 @@ rather than raising a `DataCloneError`, which is long-standing behaviour app code relies on. Transfer is not part of that leniency — a port in a worker transfer list is validated exactly as it is everywhere else, since degrading a transfer would strand the port's sibling. + +## Worker lifetime + +**A `Worker` is held strongly by the runtime from the moment its thread starts +until that thread ends**, the way a browser keeps a running worker's handle +alive. Dropping every reference to one does not stop it: it keeps running, and +it keeps dispatching `message` and `error` events at the handlers installed on +it. + +```js +(function () { + const worker = new Worker("./worker.js"); + worker.onmessage = handle; // still fires; nothing here holds `worker` + worker.postMessage("go"); +})(); +``` + +Being a GC root also means a `Worker` is a well-behaved key: put one in a +`WeakMap`, `WeakSet` or `WeakRef` and the entry survives for as long as the +worker runs. + +The root is released when the worker ends — `terminate()`, or the worker's own +`close()`. From then on the object is collectable like any other, and the +runtime drops the native side with it. Nothing about a *finished* worker is +kept alive. + +### `nsworkerended` + +When the worker's thread has finished, the runtime dispatches a plain `Event` +named `nsworkerended` on the `Worker` object. It is **internal and +non-standard** — the web has no end-of-worker event, and the name is deliberately +outside the standard namespace. It exists so that `node:worker_threads` can +report `'exit'` for a worker that ended by its own `close()`; app code should +not rely on it. The event is best effort: a worker whose parent is already +tearing down never delivers it, because the parent's own teardown disposes the +worker anyway.