Skip to content

Commit 356aa3c

Browse files
committed
feat(worker): resourceLimits for worker isolates
Adds Node's `resourceLimits` worker option, at the top level of the options object rather than under `ios`: new Worker("./w.js", { resourceLimits: { maxOldGenerationSizeMb: 64, maxYoungGenerationSizeMb: 8, jsDispatchTableSizeMb: 64, }, }); The two heap caps map onto v8::ResourceConstraints and are applied through a new `IsolateLimits` struct that Runtime::CreateIsolate takes; the struct is threaded through the worker startup lambda so CreateIsolate itself stays free of worker policy. Values are validated at construction: a non-object `resourceLimits` or a non-numeric key throws a TypeError, a non-finite or non-positive value throws a RangeError, and unknown keys are ignored. `jsDispatchTableSizeMb` is a NativeScript extension compiled behind V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM. Until the prebuilt V8 carries the reservation parameter, passing it throws; once it does, worker isolates default to a 64 MB reservation instead of V8's 256 MB, and the main isolate keeps the default. Capping a worker's heap is only useful if exhausting it is recoverable, so every worker isolate now registers a near-heap-limit callback (Node does the same unconditionally for workers). It forwards "Worker JS heap out of memory" to the parent's worker.onerror as a plain string payload, asks V8 to terminate the isolate and returns the limit raised by 16 MB so the in-progress GC can finish. Termination goes to the isolate the callback belongs to rather than through Terminate() alone, because Terminate() only reaches an isolate BackgroundLooper has already published — which happens after the entry script finishes evaluating, and a worker that exhausts its heap usually does so inside that entry. Building the exception detail for a terminating isolate crashed on the empty v8::Message such an isolate reports, so GetFullMessage now returns the plain JS message when there is none, and reads the line number with FromMaybe.
1 parent d85eb2c commit 356aa3c

10 files changed

Lines changed: 366 additions & 6 deletions

File tree

