Skip to content

Commit 3294283

Browse files
authored
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 c036f33 commit 3294283

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
@@ -558,12 +558,20 @@ class WorkerWrapper : public BaseDataWrapper {
558558
void CreateInspector(v8::Isolate* isolate, const std::string& scriptPath);
559559
void DestroyInspector();
560560

561+
// `func` runs on the worker thread: it creates the worker's runtime, hands
562+
// the isolate to PublishIsolate once the runtime is initialized, and runs
563+
// the entry script.
561564
void Start(std::shared_ptr<v8::Persistent<v8::Value>> poWorker,
562-
std::function<v8::Isolate*()> func,
565+
std::function<void()> func,
563566
std::optional<int> qualityOfService = std::nullopt);
567+
// Worker thread, once the runtime is initialized and before the entry script
568+
// runs. From here Terminate() reaches V8: it interrupts whatever JS the
569+
// worker runs, the entry script included, and flags the module pumps so a
570+
// parked graph stops waiting. A terminate() that landed earlier is honored by
571+
// the caller checking IsTerminating() right after this, before any app code.
572+
void PublishIsolate(v8::Isolate* isolate);
564573
// Both reporters take the isolate from their caller, which is running on
565-
// it: they are reachable while the entry script is still evaluating, before
566-
// workerIsolate_ is published.
574+
// it: they are reachable while the entry script is still evaluating.
567575
void CallOnErrorHandlers(v8::Isolate* isolate, v8::TryCatch& tc);
568576
// Reports a rejected entry-evaluation promise. A rejection carries a reason
569577
// rather than a TryCatch, so it cannot go through CallOnErrorHandlers, but it
@@ -607,11 +615,6 @@ class WorkerWrapper : public BaseDataWrapper {
607615
// used to name the cap in the message forwarded to the parent.
608616
void WatchHeapLimit(v8::Isolate* isolate, const std::string& scriptPath,
609617
std::optional<size_t> maxOldGenerationSizeBytes);
610-
// Whether the heap cap was hit. The worker startup path checks this to stop
611-
// before running anything else in an isolate V8 is terminating.
612-
inline bool HeapLimitExceeded() const {
613-
return heapLimitExceeded_.load(std::memory_order_acquire);
614-
}
615618
// The JS Worker object is a GC root from a successful start until the worker
616619
// ends, so a running worker is reachable the way a browser's is rather than
617620
// depending on its finalizer to keep it. Both of these run on the main
@@ -630,6 +633,9 @@ class WorkerWrapper : public BaseDataWrapper {
630633
const int Id();
631634
const bool IsRunning();
632635
const bool IsClosing();
636+
// Set by Terminate() from any thread, by the near-heap-limit callback, and by
637+
// the worker thread itself once a close() takes effect. One-way.
638+
const bool IsTerminating();
633639
const int WorkerId();
634640
const inline v8::Isolate* GetMainIsolate() { return mainIsolate_; }
635641
// The only route from the worker thread to the parent: see mainLoop_.
@@ -658,9 +664,13 @@ class WorkerWrapper : public BaseDataWrapper {
658664
enum class Holders : uint8_t { Parent, Both, WorkerThread };
659665

660666
v8::Isolate* mainIsolate_;
661-
// Written by the worker thread only: published once the worker's startup
662-
// function returns, withdrawn before the worker's runtime is deleted. Any
663-
// other thread reads and uses it under workerIsolateMutex_.
667+
// Written by the worker thread only: published by PublishIsolate once the
668+
// worker's runtime is initialized and before its entry script runs,
669+
// withdrawn before the runtime is deleted. Any other thread reads and uses
670+
// it under workerIsolateMutex_. Null while the runtime is being set up, so a
671+
// Terminate() in that window cannot interrupt the builtins Runtime::Init
672+
// evaluates; the startup function checks the terminating flag right after
673+
// publishing instead.
664674
v8::Isolate* workerIsolate_;
665675
std::mutex workerIsolateMutex_;
666676
std::atomic<bool> isRunning_;
@@ -690,10 +700,11 @@ class WorkerWrapper : public BaseDataWrapper {
690700
// thread) and DestroyInspector() (worker thread) agree on liveness.
691701
v8_inspector::WorkerInspectorClient* inspector_ = nullptr;
692702
std::mutex inspectorMutex_;
693-
// The worker isolate as seen from the heap-limit callback. Separate from
694-
// workerIsolate_, which BackgroundLooper only publishes once the entry script
695-
// has finished evaluating — the point at which a heap cap is most likely to
696-
// be hit is inside that entry.
703+
// The isolate the near-heap-limit callback is armed on. Worker thread only.
704+
// Kept apart from workerIsolate_ because the callback is armed before the
705+
// isolate is published and can fire during Runtime::Init, and because
706+
// removing a callback V8 never had registered is fatal: non-null here means
707+
// exactly "armed, remove at teardown".
697708
v8::Isolate* heapLimitIsolate_ = nullptr;
698709
std::string heapLimitMessage_;
699710
std::string heapLimitSource_;
@@ -715,7 +726,7 @@ class WorkerWrapper : public BaseDataWrapper {
715726
// task would run on.
716727
std::shared_ptr<std::atomic<WorkerWrapper*>> selfRef_;
717728

718-
void BackgroundLooper(std::function<v8::Isolate*()> func);
729+
void BackgroundLooper(std::function<void()> func);
719730
void DrainPendingTasks();
720731
void ForwardErrorPayloadToMain(const std::string& message,
721732
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)