Skip to content

Commit 6d8e58b

Browse files
fix(worker): isolate and wrapper lifetime fixes around startup and teardown
A throwing onclose run from the entry script read the worker isolate before it was published and crashed. Terminate() used the worker isolate with no synchronization against the worker thread deleting its runtime. EndWrapperLifetime kept using the wrapper after dispatching nsworkerended, whose listeners can shut the runtime down and delete it.
1 parent 6221ca8 commit 6d8e58b

5 files changed

Lines changed: 64 additions & 16 deletions

File tree

‎NativeScript/runtime/DataWrapper.h‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -553,12 +553,16 @@ class WorkerWrapper : public BaseDataWrapper {
553553
void Start(std::shared_ptr<v8::Persistent<v8::Value>> poWorker,
554554
std::function<v8::Isolate*()> func,
555555
std::optional<int> qualityOfService = std::nullopt);
556-
void CallOnErrorHandlers(v8::TryCatch& tc);
556+
// Both reporters take the isolate from their caller, which is running on
557+
// it: they are reachable while the entry script is still evaluating, before
558+
// workerIsolate_ is published.
559+
void CallOnErrorHandlers(v8::Isolate* isolate, v8::TryCatch& tc);
557560
// Reports a rejected entry-evaluation promise. A rejection carries a reason
558561
// rather than a TryCatch, so it cannot go through CallOnErrorHandlers, but it
559562
// follows the same web order: the worker scope's `onerror` first, then — only
560563
// if that did not handle it — the parent's Worker error event.
561-
void ReportEntryEvaluationRejection(v8::Local<v8::Context> context,
564+
void ReportEntryEvaluationRejection(v8::Isolate* isolate,
565+
v8::Local<v8::Context> context,
562566
v8::Local<v8::Value> reason);
563567
void PassUncaughtExceptionFromWorkerToMain(v8::Local<v8::Context> context,
564568
v8::TryCatch& tc,
@@ -629,7 +633,11 @@ class WorkerWrapper : public BaseDataWrapper {
629633

630634
private:
631635
v8::Isolate* mainIsolate_;
636+
// Written by the worker thread only: published once the worker's startup
637+
// function returns, withdrawn before the worker's runtime is deleted. Any
638+
// other thread reads and uses it under workerIsolateMutex_.
632639
v8::Isolate* workerIsolate_;
640+
std::mutex workerIsolateMutex_;
633641
std::atomic<bool> isRunning_;
634642
std::atomic<bool> isClosing_;
635643
std::atomic<bool> isTerminating_;

‎NativeScript/runtime/Worker.mm‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,7 @@ throw NativeScriptException(
564564
? info[0]
565565
: Local<Value>(v8::Exception::Error(tns::ToV8String(
566566
iso, "Worker entry module evaluation rejected")));
567-
w->ReportEntryEvaluationRejection(ctx, reason);
567+
w->ReportEntryEvaluationRejection(iso, ctx, reason);
568568
};
569569
Local<v8::Function> onFulfilled;
570570
Local<v8::Function> onRejected;
@@ -798,7 +798,7 @@ throw NativeScriptException(
798798
TryCatch tc(isolate);
799799
success = onCloseFunc->Call(context, v8::Undefined(isolate), 0, args).ToLocal(&result);
800800
if (!success && tc.HasCaught()) {
801-
worker->CallOnErrorHandlers(tc);
801+
worker->CallOnErrorHandlers(isolate, tc);
802802
}
803803
}
804804
}

‎NativeScript/runtime/WorkerWrapper.mm‎

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -120,17 +120,26 @@ static void PostToLoop(const std::shared_ptr<EventLoop>& loop, std::function<voi
120120
}
121121

122122
void WorkerWrapper::EndWrapperLifetime() {
123-
Local<Value> worker =
124-
this->poWorker_ != nullptr ? this->poWorker_->Get(this->mainIsolate_) : Local<Value>();
123+
// The dispatch below runs listeners, and a listener may shut the runtime
124+
// down, whose teardown deletes this wrapper. Everything the dispatch needs is
125+
// read first, and the liveness token says afterwards whether `this` is still
126+
// there to unroot.
127+
Isolate* isolate = this->mainIsolate_;
128+
std::shared_ptr<std::atomic<WorkerWrapper*>> selfRef = this->selfRef_;
129+
Local<Value> worker = this->poWorker_ != nullptr ? this->poWorker_->Get(isolate) : Local<Value>();
125130
if (!worker.IsEmpty() && worker->IsObject()) {
126-
TryCatch tc(this->mainIsolate_);
127-
Worker::EmitEnded(this->mainIsolate_, worker.As<Object>());
131+
TryCatch tc(isolate);
132+
Worker::EmitEnded(isolate, worker.As<Object>());
128133
if (tc.HasCaught()) {
129134
Local<Value> error = tc.Exception();
130-
Log(@"%s", tns::ToString(this->mainIsolate_, error).c_str());
131-
this->mainIsolate_->ThrowException(error);
135+
Log(@"%s", tns::ToString(isolate, error).c_str());
136+
isolate->ThrowException(error);
132137
}
133138
}
139+
if (selfRef->load(std::memory_order_acquire) == nullptr) {
140+
// Deleted during the dispatch; that teardown released the Worker object.
141+
return;
142+
}
134143
this->UnrootWorkerObject();
135144
}
136145

@@ -181,7 +190,7 @@ static void PostToLoop(const std::shared_ptr<EventLoop>& loop, std::function<voi
181190
this->onMessage_(this->workerIsolate_, globalTarget, message);
182191

183192
if (tc.HasCaught()) {
184-
this->CallOnErrorHandlers(tc);
193+
this->CallOnErrorHandlers(this->workerIsolate_, tc);
185194
}
186195
}
187196

@@ -232,7 +241,11 @@ static void PostThreadEndedNotification(Isolate* mainIsolate, std::weak_ptr<Even
232241
},
233242
this);
234243

235-
this->workerIsolate_ = func();
244+
Isolate* workerIsolate = func();
245+
{
246+
std::lock_guard<std::mutex> lock(this->workerIsolateMutex_);
247+
this->workerIsolate_ = workerIsolate;
248+
}
236249

237250
this->DrainPendingTasks();
238251

@@ -242,6 +255,14 @@ static void PostThreadEndedNotification(Isolate* mainIsolate, std::weak_ptr<Even
242255
}
243256
}
244257

258+
// Withdrawn before the runtime and its isolate go away below. Terminate()
259+
// uses the isolate under this mutex, so a terminate that already read it has
260+
// finished with it by the time this returns, and a later one finds null.
261+
{
262+
std::lock_guard<std::mutex> lock(this->workerIsolateMutex_);
263+
this->workerIsolate_ = nullptr;
264+
}
265+
245266
// The inspector must be gone before the Runtime (and with it the isolate)
246267
// is deleted below.
247268
this->DestroyInspector();
@@ -292,6 +313,9 @@ static void PostThreadEndedNotification(Isolate* mainIsolate, std::weak_ptr<Even
292313
// set terminating to true atomically
293314
bool wasTerminating = this->isTerminating_.exchange(true);
294315
if (!wasTerminating) {
316+
// Held across the use, not just the read: the worker thread withdraws the
317+
// isolate under the same mutex before deleting its runtime.
318+
std::unique_lock<std::mutex> isolateLock(this->workerIsolateMutex_);
295319
if (this->workerIsolate_ != nullptr) {
296320
// Flagged before the request so a pump that is between iterations sees
297321
// it on its next check, rather than only once V8 has some JS to
@@ -307,6 +331,7 @@ static void PostThreadEndedNotification(Isolate* mainIsolate, std::weak_ptr<Even
307331
}
308332
this->workerIsolate_->TerminateExecution();
309333
}
334+
isolateLock.unlock();
310335
{
311336
// A worker paused at a breakpoint sits in the inspector's nested pause
312337
// loop, not in the CFRunLoop — kick it loose so TerminateExecution and
@@ -414,11 +439,10 @@ static void PostThreadEndedNotification(Isolate* mainIsolate, std::weak_ptr<Even
414439
delete client;
415440
}
416441

417-
void WorkerWrapper::CallOnErrorHandlers(TryCatch& tc) {
442+
void WorkerWrapper::CallOnErrorHandlers(Isolate* isolate, TryCatch& tc) {
418443
if (this->isTerminating_) {
419444
return;
420445
}
421-
Isolate* isolate = this->workerIsolate_;
422446
Local<Context> context = Caches::Get(isolate)->GetContext();
423447
Local<Object> global = context->Global();
424448

@@ -447,11 +471,11 @@ static void PostThreadEndedNotification(Isolate* mainIsolate, std::weak_ptr<Even
447471
this->PassUncaughtExceptionFromWorkerToMain(context, tc);
448472
}
449473

450-
void WorkerWrapper::ReportEntryEvaluationRejection(Local<Context> context, Local<Value> reason) {
474+
void WorkerWrapper::ReportEntryEvaluationRejection(Isolate* isolate, Local<Context> context,
475+
Local<Value> reason) {
451476
if (this->isTerminating_) {
452477
return;
453478
}
454-
Isolate* isolate = this->workerIsolate_;
455479
Local<Object> global = context->Global();
456480

457481
Local<Value> onErrorVal;

‎TestRunner/app/tests/MessagingTests.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,16 @@ describe("Messaging runtime edges", function () {
252252
}
253253
};
254254
});
255+
256+
it("reports an error onclose threw while the entry script was still running", function (done) {
257+
var worker = new Worker("./messaging/throwingOncloseWorker.js");
258+
worker.onerror = function (event) {
259+
event.preventDefault();
260+
expect(event.message).toContain("boom from onclose");
261+
worker.terminate();
262+
done();
263+
};
264+
});
255265
});
256266

257267
describe("AbortSignal handler attribute accounting", function () {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Closes from inside the entry script, so onclose runs before the entry has
2+
// finished evaluating.
3+
onclose = function () {
4+
throw new Error("boom from onclose");
5+
};
6+
close();

0 commit comments

Comments
 (0)