Skip to content

Commit 1f141da

Browse files
committed
fix(worker): terminate() reaches a worker still inside its entry script
The worker's isolate was published to its wrapper only after the startup function returned, i.e. after the entry script had finished evaluating, so Terminate() found nothing to interrupt for a worker still in its entry: a synchronous loop there ran forever, and a parked top-level await ended only when the loader's settle deadline expired. Node and the web both interrupt the running entry. The isolate is now published right after the worker runtime is initialized, before the entry runs, and the startup function honors a terminate() that landed earlier by checking the flag before any app code. A termination that lands inside the entry unwinds RunModule, which no longer rethrows or reports on a terminating isolate, and the thread goes straight to its existing teardown path. The module runner and the exception constructor name a caught termination for what it is instead of formatting the TryCatch, and the settle pump checks for one after its loop too, so a termination is never reported as a timeout or as an entry rejection. Also documents the real resourceLimits and ios.priority Worker options in docs/worker-threads.md, whose node:worker_threads table claimed the runtime imposed no limits. Suite 1735/0, also under AddressSanitizer. Fixes #445
1 parent 1c1ce8d commit 1f141da

10 files changed

Lines changed: 328 additions & 74 deletions

File tree

‎NativeScript/runtime/DataWrapper.h‎

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -550,12 +550,20 @@ class WorkerWrapper : public BaseDataWrapper {
550550
void CreateInspector(v8::Isolate* isolate, const std::string& scriptPath);
551551
void DestroyInspector();
552552

553+
// `func` runs on the worker thread: it creates the worker's runtime, hands
554+
// the isolate to PublishIsolate once the runtime is initialized, and runs
555+
// the entry script.
553556
void Start(std::shared_ptr<v8::Persistent<v8::Value>> poWorker,
554-
std::function<v8::Isolate*()> func,
557+
std::function<void()> func,
555558
std::optional<int> qualityOfService = std::nullopt);
559+
// Worker thread, once the runtime is initialized and before the entry script
560+
// runs. From here Terminate() reaches V8: it interrupts whatever JS the
561+
// worker runs, the entry script included, and flags the module pumps so a
562+
// parked graph stops waiting. A terminate() that landed earlier is honored by
563+
// the caller checking IsTerminating() right after this, before any app code.
564+
void PublishIsolate(v8::Isolate* isolate);
556565
// 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.
566+
// it: they are reachable while the entry script is still evaluating.
559567
void CallOnErrorHandlers(v8::Isolate* isolate, v8::TryCatch& tc);
560568
// Reports a rejected entry-evaluation promise. A rejection carries a reason
561569
// rather than a TryCatch, so it cannot go through CallOnErrorHandlers, but it
@@ -599,11 +607,6 @@ class WorkerWrapper : public BaseDataWrapper {
599607
// used to name the cap in the message forwarded to the parent.
600608
void WatchHeapLimit(v8::Isolate* isolate, const std::string& scriptPath,
601609
std::optional<size_t> maxOldGenerationSizeBytes);
602-
// Whether the heap cap was hit. The worker startup path checks this to stop
603-
// before running anything else in an isolate V8 is terminating.
604-
inline bool HeapLimitExceeded() const {
605-
return heapLimitExceeded_.load(std::memory_order_acquire);
606-
}
607610
// The JS Worker object is a GC root from a successful start until the worker
608611
// ends, so a running worker is reachable the way a browser's is rather than
609612
// depending on its finalizer to keep it. Both of these run on the main
@@ -622,6 +625,9 @@ class WorkerWrapper : public BaseDataWrapper {
622625
const int Id();
623626
const bool IsRunning();
624627
const bool IsClosing();
628+
// Set by Terminate() from any thread, by the near-heap-limit callback, and by
629+
// the worker thread itself once a close() takes effect. One-way.
630+
const bool IsTerminating();
625631
const int WorkerId();
626632
const inline v8::Isolate* GetMainIsolate() { return mainIsolate_; }
627633
// The only route from the worker thread to the parent: see mainLoop_.
@@ -650,9 +656,13 @@ class WorkerWrapper : public BaseDataWrapper {
650656
enum class Holders : uint8_t { Parent, Both, WorkerThread };
651657

652658
v8::Isolate* mainIsolate_;
653-
// Written by the worker thread only: published once the worker's startup
654-
// function returns, withdrawn before the worker's runtime is deleted. Any
655-
// other thread reads and uses it under workerIsolateMutex_.
659+
// Written by the worker thread only: published by PublishIsolate once the
660+
// worker's runtime is initialized and before its entry script runs,
661+
// withdrawn before the runtime is deleted. Any other thread reads and uses
662+
// it under workerIsolateMutex_. Null while the runtime is being set up, so a
663+
// Terminate() in that window cannot interrupt the builtins Runtime::Init
664+
// evaluates; the startup function checks the terminating flag right after
665+
// publishing instead.
656666
v8::Isolate* workerIsolate_;
657667
std::mutex workerIsolateMutex_;
658668
std::atomic<bool> isRunning_;
@@ -682,10 +692,11 @@ class WorkerWrapper : public BaseDataWrapper {
682692
// thread) and DestroyInspector() (worker thread) agree on liveness.
683693
v8_inspector::WorkerInspectorClient* inspector_ = nullptr;
684694
std::mutex inspectorMutex_;
685-
// The worker isolate as seen from the heap-limit callback. Separate from
686-
// workerIsolate_, which BackgroundLooper only publishes once the entry script
687-
// has finished evaluating — the point at which a heap cap is most likely to
688-
// be hit is inside that entry.
695+
// The isolate the near-heap-limit callback is armed on. Worker thread only.
696+
// Kept apart from workerIsolate_ because the callback is armed before the
697+
// isolate is published and can fire during Runtime::Init, and because
698+
// removing a callback V8 never had registered is fatal: non-null here means
699+
// exactly "armed, remove at teardown".
689700
v8::Isolate* heapLimitIsolate_ = nullptr;
690701
std::string heapLimitMessage_;
691702
std::string heapLimitSource_;
@@ -707,7 +718,7 @@ class WorkerWrapper : public BaseDataWrapper {
707718
// task would run on.
708719
std::shared_ptr<std::atomic<WorkerWrapper*>> selfRef_;
709720

710-
void BackgroundLooper(std::function<v8::Isolate*()> func);
721+
void BackgroundLooper(std::function<void()> func);
711722
void DrainPendingTasks();
712723
void ForwardErrorPayloadToMain(const std::string& message,
713724
const std::string& source,

‎NativeScript/runtime/ModuleInternal.mm‎

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,11 @@ throw NativeScriptException(
293293
success = requireFunc->Call(context, globalObject, 1, args).ToLocal(&result);
294294

295295
if (!success || tc.HasCaught()) {
296+
// A termination is caught like an exception but carries no error value;
297+
// naming it as the failure keeps it from being reported as one.
298+
if (tc.HasTerminated()) {
299+
throw NativeScriptException("Module evaluation interrupted by isolate termination: " + path);
300+
}
296301
if (RuntimeConfig.IsDebug) {
297302
Log(@"***** JavaScript exception occurred *****");
298303
Log(@"Error in require() call:");
@@ -882,6 +887,10 @@ throw NativeScriptException(isolate,
882887
moduleFunc->Call(context, thiz, sizeof(requireArgs) / sizeof(Local<Value>), requireArgs)
883888
.ToLocal(&result);
884889
if (!success || tc.HasCaught()) {
890+
if (tc.HasTerminated()) {
891+
throw NativeScriptException("Module evaluation interrupted by isolate termination: " +
892+
modulePath);
893+
}
885894
throw NativeScriptException(isolate, tc, "Error calling module function");
886895
}
887896
}
@@ -1277,6 +1286,13 @@ throw NativeScriptException(
12771286
Local<Value> result;
12781287
if (!module->Evaluate(context).ToLocal(&result)) {
12791288
RemoveModuleFromRegistry(isolate, canonicalPath);
1289+
// Same rule as the pump below: a termination outranks any failure detail,
1290+
// and reading the TryCatch as an error would run JS on the dying isolate.
1291+
if (tcEval.HasTerminated() || isolate->IsExecutionTerminating()) {
1292+
LogEsmPhase(canonicalPath, "evaluate", "terminated");
1293+
throw NativeScriptException("Module evaluation interrupted by isolate termination: " +
1294+
canonicalPath);
1295+
}
12801296
const char* classification = "unknown";
12811297
if (tcEval.HasCaught()) {
12821298
Local<Message> msg = tcEval.Message();
@@ -1364,32 +1380,38 @@ throw NativeScriptException("ES module " + canonicalPath +
13641380
NSDate* deadline = [NSDate dateWithTimeIntervalSinceNow:options.deadlineSeconds];
13651381
bool settled = false;
13661382

1383+
// Termination outranks a settled result and a timeout alike. Handing back a
1384+
// namespace would send the caller on to run more JS — enabling a queue,
1385+
// draining messages — on an isolate V8 has already been told to stop, and
1386+
// reporting a timeout would name a reason that is not the real one.
1387+
//
1388+
// Three signals are consulted: V8 only reports a termination it has already
1389+
// materialized, which needs JS to run, and a graph parked on a promise
1390+
// nothing settles never gives it any; one that materialized inside a
1391+
// checkpoint lands in promiseTc. Message-only exception: building a V8
1392+
// error on a terminating isolate is not allowed.
1393+
auto throwIfTerminating = [&]() {
1394+
if (!promiseTc.HasTerminated() && !isolate->IsExecutionTerminating() &&
1395+
(runtime == nullptr || !runtime->IsTerminationRequested())) {
1396+
return;
1397+
}
1398+
LogEsmPhase(canonicalPath, "evaluate", "terminated");
1399+
// Probed, not consumed: only a still-pending promise leaves a
1400+
// half-evaluated module in the registry. One that already settled is
1401+
// complete, and evicting it would throw away a good entry for no reason
1402+
// — the result simply goes unused.
1403+
if (promise->State() == Promise::kPending) {
1404+
RemoveModuleFromRegistry(isolate, canonicalPath);
1405+
}
1406+
throw NativeScriptException("Module evaluation interrupted by isolate termination: " +
1407+
canonicalPath);
1408+
};
1409+
13671410
// State is checked before the first pump: a synchronous graph's
13681411
// evaluation promise is already settled when Evaluate() returns, so it
13691412
// exits here without paying for a runloop slice.
13701413
while (!promiseTc.HasCaught()) {
1371-
// Termination outranks a settled result. Handing back a namespace here
1372-
// would send the caller on to run more JS — enabling a queue, draining
1373-
// messages — on an isolate V8 has already been told to stop, so a
1374-
// termination seen at the loop head always throws, settled or not.
1375-
//
1376-
// Both signals are consulted: V8 only reports a termination it has already
1377-
// materialized, which needs JS to run, and a graph parked on a promise
1378-
// nothing settles never gives it any. Message-only exception: building a
1379-
// V8 error on a terminating isolate is not allowed.
1380-
if (isolate->IsExecutionTerminating() ||
1381-
(runtime != nullptr && runtime->IsTerminationRequested())) {
1382-
LogEsmPhase(canonicalPath, "evaluate", "terminated");
1383-
// Probed, not consumed: only a still-pending promise leaves a
1384-
// half-evaluated module in the registry. One that already settled is
1385-
// complete, and evicting it would throw away a good entry for no reason
1386-
// — the result simply goes unused.
1387-
if (promise->State() == Promise::kPending) {
1388-
RemoveModuleFromRegistry(isolate, canonicalPath);
1389-
}
1390-
throw NativeScriptException("Module evaluation interrupted by isolate termination: " +
1391-
canonicalPath);
1392-
}
1414+
throwIfTerminating();
13931415

13941416
Promise::PromiseState state = promise->State();
13951417
if (state != Promise::kPending) {
@@ -1411,6 +1433,11 @@ throw NativeScriptException("Module evaluation interrupted by isolate terminatio
14111433
}
14121434
}
14131435

1436+
// The loop leaves through its condition when a pump materializes the
1437+
// termination, and through the deadline when the request arrived during the
1438+
// final slice.
1439+
throwIfTerminating();
1440+
14141441
if (!settled && promise->State() == Promise::kPending) {
14151442
LogEsmPhase(canonicalPath, "evaluate", "promise-timeout");
14161443
if (options.timeoutBehavior == ModuleEvaluationOptions::TimeoutBehavior::kThrow) {

‎NativeScript/runtime/NativeScriptException.mm‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,15 @@ static void ConsiderStackCandidate(PendingErrorDisplay& state, v8::Isolate* isol
8585

8686
NativeScriptException::NativeScriptException(Isolate* isolate, TryCatch& tc,
8787
const std::string& message) {
88+
// A caught termination has no exception value, message or stack to read:
89+
// V8 hands back a sentinel, and formatting it would run JS on an isolate
90+
// that must not run any.
91+
if (tc.HasTerminated()) {
92+
this->javascriptException_ = nullptr;
93+
this->message_ = message;
94+
this->name_ = "NativeScriptException";
95+
return;
96+
}
8897
Local<Value> error = tc.Exception();
8998
this->javascriptException_ = new Persistent<Value>(isolate, tc.Exception());
9099
this->message_ = GetErrorMessage(isolate, error, message);
@@ -333,7 +342,7 @@ static void ScheduleDeferredThrow(Isolate* isolate, NSException* e) {
333342
if (error->IsObject()) {
334343
auto errObject = error.As<Object>();
335344
auto fullMessageString = tns::ToV8String(isolate, "fullMessage");
336-
if (errObject->HasOwnProperty(context, fullMessageString).ToChecked()) {
345+
if (errObject->HasOwnProperty(context, fullMessageString).FromMaybe(false)) {
337346
// check if we have a "fullMessage" on the error, and log that instead - since it includes
338347
// more info about the exception.
339348
v8::Local<v8::Value> fullMessage_;
@@ -765,7 +774,7 @@ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local<Context> context, L
765774
std::string errMessage;
766775
bool hasFullErrorMessage = false;
767776
auto v8FullMessage = tns::ToV8String(isolate, "fullMessage");
768-
if (error->IsObject() && error.As<Object>()->Has(context, v8FullMessage).ToChecked()) {
777+
if (error->IsObject() && error.As<Object>()->Has(context, v8FullMessage).FromMaybe(false)) {
769778
hasFullErrorMessage = true;
770779
Local<Value> errMsgVal;
771780
bool success = error.As<Object>()->Get(context, v8FullMessage).ToLocal(&errMsgVal);

‎NativeScript/runtime/Worker.mm‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ throw NativeScriptException(
433433
// vocabulary updates).
434434
tns::LoaderVocabulary inheritedVocabulary = tns::CaptureLoaderVocabulary(isolate);
435435

436-
std::function<Isolate*()> func([worker, workerPath, inheritedVocabulary, resourceLimits]() {
436+
std::function<void()> func([worker, workerPath, inheritedVocabulary, resourceLimits]() {
437437
// Name the looper thread after its entry script so a crash report
438438
// identifies which worker died instead of an anonymous NSOperationQueue
439439
// thread. Darwin caps thread names at 63 bytes; keep the basename only.
@@ -477,6 +477,14 @@ throw NativeScriptException(
477477
// the worker's scripts are visible to the debugger from the start.
478478
worker->CreateInspector(isolate, resolvedPath);
479479

480+
// From here terminate() interrupts this isolate. A terminate() that
481+
// landed before now had nothing to interrupt, so it is honored here,
482+
// before any app code runs: the thread goes straight to teardown.
483+
worker->PublishIsolate(isolate);
484+
if (worker->IsTerminating()) {
485+
return;
486+
}
487+
480488
TryCatch tc(isolate);
481489

482490
// If the script can be determined missing up-front, report it through
@@ -491,7 +499,7 @@ throw NativeScriptException(
491499
worker->PassUncaughtExceptionFromWorkerToMain(
492500
"Worker script does not exist: " + resolvedPath, resolvedPath, "", 1, true);
493501
worker->Terminate();
494-
return isolate;
502+
return;
495503
}
496504
}
497505

@@ -500,16 +508,22 @@ throw NativeScriptException(
500508
} catch (NativeScriptException& ex) {
501509
// Re-arm the failure as the pending V8 exception (the original JS
502510
// error when one was captured) so the tc.HasCaught() path below
503-
// routes it to worker.onerror with full detail.
504-
Isolate::Scope isolate_scope(isolate);
505-
HandleScope handle_scope(isolate);
506-
ex.ReThrowToV8(isolate);
511+
// routes it to worker.onerror with full detail. Not on an isolate
512+
// that is terminating: the failure then is the termination itself,
513+
// and throwing on such an isolate is not allowed.
514+
if (!worker->IsTerminating()) {
515+
Isolate::Scope isolate_scope(isolate);
516+
HandleScope handle_scope(isolate);
517+
ex.ReThrowToV8(isolate);
518+
}
507519
}
508520

509-
// The near-heap-limit callback has already reported to the parent and
510-
// asked V8 to terminate this isolate; everything below would run JS on it.
511-
if (worker->HeapLimitExceeded()) {
512-
return isolate;
521+
// The entry was cut short — by terminate(), or by the near-heap-limit
522+
// callback, which has already reported to the parent and asked V8 to
523+
// terminate this isolate. Everything below would run JS on it, and a
524+
// terminated worker reports no error.
525+
if (worker->IsTerminating()) {
526+
return;
513527
}
514528

515529
// WHATWG parity: enable the implicit port's message queue once the
@@ -590,8 +604,6 @@ throw NativeScriptException(
590604
worker->PassUncaughtExceptionFromWorkerToMain(context, tc, true);
591605
worker->Terminate();
592606
}
593-
594-
return isolate;
595607
});
596608

597609
// The registry entry has to exist before the worker can run: the worker

0 commit comments

Comments
 (0)