‎NativeScript/runtime/DataWrapper.h‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,19 @@ class WorkerWrapper : public BaseDataWrapper {
586586
void Close();
587587
void Terminate();
588588

589+
// Arms the near-heap-limit callback on the worker's own isolate. Runs on the
590+
// worker thread right after the isolate exists; without it an isolate that
591+
// reaches its heap cap aborts the whole process instead of surfacing as an
592+
// error on the parent's Worker object. `maxOldGenerationSizeBytes` is only
593+
// used to name the cap in the message forwarded to the parent.
594+
void WatchHeapLimit(v8::Isolate* isolate, const std::string& scriptPath,
595+
std::optional<size_t> maxOldGenerationSizeBytes);
596+
// Whether the heap cap was hit. The worker startup path checks this to stop
597+
// before running anything else in an isolate V8 is terminating.
598+
inline bool HeapLimitExceeded() const {
599+
return heapLimitExceeded_.load(std::memory_order_acquire);
600+
}
601+
589602
const WrapperType Type();
590603
const int Id();
591604
const inline bool isDisposed() { return isDisposed_; }
@@ -619,6 +632,19 @@ class WorkerWrapper : public BaseDataWrapper {
619632
// thread) and DestroyInspector() (worker thread) agree on liveness.
620633
v8_inspector::WorkerInspectorClient* inspector_ = nullptr;
621634
std::mutex inspectorMutex_;
635+
// The worker isolate as seen from the heap-limit callback. Separate from
636+
// workerIsolate_, which BackgroundLooper only publishes once the entry script
637+
// has finished evaluating — the point at which a heap cap is most likely to
638+
// be hit is inside that entry.
639+
v8::Isolate* heapLimitIsolate_ = nullptr;
640+
std::string heapLimitMessage_;
641+
std::string heapLimitSource_;
642+
std::atomic<bool> heapLimitExceeded_{false};
643+
644+
// Runs on the worker thread from inside a GC, where no JS may run and no
645+
// handle may be created.
646+
static size_t OnNearHeapLimit(void* data, size_t current_heap_limit,
647+
size_t initial_heap_limit);
622648

623649
void BackgroundLooper(std::function<v8::Isolate*()> func);
624650
void DrainPendingTasks();

‎NativeScript/runtime/NativeScriptException.mm‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,12 @@ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local<Context> context,
805805

806806
std::string NativeScriptException::GetFullMessage(Isolate* isolate, Local<v8::Message> message,
807807
const std::string& jsExceptionMessage) {
808+
// An isolate V8 has been told to terminate hands back an exception with no
809+
// v8::Message at all, and every read below needs a real handle.
810+
if (message.IsEmpty()) {
811+
return jsExceptionMessage;
812+
}
813+
808814
Local<Context> context = isolate->GetEnteredOrMicrotaskContext();
809815

810816
std::stringstream ss;
@@ -821,7 +827,9 @@ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local<Context> context,
821827
} else {
822828
ss << std::endl << "File: (<unknown>";
823829
}
824-
ss << ":" << message->GetLineNumber(context).ToChecked() << ":" << message->GetStartColumn()
830+
// FromMaybe, not ToChecked: a location lookup that fails is not worth
831+
// aborting the process over.
832+
ss << ":" << message->GetLineNumber(context).FromMaybe(0) << ":" << message->GetStartColumn()
825833
<< ")" << std::endl
826834
<< std::endl;
827835
ss << "StackTrace: " << std::endl << stackTraceMessage << std::endl;

‎NativeScript/runtime/Runtime.h‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#define Runtime_h
33

44
#include <atomic>
5+
#include <optional>
56

67
#include "Caches.h"
78
#include "Common.h"
@@ -17,11 +18,21 @@ typedef struct napi_env__* napi_env;
1718

1819
namespace tns {
1920

21+
// Per-isolate caps handed to Isolate::New. Every entry is optional; an absent
22+
// one leaves V8's own default in place. Values are bytes.
23+
struct IsolateLimits {
24+
std::optional<size_t> maxOldGenerationSizeBytes;
25+
std::optional<size_t> maxYoungGenerationSizeBytes;
26+
// Only honored by a V8 build that defines
27+
// V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM; ignored otherwise.
28+
std::optional<size_t> jsDispatchTableReservationBytes;
29+
};
30+
2031
class Runtime {
2132
public:
2233
Runtime();
2334
~Runtime();
24-
v8::Isolate* CreateIsolate();
35+
v8::Isolate* CreateIsolate(const IsolateLimits& limits = {});
2536
void Init(v8::Isolate* isolate, bool isWorker = false);
2637
void RunMainScript();
2738
v8::Isolate* GetIsolate();

‎NativeScript/runtime/Runtime.mm‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ void DisposeIsolateWhenPossible(Isolate* isolate) {
316316
return static_cast<Runtime*>(isolate->GetData(Constants::RUNTIME_SLOT));
317317
}
318318

319-
Isolate* Runtime::CreateIsolate() {
319+
Isolate* Runtime::CreateIsolate(const IsolateLimits& limits) {
320320
if (!v8Initialized_) {
321321
// Runtime::platform_ = RuntimeConfig.IsDebug
322322
// ? v8_inspector::V8InspectorPlatform::CreateDefaultPlatform()
@@ -345,6 +345,19 @@ void DisposeIsolateWhenPossible(Isolate* isolate) {
345345

346346
Isolate::CreateParams create_params;
347347
create_params.array_buffer_allocator = &allocator_;
348+
if (limits.maxOldGenerationSizeBytes.has_value()) {
349+
create_params.constraints.set_max_old_generation_size_in_bytes(
350+
*limits.maxOldGenerationSizeBytes);
351+
}
352+
if (limits.maxYoungGenerationSizeBytes.has_value()) {
353+
create_params.constraints.set_max_young_generation_size_in_bytes(
354+
*limits.maxYoungGenerationSizeBytes);
355+
}
356+
#ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM
357+
if (limits.jsDispatchTableReservationBytes.has_value()) {
358+
create_params.js_dispatch_table_reservation_size = *limits.jsDispatchTableReservationBytes;
359+
}
360+
#endif
348361
Isolate* isolate = Isolate::New(create_params);
349362
runtimeLoop_ = CFRunLoopGetCurrent();
350363
// v8 already asked for this isolate's task runner during Isolate::New, so

‎NativeScript/runtime/Worker.mm‎

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#include "Worker.h"
22
#include <pthread.h>
3+
#include <cmath>
34
#include <functional>
45
#include <mutex>
56
#include <optional>
@@ -48,6 +49,18 @@ bool MapPriorityName(const std::string& name, int& qos) {
4849
throw NativeScriptException(isolate, error, message);
4950
}
5051

52+
[[noreturn]] void ThrowOptionRangeError(Isolate* isolate, const std::string& message) {
53+
Local<Value> error = Exception::RangeError(tns::ToV8String(isolate, message));
54+
throw NativeScriptException(isolate, error, message);
55+
}
56+
57+
#ifndef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM
58+
[[noreturn]] void ThrowOptionError(Isolate* isolate, const std::string& message) {
59+
Local<Value> error = Exception::Error(tns::ToV8String(isolate, message));
60+
throw NativeScriptException(isolate, error, message);
61+
}
62+
#endif
63+
5164
// Returns the quality of service the caller asked for, or nullopt to leave the
5265
// worker thread at the operation queue's own default.
5366
std::optional<int> ParseQualityOfService(Isolate* isolate, Local<Context> context,
@@ -96,6 +109,93 @@ bool MapPriorityName(const std::string& name, int& qos) {
96109
return qos;
97110
}
98111

112+
// Reads one megabyte-valued `resourceLimits` key. Absent means "leave V8's own
113+
// default alone"; anything present has to be a finite, positive number, which
114+
// is where Node draws the line too.
115+
std::optional<double> ReadMegabyteLimit(Isolate* isolate, Local<Context> context,
116+
Local<Object> resourceLimits, const char* key) {
117+
Local<Value> value;
118+
if (!resourceLimits->Get(context, tns::ToV8String(isolate, key)).ToLocal(&value) ||
119+
value->IsUndefined()) {
120+
return std::nullopt;
121+
}
122+
123+
std::string name = std::string("resourceLimits.") + key;
124+
if (!value->IsNumber()) {
125+
ThrowOptionTypeError(isolate, "Worker option \"" + name + "\" must be a number.");
126+
}
127+
128+
double megabytes = value.As<Number>()->Value();
129+
if (!std::isfinite(megabytes) || megabytes <= 0) {
130+
ThrowOptionRangeError(
131+
isolate, "Worker option \"" + name + "\" must be a finite number greater than zero.");
132+
}
133+
134+
return megabytes;
135+
}
136+
137+
// The JS dispatch table is reserved as one contiguous range at isolate
138+
// creation, so V8 only accepts whole megabytes up to its own hard ceiling.
139+
constexpr double kMaxJsDispatchTableSizeMb = 256;
140+
141+
#ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM
142+
// iOS caps a process's address space by device RAM, and every isolate reserves
143+
// 256 MB for its JS dispatch table by default; 64 MB still holds four million
144+
// dispatch entries, far more than a worker allocates. Only workers get the
145+
// smaller reservation — the main isolate keeps V8's default.
146+
constexpr size_t kDefaultWorkerJsDispatchTableBytes = 64 * 1024 * 1024;
147+
#endif
148+
149+
// Node's `resourceLimits` shape. Unknown keys are ignored, so the options Node
150+
// has and this runtime cannot honor (codeRangeSizeMb, stackSizeMb) stay
151+
// harmless to pass.
152+
IsolateLimits ParseResourceLimits(Isolate* isolate, Local<Context> context, Local<Object> options) {
153+
IsolateLimits limits;
154+
155+
Local<Value> value;
156+
if (!options->Get(context, tns::ToV8String(isolate, "resourceLimits")).ToLocal(&value) ||
157+
value->IsNullOrUndefined()) {
158+
return limits;
159+
}
160+
161+
if (!value->IsObject()) {
162+
ThrowOptionTypeError(isolate, "Worker option \"resourceLimits\" must be an object.");
163+
}
164+
Local<Object> resourceLimits = value.As<Object>();
165+
166+
constexpr double kBytesPerMegabyte = 1024 * 1024;
167+
168+
if (auto megabytes =
169+
ReadMegabyteLimit(isolate, context, resourceLimits, "maxOldGenerationSizeMb")) {
170+
limits.maxOldGenerationSizeBytes = static_cast<size_t>(*megabytes * kBytesPerMegabyte);
171+
}
172+
173+
if (auto megabytes =
174+
ReadMegabyteLimit(isolate, context, resourceLimits, "maxYoungGenerationSizeMb")) {
175+
limits.maxYoungGenerationSizeBytes = static_cast<size_t>(*megabytes * kBytesPerMegabyte);
176+
}
177+
178+
if (auto megabytes =
179+
ReadMegabyteLimit(isolate, context, resourceLimits, "jsDispatchTableSizeMb")) {
180+
if (*megabytes != std::floor(*megabytes) || *megabytes < 1 ||
181+
*megabytes > kMaxJsDispatchTableSizeMb) {
182+
ThrowOptionRangeError(isolate,
183+
"Worker option \"resourceLimits.jsDispatchTableSizeMb\" must be a "
184+
"whole number of megabytes between 1 and 256.");
185+
}
186+
#ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM
187+
limits.jsDispatchTableReservationBytes =
188+
static_cast<size_t>(*megabytes) * static_cast<size_t>(kBytesPerMegabyte);
189+
#else
190+
ThrowOptionError(isolate,
191+
"Worker option \"resourceLimits.jsDispatchTableSizeMb\" requires a V8 build "
192+
"with a configurable JS dispatch table.");
193+
#endif
194+
}
195+
196+
return limits;
197+
}
198+
99199
} // namespace
100200

101201
void Worker::Init(Isolate* isolate, Local<ObjectTemplate> globalTemplate) {
@@ -230,10 +330,19 @@ throw NativeScriptException(
230330
}
231331

232332
std::optional<int> qos;
333+
IsolateLimits resourceLimits;
233334
if (info.Length() >= 2 && info[1]->IsObject()) {
234-
qos = ParseQualityOfService(isolate, context, info[1].As<Object>());
335+
Local<Object> options = info[1].As<Object>();
336+
qos = ParseQualityOfService(isolate, context, options);
337+
resourceLimits = ParseResourceLimits(isolate, context, options);
235338
}
236339

340+
#ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM
341+
if (!resourceLimits.jsDispatchTableReservationBytes.has_value()) {
342+
resourceLimits.jsDispatchTableReservationBytes = kDefaultWorkerJsDispatchTableBytes;
343+
}
344+
#endif
345+
237346
WorkerWrapper* worker = new WorkerWrapper(isolate, Worker::OnMessageCallback);
238347
tns::SetValue(isolate, thiz, worker);
239348
std::shared_ptr<Persistent<Value>> poWorker = ObjectManager::Register(context, thiz);
@@ -246,7 +355,7 @@ throw NativeScriptException(
246355
// vocabulary updates).
247356
tns::LoaderVocabulary inheritedVocabulary = tns::CaptureLoaderVocabulary(isolate);
248357

249-
std::function<Isolate*()> func([worker, workerPath, inheritedVocabulary]() {
358+
std::function<Isolate*()> func([worker, workerPath, inheritedVocabulary, resourceLimits]() {
250359
// Name the looper thread after its entry script so a crash report
251360
// identifies which worker died instead of an anonymous NSOperationQueue
252361
// thread. Darwin caps thread names at 63 bytes; keep the basename only.
@@ -273,8 +382,11 @@ throw NativeScriptException(
273382
}
274383

275384
tns::Runtime* runtime = new tns::Runtime();
276-
Isolate* isolate = runtime->CreateIsolate();
385+
Isolate* isolate = runtime->CreateIsolate(resourceLimits);
277386
v8::Locker locker(isolate);
387+
// Armed for every worker isolate, capped or not: without it V8 aborts the
388+
// whole process when a worker exhausts its heap.
389+
worker->WatchHeapLimit(isolate, resolvedPath, resourceLimits.maxOldGenerationSizeBytes);
278390
runtime->Init(isolate, true);
279391
// Before any module load runs in this isolate.
280392
tns::InstallLoaderVocabulary(isolate, inheritedVocabulary);
@@ -313,6 +425,12 @@ throw NativeScriptException(
313425
ex.ReThrowToV8(isolate);
314426
}
315427

428+
// The near-heap-limit callback has already reported to the parent and
429+
// asked V8 to terminate this isolate; everything below would run JS on it.
430+
if (worker->HeapLimitExceeded()) {
431+
return isolate;
432+
}
433+
316434
// WHATWG parity: enable the implicit port's message queue once the
317435
// entry has finished evaluating. RunModule returns settled for classic
318436
// scripts and pumped HTTP entries; a local top-level-await entry that

‎NativeScript/runtime/WorkerWrapper.mm‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,15 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function<void()> fn, bool a
165165
// is deleted below.
166166
this->DestroyInspector();
167167

168+
// The callback closes over this wrapper, which ~Runtime may delete below,
169+
// while V8 keeps the registration until the isolate is disposed - and
170+
// disposal can be deferred past that point.
171+
if (this->heapLimitIsolate_ != nullptr) {
172+
v8::Locker locker(this->heapLimitIsolate_);
173+
this->heapLimitIsolate_->RemoveNearHeapLimitCallback(WorkerWrapper::OnNearHeapLimit, 0);
174+
this->heapLimitIsolate_ = nullptr;
175+
}
176+
168177
this->isDisposed_ = true;
169178
Runtime* runtime = Runtime::GetCurrentRuntime();
170179
if (runtime != nullptr) {
@@ -221,6 +230,52 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function<void()> fn, bool a
221230
}
222231
}
223232

233+
void WorkerWrapper::WatchHeapLimit(Isolate* isolate, const std::string& scriptPath,
234+
std::optional<size_t> maxOldGenerationSizeBytes) {
235+
this->heapLimitIsolate_ = isolate;
236+
this->heapLimitSource_ = scriptPath.empty() ? "Worker" : scriptPath;
237+
this->heapLimitMessage_ = "Worker JS heap out of memory";
238+
if (maxOldGenerationSizeBytes.has_value()) {
239+
this->heapLimitMessage_ +=
240+
" (maxOldGenerationSizeMb: " + std::to_string(*maxOldGenerationSizeBytes / (1024 * 1024)) +
241+
")";
242+
}
243+
isolate->AddNearHeapLimitCallback(WorkerWrapper::OnNearHeapLimit, this);
244+
}
245+
246+
size_t WorkerWrapper::OnNearHeapLimit(void* data, size_t current_heap_limit,
247+
size_t initial_heap_limit) {
248+
auto* worker = static_cast<WorkerWrapper*>(data);
249+
250+
// Node's allowance: raising the limit lets the in-progress GC finish instead
251+
// of aborting the process, and the isolate is being torn down anyway. The
252+
// same raised limit has to come back on every later invocation, because
253+
// returning a lower one is fatal to V8.
254+
constexpr size_t kHeapLimitAllowance = 16 * 1024 * 1024;
255+
size_t raisedLimit = current_heap_limit + kHeapLimitAllowance;
256+
257+
if (worker->heapLimitExceeded_.exchange(true, std::memory_order_acq_rel)) {
258+
return raisedLimit;
259+
}
260+
261+
// Marshals strings onto the parent's loop and touches no V8 handle, which is
262+
// the only kind of reporting allowed from inside a GC.
263+
worker->PassUncaughtExceptionFromWorkerToMain(worker->heapLimitMessage_, worker->heapLimitSource_,
264+
"", 0, true);
265+
266+
// Terminate() only reaches an isolate BackgroundLooper has already published,
267+
// which happens after the entry script finished evaluating — and a worker
268+
// that exhausts its heap usually does so inside that entry. Ask the isolate
269+
// this callback belongs to directly.
270+
if (Runtime* runtime = Runtime::GetRuntime(worker->heapLimitIsolate_)) {
271+
runtime->RequestTermination();
272+
}
273+
worker->heapLimitIsolate_->TerminateExecution();
274+
worker->Terminate();
275+
276+
return raisedLimit;
277+
}
278+
224279
void WorkerWrapper::CreateInspector(Isolate* isolate, const std::string& scriptPath) {
225280
if (!RuntimeConfig.IsDebug) {
226281
return;

0 commit comments

Comments
 (0)