Skip to content

Commit 872aba9

Browse files
authored
fix(worker): terminate() reaches a worker still inside its entry script (#493)
* 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 * fix(worker): keep a termination from being re-armed as an error on its way out A termination that interrupts a CommonJS entry unwinds through the native require callback, whose catch re-threw the message-only failure onto the isolate as an ordinary Error: V8 then reported an exception rather than a termination to RunModule's TryCatch, which built a full error report before the worker boundary dropped it. ReThrowToV8 now leaves a terminating isolate alone, since the pending termination is the failure, and RunModule consults the same three termination signals as the settle pump. The pumped graph walk ahead of an entry's compile bails on termination but reports nothing, so LoadESModule went on to the synchronous HTTP loader, which can block on the network. Both walks are now followed by a termination check. Also refreshes the drain comment that described the pre-publication isolate window, widens the startup-sweep spec's spread so its rounds reach the settle pump, and corrects the resourceLimits RangeError wording. * fix(worker): consult the termination-requested flag at the last two failure sites The CommonJS module-function call and the ES module Evaluate failure only recognized a termination V8 had already materialized. A request that landed during the call, before any JS ran to materialize it, let an ordinary failure be formatted as an error on a terminating isolate. Both sites now read the same three signals as the settle pump and the require path. * fix(worker): rethrow ordinary failures until a termination materializes ReThrowToV8 skipped the throw whenever the runtime's termination-requested flag was set. Between terminate() setting that flag and V8 delivering the interrupt, JS still runs, so an ordinary native failure in that window returned nothing: require() yielded undefined and a native constructor handed back an uninitialized object, which a following call could trip an assert on in Debug builds. The skip now keys on IsExecutionTerminating() alone; inside a callback a materialized termination is never cleared on the way up, so that signal is sufficient there. Also gives the module-instantiate failure in LoadESModule the same termination check as the runner's other failure sites, so a terminated link is reported as an interruption rather than logged as an instantiation error.
1 parent d02e43c commit 872aba9

10 files changed

Lines changed: 382 additions & 81 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: 85 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,10 @@ static bool IsHttpModulePath(const std::string& path) {
258258
moduleNamespace =
259259
ModuleInternal::LoadESModule(isolate, path, BootEntryEvaluationOptions(isHttpModule));
260260
} catch (const NativeScriptException& ex) {
261-
if (RuntimeConfig.IsDebug) {
261+
Runtime* runtime = Runtime::GetRuntime(isolate);
262+
bool terminating = isolate->IsExecutionTerminating() ||
263+
(runtime != nullptr && runtime->IsTerminationRequested());
264+
if (RuntimeConfig.IsDebug && !terminating) {
262265
Log(@"***** JavaScript exception occurred *****");
263266
Log(@"Error loading ES module: %s", path.c_str());
264267
Log(@"Exception: %s", ex.getMessage().c_str());
@@ -293,6 +296,15 @@ throw NativeScriptException(
293296
success = requireFunc->Call(context, globalObject, 1, args).ToLocal(&result);
294297

295298
if (!success || tc.HasCaught()) {
299+
// A termination is caught like an exception but carries no error value;
300+
// naming it as the failure keeps it from being reported as one. All three
301+
// signals, as in the settle pump: a native frame between here and the
302+
// interrupted JS may have swallowed the sentinel on its way out.
303+
Runtime* runtime = Runtime::GetRuntime(isolate);
304+
if (tc.HasTerminated() || isolate->IsExecutionTerminating() ||
305+
(runtime != nullptr && runtime->IsTerminationRequested())) {
306+
throw NativeScriptException("Module evaluation interrupted by isolate termination: " + path);
307+
}
296308
if (RuntimeConfig.IsDebug) {
297309
Log(@"***** JavaScript exception occurred *****");
298310
Log(@"Error in require() call:");
@@ -882,6 +894,12 @@ throw NativeScriptException(isolate,
882894
moduleFunc->Call(context, thiz, sizeof(requireArgs) / sizeof(Local<Value>), requireArgs)
883895
.ToLocal(&result);
884896
if (!success || tc.HasCaught()) {
897+
Runtime* runtime = Runtime::GetRuntime(isolate);
898+
if (tc.HasTerminated() || isolate->IsExecutionTerminating() ||
899+
(runtime != nullptr && runtime->IsTerminationRequested())) {
900+
throw NativeScriptException("Module evaluation interrupted by isolate termination: " +
901+
modulePath);
902+
}
885903
throw NativeScriptException(isolate, tc, "Error calling module function");
886904
}
887905
}
@@ -1277,6 +1295,15 @@ throw NativeScriptException(
12771295
Local<Value> result;
12781296
if (!module->Evaluate(context).ToLocal(&result)) {
12791297
RemoveModuleFromRegistry(isolate, canonicalPath);
1298+
// Same rule as the pump below: a termination outranks any failure detail,
1299+
// and reading the TryCatch as an error would run JS on the dying isolate.
1300+
Runtime* runtime = Runtime::GetRuntime(isolate);
1301+
if (tcEval.HasTerminated() || isolate->IsExecutionTerminating() ||
1302+
(runtime != nullptr && runtime->IsTerminationRequested())) {
1303+
LogEsmPhase(canonicalPath, "evaluate", "terminated");
1304+
throw NativeScriptException("Module evaluation interrupted by isolate termination: " +
1305+
canonicalPath);
1306+
}
12801307
const char* classification = "unknown";
12811308
if (tcEval.HasCaught()) {
12821309
Local<Message> msg = tcEval.Message();
@@ -1364,32 +1391,38 @@ throw NativeScriptException("ES module " + canonicalPath +
13641391
NSDate* deadline = [NSDate dateWithTimeIntervalSinceNow:options.deadlineSeconds];
13651392
bool settled = false;
13661393

1394+
// Termination outranks a settled result and a timeout alike. Handing back a
1395+
// namespace would send the caller on to run more JS — enabling a queue,
1396+
// draining messages — on an isolate V8 has already been told to stop, and
1397+
// reporting a timeout would name a reason that is not the real one.
1398+
//
1399+
// Three signals are consulted: V8 only reports a termination it has already
1400+
// materialized, which needs JS to run, and a graph parked on a promise
1401+
// nothing settles never gives it any; one that materialized inside a
1402+
// checkpoint lands in promiseTc. Message-only exception: building a V8
1403+
// error on a terminating isolate is not allowed.
1404+
auto throwIfTerminating = [&]() {
1405+
if (!promiseTc.HasTerminated() && !isolate->IsExecutionTerminating() &&
1406+
(runtime == nullptr || !runtime->IsTerminationRequested())) {
1407+
return;
1408+
}
1409+
LogEsmPhase(canonicalPath, "evaluate", "terminated");
1410+
// Probed, not consumed: only a still-pending promise leaves a
1411+
// half-evaluated module in the registry. One that already settled is
1412+
// complete, and evicting it would throw away a good entry for no reason
1413+
// — the result simply goes unused.
1414+
if (promise->State() == Promise::kPending) {
1415+
RemoveModuleFromRegistry(isolate, canonicalPath);
1416+
}
1417+
throw NativeScriptException("Module evaluation interrupted by isolate termination: " +
1418+
canonicalPath);
1419+
};
1420+
13671421
// State is checked before the first pump: a synchronous graph's
13681422
// evaluation promise is already settled when Evaluate() returns, so it
13691423
// exits here without paying for a runloop slice.
13701424
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-
}
1425+
throwIfTerminating();
13931426

13941427
Promise::PromiseState state = promise->State();
13951428
if (state != Promise::kPending) {
@@ -1411,6 +1444,11 @@ throw NativeScriptException("Module evaluation interrupted by isolate terminatio
14111444
}
14121445
}
14131446

1447+
// The loop leaves through its condition when a pump materializes the
1448+
// termination, and through the deadline when the request arrived during the
1449+
// final slice.
1450+
throwIfTerminating();
1451+
14141452
if (!settled && promise->State() == Promise::kPending) {
14151453
LogEsmPhase(canonicalPath, "evaluate", "promise-timeout");
14161454
if (options.timeoutBehavior == ModuleEvaluationOptions::TimeoutBehavior::kThrow) {
@@ -1434,6 +1472,21 @@ throw NativeScriptException("Module evaluation interrupted by isolate terminatio
14341472
return MaybeLocal<Promise>();
14351473
}
14361474

1475+
// A pumped graph walk bails on a termination request but reports nothing;
1476+
// what follows it would compile, fetch synchronously or evaluate on an
1477+
// isolate that must not run anything more.
1478+
static void ThrowIfLoadInterruptedByTermination(Isolate* isolate,
1479+
const std::string& canonicalPath) {
1480+
Runtime* runtime = Runtime::GetRuntime(isolate);
1481+
if (!isolate->IsExecutionTerminating() &&
1482+
(runtime == nullptr || !runtime->IsTerminationRequested())) {
1483+
return;
1484+
}
1485+
LogEsmPhase(canonicalPath, "load", "terminated");
1486+
throw NativeScriptException("Module evaluation interrupted by isolate termination: " +
1487+
canonicalPath);
1488+
}
1489+
14371490
Local<Value> ModuleInternal::LoadESModule(Isolate* isolate, const std::string& path,
14381491
const ModuleEvaluationOptions& options) {
14391492
bool isHttpModule = IsHttpModulePath(path);
@@ -1513,6 +1566,7 @@ throw NativeScriptException("Module evaluation interrupted by isolate terminatio
15131566
// registry hit and instantiation resolves as pure lookup. On timeout or
15141567
// partial coverage the legacy synchronous path still owns correctness.
15151568
RunModuleGraphLoadPumped(isolate, context, requestPath, kModuleEvaluateDeadlineSeconds);
1569+
ThrowIfLoadInterruptedByTermination(isolate, canonicalPath);
15161570
MaybeLocal<Module> maybeMod = LoadHttpModuleForUrl(isolate, context, requestPath);
15171571
if (!maybeMod.ToLocal(&module)) {
15181572
logPhase("compile", "fail", "http-loader");
@@ -1536,6 +1590,7 @@ throw NativeScriptException("Module evaluation interrupted by isolate terminatio
15361590
// async fetch, so it legitimately waits here and then evaluates
15371591
// synchronously.
15381592
RunModuleGraphLoadPumped(isolate, context, canonicalPath, kModuleEvaluateDeadlineSeconds);
1593+
ThrowIfLoadInterruptedByTermination(isolate, canonicalPath);
15391594
auto walkedIt = registry.find(canonicalPath);
15401595
if (walkedIt != registry.end()) {
15411596
Local<Module> walked = walkedIt->second.Get(isolate);
@@ -1597,6 +1652,13 @@ throw NativeScriptException(isolate, tcCompile,
15971652

15981653
if (!linked) {
15991654
RemoveModuleFromRegistry(isolate, canonicalPath);
1655+
Runtime* runtime = Runtime::GetRuntime(isolate);
1656+
if (tcLink.HasTerminated() || isolate->IsExecutionTerminating() ||
1657+
(runtime != nullptr && runtime->IsTerminationRequested())) {
1658+
logPhase("instantiate", "terminated");
1659+
throw NativeScriptException("Module evaluation interrupted by isolate termination: " +
1660+
canonicalPath);
1661+
}
16001662
const char* classification = "unknown";
16011663
if (tcLink.HasCaught()) {
16021664
Local<Message> msg = tcLink.Message();

‎NativeScript/runtime/NativeScriptException.mm‎

Lines changed: 19 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_;
@@ -705,6 +714,14 @@ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local<Context> context, L
705714
}
706715

707716
void NativeScriptException::ReThrowToV8(Isolate* isolate) {
717+
// A materialized termination stays pending until the JS frames below
718+
// unwind; throwing would replace it with an ordinary error. Only the V8
719+
// signal counts here, not the runtime's termination-requested flag: while
720+
// the request has not materialized, JS still runs, and an ordinary failure
721+
// must still throw or the caller would continue with a bogus result.
722+
if (isolate->IsExecutionTerminating()) {
723+
return;
724+
}
708725
@try {
709726
// The Isolate::Scope here is necessary because the Exception::Error method internally relies on
710727
// the Isolate::GetCurrent method which might return null if we do not use the proper scope
@@ -765,7 +782,7 @@ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local<Context> context, L
765782
std::string errMessage;
766783
bool hasFullErrorMessage = false;
767784
auto v8FullMessage = tns::ToV8String(isolate, "fullMessage");
768-
if (error->IsObject() && error.As<Object>()->Has(context, v8FullMessage).ToChecked()) {
785+
if (error->IsObject() && error.As<Object>()->Has(context, v8FullMessage).FromMaybe(false)) {
769786
hasFullErrorMessage = true;
770787
Local<Value> errMsgVal;
771788
bool success = error.As<Object>()->Get(context, v8FullMessage).ToLocal(&errMsgVal);

0 commit comments

Comments
 (0)