Skip to content

Commit ab72efc

Browse files
authored
fix(runtime): strong Worker wrapper lifetime while the thread runs (#456)
* fix(runtime): Worker wrappers are strong roots while their thread runs Replaces the finalizer-resurrection lifetime with reachability: the wrapper's persistent goes strong once the thread starts and is released only by the thread-exit notification, posted from the worker's teardown to the parent's event loop — terminate() initiates the wind-down but never drops the root early, so no GC can condemn a wrapper whose thread is still draining. ObjectManager's refuse-and-re-weaken branch stays as a defensive fallback but is unreachable for workers. The motivation is a reproduced heap corruption: the patched collector's kFinalizer resurrection handles ephemeron keys in the atomic pause but not under concurrent marking — a resurrected WeakMap key whose values are reachable only through the entry leaves a dangling value slot that crashes ConcurrentMarkingVisitor::RecordSlot on a later cycle. Strong lifetime takes Worker off that path entirely; the collector bug is tracked separately for the other resurrectable wrapper types. The thread-exit notification also dispatches the internal nsworkerended event on the Worker object, so node:worker_threads' Worker shim now emits 'exit' exactly once for self-close as well as terminate(). Suite: 1663/0 incl. new WorkerLifetimeTests (WeakMap-key repro that crashed before this change, collectability after terminate and self-close, delivery to an unreferenced live worker). * docs(runtime): the listener-bag rule is Node parity plus patch independence, not a live crash The wrapper-keyed-WeakMap corruption was a collector bug fixed in the v8-14.9.207.39-6 prebuilts; the rule stays because own-instance state is Node's design for handler attributes and keeps the builtins off the resurrection/ephemeron interplay the kFinalizer patch must re-cover on every V8 upgrade. * fix(runtime): reach the parent through its event loop, never its isolate, from the worker thread Worker-thread posts to the parent read the parent isolate's runtime slot and then the runtime's loop. The parent's destructor terminates its children without joining them, clears that slot and disposes the isolate, so a child ending while a worker-parent was torn down could read a freed isolate or a runtime mid-destruction. The wrapper now captures a weak_ptr to the parent's loop on the parent's thread at construction; a loop that has shut down drops the post and an expired pointer means the parent is gone. BackgroundLooper also reads everything it needs before publishing isDisposed_, which is what allows a tearing-down parent to delete the wrapper concurrently. * test(runtime): a terminated worker whose dropped message sentinels a port it owns must end * fix(runtime): settle terminate() from the thread-ended notification, after exit The shim emitted exit and resolved terminate() off a microtask, before the thread was down and before messages and errors the worker had already queued on the parent's loop had run. Both now follow the runtime's end-of-worker notification, so nothing the worker sent can arrive after exit. The code stays 0 for every end, as the cross-runtime suite pins. * docs(runtime): drop the resurrection rationale the strong Worker root made stale, and name the real patch gate
1 parent 94b04db commit ab72efc

16 files changed

Lines changed: 565 additions & 43 deletions

‎NativeScript/runtime/DataWrapper.h‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class WorkerInspectorClient;
2020
namespace tns {
2121

2222
class PrimitiveDataWrapper;
23+
struct ObjectWeakCallbackState;
24+
class EventLoop;
2325

2426
enum class WrapperType {
2527
Base = 1 << 0,
@@ -598,6 +600,19 @@ class WorkerWrapper : public BaseDataWrapper {
598600
inline bool HeapLimitExceeded() const {
599601
return heapLimitExceeded_.load(std::memory_order_acquire);
600602
}
603+
// The JS Worker object is a GC root from a successful start until the worker
604+
// ends, so a running worker is reachable the way a browser's is rather than
605+
// depending on its finalizer to keep it. Both of these run on the main
606+
// isolate's thread only -- they re-arm that isolate's global handle -- and
607+
// the unroot is idempotent, so an end reached by more than one path re-arms
608+
// the finalizer once.
609+
void RootWorkerObject();
610+
void UnrootWorkerObject();
611+
// Dispatches the end-of-worker event and unroots. Main isolate's thread,
612+
// with the isolate entered and locked by the caller.
613+
void EndWrapperLifetime();
614+
615+
~WorkerWrapper();
601616

602617
const WrapperType Type();
603618
const int Id();
@@ -606,6 +621,8 @@ class WorkerWrapper : public BaseDataWrapper {
606621
const bool IsClosing();
607622
const int WorkerId();
608623
const inline v8::Isolate* GetMainIsolate() { return mainIsolate_; }
624+
// The only route from the worker thread to the parent: see mainLoop_.
625+
std::weak_ptr<EventLoop> MainLoop() const { return mainLoop_; }
609626
const inline v8::Isolate* GetWorkerIsolate() { return workerIsolate_; }
610627
const inline void MakeWeak() { isWeak_ = true; }
611628
const inline bool IsWeak() { return isWeak_; }
@@ -625,6 +642,12 @@ class WorkerWrapper : public BaseDataWrapper {
625642
std::shared_ptr<worker::Message>)>
626643
onMessage_;
627644
std::shared_ptr<v8::Persistent<v8::Value>> poWorker_;
645+
// The parent's event loop, taken on the parent's thread at construction.
646+
// Every worker-thread post to the parent goes through it and never through
647+
// the parent isolate: the parent runtime may be mid-teardown or its isolate
648+
// already disposed when the post runs, whereas a loop that has shut down
649+
// drops the post, and an expired pointer means the parent is gone entirely.
650+
std::weak_ptr<EventLoop> mainLoop_;
628651
ConcurrentQueue queue_;
629652
static std::atomic<int> nextId_;
630653
int workerId_;
@@ -645,6 +668,15 @@ class WorkerWrapper : public BaseDataWrapper {
645668
// handle may be created.
646669
static size_t OnNearHeapLimit(void* data, size_t current_heap_limit,
647670
size_t initial_heap_limit);
671+
// Parked while the Worker object is rooted, so the unroot can re-arm the very
672+
// finalizer ObjectManager::Register installed. Main isolate's thread only.
673+
ObjectWeakCallbackState* weakCallbackState_ = nullptr;
674+
bool workerObjectRooted_ = false;
675+
// Cleared by the destructor, so a task posted from the worker thread can tell
676+
// whether this wrapper still exists once it reaches the main isolate. The
677+
// wrapper is only ever destroyed with that isolate locked, which is what the
678+
// task takes before reading this.
679+
std::shared_ptr<std::atomic<WorkerWrapper*>> selfRef_;
648680

649681
void BackgroundLooper(std::function<v8::Isolate*()> func);
650682
void DrainPendingTasks();

‎NativeScript/runtime/ObjectManager.mm‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,16 @@ void DisposeHandle(v8::Isolate* isolate,
304304
case WrapperType::Worker: {
305305
WorkerWrapper* worker = static_cast<WorkerWrapper*>(wrapper);
306306
if (!worker->isDisposed()) {
307-
// during final disposal, inform the worker it should delete itself
307+
// A running worker's Worker object is rooted (WorkerWrapper::
308+
// RootWorkerObject), so a weak callback should not reach a live worker
309+
// at all. This refusal stays as the floor under that: re-arming keeps
310+
// the wrapper alive for another cycle, which is safe, whereas freeing
311+
// it while the thread still posts through it is not. Reaching it is not
312+
// free either -- a re-armed handle that is also a weak-collection key
313+
// can corrupt the collector's ephemeron bookkeeping -- so it is a
314+
// fallback, not a mechanism to rely on.
315+
//
316+
// During final disposal, inform the worker it should delete itself.
308317
if (isFinalDisposal) {
309318
worker->MakeWeak();
310319
}

‎NativeScript/runtime/Worker.h‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ class Worker {
3030
const std::string& message, const std::string& source,
3131
const std::string& stackTrace, int lineNumber);
3232

33+
// Dispatches `nsworkerended` on `receiver` (the Worker object, on the parent
34+
// isolate) once the worker's thread has finished. Internal and non-standard:
35+
// the web has no end-of-worker event, and the node:worker_threads shim is
36+
// what turns this into an 'exit'. A listener that throws leaves the exception
37+
// pending for the caller's TryCatch. No-op before InitEvents has run.
38+
static void EmitEnded(v8::Isolate* isolate, v8::Local<v8::Object> receiver);
39+
3340
static std::vector<std::string> GlobalFunctions;
3441

3542
private:

‎NativeScript/runtime/Worker.mm‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@
2222
namespace {
2323

2424
// The worker-events builtin's delivery callouts for this isolate. Both message
25-
// directions share emitMessage; only the receiver differs. emitError is
26-
// parent-side only.
25+
// directions share emitMessage; only the receiver differs. emitError and
26+
// emitEnded are parent-side only.
2727
struct WorkerEventsState {
2828
Global<v8::Function> emitMessage;
2929
Global<v8::Function> emitError;
30+
Global<v8::Function> emitEnded;
3031
};
3132

3233
} // namespace
@@ -294,10 +295,16 @@ bool ParseResourceLimits(Isolate* isolate, Local<Context> context, Local<Object>
294295
emitError->IsFunction();
295296
tns::Assert(success, isolate);
296297

298+
Local<Value> emitEnded;
299+
success = exports->Get(context, tns::ToV8String(isolate, "emitEnded")).ToLocal(&emitEnded) &&
300+
emitEnded->IsFunction();
301+
tns::Assert(success, isolate);
302+
297303
WorkerEventsState* state = Caches::StateFor<WorkerEventsState>(isolate);
298304
tns::Assert(state != nullptr, isolate);
299305
state->emitMessage.Reset(isolate, emitMessage.As<v8::Function>());
300306
state->emitError.Reset(isolate, emitError.As<v8::Function>());
307+
state->emitEnded.Reset(isolate, emitEnded.As<v8::Function>());
301308
}
302309

303310
void Worker::ConstructorCallback(const FunctionCallbackInfo<Value>& info) {
@@ -596,6 +603,10 @@ throw NativeScriptException(
596603
Caches::Workers->Insert(worker->Id(), state);
597604

598605
worker->Start(poWorker, func, qos);
606+
// The thread is away, so from here the Worker object is a GC root. The
607+
// parent's loop cannot run before this returns, so the thread-exit
608+
// notification can never overtake this root.
609+
worker->RootWorkerObject();
599610
} catch (NativeScriptException& ex) {
600611
ex.ReThrowToV8(isolate);
601612
}
@@ -625,8 +636,8 @@ throw NativeScriptException(
625636
// Resolved before anything is serialized: serializing a transfer list
626637
// detaches the caller's buffers, so bailing out afterwards would destroy
627638
// their contents without ever delivering the message.
628-
auto runtime = static_cast<Runtime*>(state->GetIsolate()->GetData(Constants::RUNTIME_SLOT));
629-
if (runtime == nullptr) {
639+
std::shared_ptr<EventLoop> mainLoop = worker->MainLoop().lock();
640+
if (mainLoop == nullptr) {
630641
return;
631642
}
632643

@@ -642,7 +653,7 @@ throw NativeScriptException(
642653
return;
643654
}
644655

645-
runtime->GetEventLoop()->PostInternal([state, message]() {
656+
mainLoop->PostInternal([state, message]() {
646657
Isolate* isolate = state->GetIsolate();
647658
v8::Locker locker(isolate);
648659
Isolate::Scope isolate_scope(isolate);
@@ -752,6 +763,16 @@ throw NativeScriptException(
752763
return result->BooleanValue(isolate);
753764
}
754765

766+
void Worker::EmitEnded(Isolate* isolate, Local<Object> receiver) {
767+
WorkerEventsState* state = Caches::StateFor<WorkerEventsState>(isolate);
768+
if (state == nullptr || state->emitEnded.IsEmpty()) {
769+
return;
770+
}
771+
Local<Context> context = Caches::Get(isolate)->GetContext();
772+
Local<Value> result;
773+
(void)state->emitEnded.Get(isolate)->Call(context, receiver, 0, nullptr).ToLocal(&result);
774+
}
775+
755776
void Worker::CloseWorkerCallback(const FunctionCallbackInfo<Value>& info) {
756777
Isolate* isolate = info.GetIsolate();
757778
int workerId = Worker::GetWorkerId(isolate, info.This());
@@ -789,6 +810,10 @@ throw NativeScriptException(
789810

790811
WorkerWrapper* worker = static_cast<WorkerWrapper*>(wrapper);
791812
worker->Terminate();
813+
// The root is NOT released here: the wrapper stays strong until the thread
814+
// has actually wound down and the thread-exit notification releases it, so
815+
// no GC can condemn a wrapper whose thread is still draining — the
816+
// ObjectManager resurrection fallback stays unreachable for workers.
792817
}
793818

794819
void Worker::SetWorkerId(Isolate* isolate, int workerId) {

‎NativeScript/runtime/WorkerWrapper.mm‎

Lines changed: 88 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include "DataWrapper.h"
55
#include "ErrorEvents.h"
66
#include "Helpers.h"
7+
#include "ObjectManager.h"
78
#include "Runtime.h"
89
#include "RuntimeConfig.h"
910
#include "Worker.h"
@@ -24,11 +25,8 @@
2425
// Posts to the target runtime's internal lane from the worker thread. When
2526
// async is false, blocks until the entry ran - or until it is destroyed
2627
// unrun by a shutdown that raced the post, which must release the waiter too.
27-
static void PostToRuntimeLoop(Runtime* runtime, std::function<void()> fn, bool async) {
28-
auto loop = runtime->GetEventLoop();
29-
if (loop == nullptr) {
30-
return;
31-
}
28+
static void PostToLoop(const std::shared_ptr<EventLoop>& loop, std::function<void()> fn,
29+
bool async) {
3230
if (async) {
3331
loop->PostInternal(std::move(fn));
3432
return;
@@ -57,7 +55,11 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function<void()> fn, bool a
5755
isWeak_(false),
5856
messagesEnabled_(false),
5957
onMessage_(onMessage),
60-
workerId_(nextId_.fetch_add(1, std::memory_order_relaxed) + 1) {}
58+
mainLoop_(Runtime::GetRuntime(mainIsolate)->GetEventLoop()),
59+
workerId_(nextId_.fetch_add(1, std::memory_order_relaxed) + 1),
60+
selfRef_(std::make_shared<std::atomic<WorkerWrapper*>>(this)) {}
61+
62+
WorkerWrapper::~WorkerWrapper() { this->selfRef_->store(nullptr, std::memory_order_release); }
6163

6264
const WrapperType WorkerWrapper::Type() { return WrapperType::Worker; }
6365

@@ -94,6 +96,44 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function<void()> fn, bool a
9496
[workers_ addOperation:op];
9597
}
9698

99+
void WorkerWrapper::RootWorkerObject() {
100+
if (this->workerObjectRooted_ || this->poWorker_ == nullptr || this->poWorker_->IsEmpty() ||
101+
!this->poWorker_->IsWeak()) {
102+
return;
103+
}
104+
this->weakCallbackState_ = this->poWorker_->ClearWeak<ObjectWeakCallbackState>();
105+
this->workerObjectRooted_ = true;
106+
}
107+
108+
void WorkerWrapper::UnrootWorkerObject() {
109+
if (!this->workerObjectRooted_) {
110+
return;
111+
}
112+
this->workerObjectRooted_ = false;
113+
ObjectWeakCallbackState* state = this->weakCallbackState_;
114+
this->weakCallbackState_ = nullptr;
115+
if (state == nullptr || this->poWorker_ == nullptr || this->poWorker_->IsEmpty()) {
116+
return;
117+
}
118+
this->poWorker_->SetWeak(state, ObjectManager::FinalizerCallback,
119+
v8::WeakCallbackType::kFinalizer);
120+
}
121+
122+
void WorkerWrapper::EndWrapperLifetime() {
123+
Local<Value> worker =
124+
this->poWorker_ != nullptr ? this->poWorker_->Get(this->mainIsolate_) : Local<Value>();
125+
if (!worker.IsEmpty() && worker->IsObject()) {
126+
TryCatch tc(this->mainIsolate_);
127+
Worker::EmitEnded(this->mainIsolate_, worker.As<Object>());
128+
if (tc.HasCaught()) {
129+
Local<Value> error = tc.Exception();
130+
Log(@"%s", tns::ToString(this->mainIsolate_, error).c_str());
131+
this->mainIsolate_->ThrowException(error);
132+
}
133+
}
134+
this->UnrootWorkerObject();
135+
}
136+
97137
void WorkerWrapper::DrainPendingTasks() {
98138
// The drain source is armed (and can be signaled by a main-thread
99139
// PostMessage) BEFORE `workerIsolate_` is assigned in BackgroundLooper, and
@@ -154,6 +194,33 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function<void()> fn, bool a
154194
}
155195
}
156196

197+
// Hands the parent isolate the end-of-worker notification: the `nsworkerended`
198+
// dispatch and the unroot that makes the Worker object collectable again.
199+
// Takes only primitives plus the liveness token, because the wrapper it acts on
200+
// may already be gone by the time the parent's loop gets here -- and, when the
201+
// parent is shutting down, the post is dropped and the parent's teardown
202+
// cascade owns disposal instead.
203+
static void PostThreadEndedNotification(Isolate* mainIsolate, std::weak_ptr<EventLoop> mainLoop,
204+
std::shared_ptr<std::atomic<WorkerWrapper*>> selfRef) {
205+
std::shared_ptr<EventLoop> loop = mainLoop.lock();
206+
if (loop == nullptr) {
207+
return;
208+
}
209+
PostToLoop(
210+
loop,
211+
[mainIsolate, selfRef]() {
212+
v8::Locker locker(mainIsolate);
213+
Isolate::Scope isolate_scope(mainIsolate);
214+
HandleScope handle_scope(mainIsolate);
215+
WorkerWrapper* self = selfRef->load(std::memory_order_acquire);
216+
if (self == nullptr) {
217+
return;
218+
}
219+
self->EndWrapperLifetime();
220+
},
221+
true);
222+
}
223+
157224
void WorkerWrapper::BackgroundLooper(std::function<Isolate*()> func) {
158225
if (!this->isTerminating_) {
159226
CFRunLoopRef runLoop = CFRunLoopGetCurrent();
@@ -188,20 +255,30 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function<void()> fn, bool a
188255
this->heapLimitIsolate_ = nullptr;
189256
}
190257

258+
// Everything needed below is read first: publishing isDisposed_ is the last
259+
// permitted touch of `this`. From that store on, a parent that is tearing
260+
// down may delete this wrapper concurrently, and ~Runtime deletes it on this
261+
// thread when the parent already handed ownership over.
262+
Isolate* mainIsolate = this->mainIsolate_;
263+
std::weak_ptr<EventLoop> mainLoop = this->mainLoop_;
264+
std::shared_ptr<std::atomic<WorkerWrapper*>> selfRef = this->selfRef_;
265+
int workerId = this->workerId_;
191266
this->isDisposed_ = true;
267+
192268
Runtime* runtime = Runtime::GetCurrentRuntime();
193269
if (runtime != nullptr) {
194270
delete runtime;
195271
} else {
196272
// Runtime was never created (worker terminated before initialization).
197273
// The runtime destructor normally handles this cleanup, so do it here.
198-
int workerId = this->workerId_;
199274
bool found;
200275
auto state = Caches::Workers->Get(workerId, found);
201276
if (found) {
202277
Caches::Workers->Remove(workerId);
203278
}
204279
}
280+
281+
PostThreadEndedNotification(mainIsolate, mainLoop, selfRef);
205282
}
206283

207284
void WorkerWrapper::EnableMessageQueue() {
@@ -480,8 +557,8 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function<void()> fn, bool a
480557
void WorkerWrapper::ForwardErrorPayloadToMain(const std::string& message, const std::string& source,
481558
const std::string& stackTrace, int lineNumber,
482559
bool async) {
483-
auto runtime = static_cast<Runtime*>(mainIsolate_->GetData(Constants::RUNTIME_SLOT));
484-
if (runtime == nullptr) {
560+
std::shared_ptr<EventLoop> loop = mainLoop_.lock();
561+
if (loop == nullptr) {
485562
return;
486563
}
487564
// The task runs later, on the parent's loop, and this wrapper may be gone by
@@ -492,8 +569,8 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function<void()> fn, bool a
492569
// the Worker object is gone and there is nothing left to report to.
493570
Isolate* mainIsolate = mainIsolate_;
494571
std::shared_ptr<Persistent<Value>> poWorker = poWorker_;
495-
PostToRuntimeLoop(
496-
runtime,
572+
PostToLoop(
573+
loop,
497574
[mainIsolate, poWorker, message, source, stackTrace, lineNumber]() {
498575
v8::Locker locker(mainIsolate);
499576
Isolate::Scope isolate_scope(mainIsolate);

‎NativeScript/runtime/js/README.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,12 @@ The two extra rules a lazy builtin lives by:
117117
are whatever user code left behind, so it should not reach for them at all.
118118
- The per-instance wrappers `defineEventHandler` creates live on the target's
119119
**own listener bag**, under a private symbol — never in a WeakMap keyed by
120-
the target. An ObjectManager-registered object (a `Worker`) can be
121-
resurrected by its finalizer while its thread is alive, and a resurrected
122-
object's weak-collection entries are already gone, so a WeakMap would hand
123-
the revived object a fresh, empty handler map.
120+
the target. Own-instance state is Node's own design for handler attributes,
121+
and it keeps the builtins independent of the patched collector's handling of
122+
resurrected ephemeron keys (`kFinalizer` resurrection interacting with
123+
WeakMaps has been a source of collector bugs, and the patch is re-ported on
124+
every V8 upgrade — builtins not leaning on it means a re-port mistake breaks
125+
app-level tests, not the event system itself).
124126
- No `import`/`export` — these are classic function bodies, not modules.
125127
- ESLint (`eslint.config.mjs` at the repo root, run by lint-staged) declares
126128
`exports`, `require`, `module`, `binding`, `primordials` and the reachable

‎NativeScript/runtime/js/events.js‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,12 @@ function setListenerErrorReporter(fn) {
4444

4545
// Event name -> handler-attribute wrapper (see defineEventHandler), stored on
4646
// the target's own listener bag under a symbol so it cannot collide with an
47-
// event type. Deliberately NOT a WeakMap keyed by the target: a Worker is an
48-
// ObjectManager-registered object whose finalizer resurrects it while its
49-
// thread is alive, and a resurrected object's weak-collection entries are
50-
// already gone. Each wrapper carries a `delta` that the listener count is
51-
// corrected by: the wrapper occupies one slot in the listener list from its
52-
// first assignment onwards, but a cleared handler is not a listener.
47+
// event type. Deliberately NOT a WeakMap keyed by the target: the wrappers
48+
// live with the target, as Node keeps them, and stay independent of how the
49+
// collector treats weak-collection entries of objects that native code keeps
50+
// alive. Each wrapper carries a `delta` that the listener count is corrected
51+
// by: the wrapper occupies one slot in the listener list from its first
52+
// assignment onwards, but a cleared handler is not a listener.
5353
var kHandlers = Symbol("handlers");
5454

5555
function handlersOf(target) {

0 commit comments

Comments
 (0)