From c3d8d8c12fd62e6aa16fdfea6b3036513ee7081a Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sat, 5 Sep 2026 23:03:17 -0300 Subject: [PATCH 1/4] feat(worker): resourceLimits for worker isolates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- NativeScript/runtime/DataWrapper.h | 26 ++++ NativeScript/runtime/NativeScriptException.mm | 10 +- NativeScript/runtime/Runtime.h | 13 +- NativeScript/runtime/Runtime.mm | 15 +- NativeScript/runtime/Worker.mm | 144 +++++++++++++++++- NativeScript/runtime/WorkerWrapper.mm | 55 +++++++ .../app/tests/WorkerResourceLimitsTests.js | 119 +++++++++++++++ TestRunner/app/tests/index.js | 1 + .../tests/workerResourceLimits/echoWorker.js | 3 + .../tests/workerResourceLimits/oomWorker.js | 6 + 10 files changed, 386 insertions(+), 6 deletions(-) create mode 100644 TestRunner/app/tests/WorkerResourceLimitsTests.js create mode 100644 TestRunner/app/tests/workerResourceLimits/echoWorker.js create mode 100644 TestRunner/app/tests/workerResourceLimits/oomWorker.js diff --git a/NativeScript/runtime/DataWrapper.h b/NativeScript/runtime/DataWrapper.h index becc9ca9..193d2fbf 100644 --- a/NativeScript/runtime/DataWrapper.h +++ b/NativeScript/runtime/DataWrapper.h @@ -586,6 +586,19 @@ class WorkerWrapper : public BaseDataWrapper { void Close(); void Terminate(); + // Arms the near-heap-limit callback on the worker's own isolate. Runs on the + // worker thread right after the isolate exists; without it an isolate that + // reaches its heap cap aborts the whole process instead of surfacing as an + // error on the parent's Worker object. `maxOldGenerationSizeBytes` is only + // used to name the cap in the message forwarded to the parent. + void WatchHeapLimit(v8::Isolate* isolate, const std::string& scriptPath, + std::optional maxOldGenerationSizeBytes); + // Whether the heap cap was hit. The worker startup path checks this to stop + // before running anything else in an isolate V8 is terminating. + inline bool HeapLimitExceeded() const { + return heapLimitExceeded_.load(std::memory_order_acquire); + } + const WrapperType Type(); const int Id(); const inline bool isDisposed() { return isDisposed_; } @@ -619,6 +632,19 @@ class WorkerWrapper : public BaseDataWrapper { // thread) and DestroyInspector() (worker thread) agree on liveness. v8_inspector::WorkerInspectorClient* inspector_ = nullptr; std::mutex inspectorMutex_; + // The worker isolate as seen from the heap-limit callback. Separate from + // workerIsolate_, which BackgroundLooper only publishes once the entry script + // has finished evaluating — the point at which a heap cap is most likely to + // be hit is inside that entry. + v8::Isolate* heapLimitIsolate_ = nullptr; + std::string heapLimitMessage_; + std::string heapLimitSource_; + std::atomic heapLimitExceeded_{false}; + + // Runs on the worker thread from inside a GC, where no JS may run and no + // handle may be created. + static size_t OnNearHeapLimit(void* data, size_t current_heap_limit, + size_t initial_heap_limit); void BackgroundLooper(std::function func); void DrainPendingTasks(); diff --git a/NativeScript/runtime/NativeScriptException.mm b/NativeScript/runtime/NativeScriptException.mm index e429b76c..3ebf0714 100644 --- a/NativeScript/runtime/NativeScriptException.mm +++ b/NativeScript/runtime/NativeScriptException.mm @@ -805,6 +805,12 @@ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local context, std::string NativeScriptException::GetFullMessage(Isolate* isolate, Local message, const std::string& jsExceptionMessage) { + // An isolate V8 has been told to terminate hands back an exception with no + // v8::Message at all, and every read below needs a real handle. + if (message.IsEmpty()) { + return jsExceptionMessage; + } + Local context = isolate->GetEnteredOrMicrotaskContext(); std::stringstream ss; @@ -821,7 +827,9 @@ static bool GiveWorkerOnErrorAChance(Isolate* isolate, Local context, } else { ss << std::endl << "File: ("; } - ss << ":" << message->GetLineNumber(context).ToChecked() << ":" << message->GetStartColumn() + // FromMaybe, not ToChecked: a location lookup that fails is not worth + // aborting the process over. + ss << ":" << message->GetLineNumber(context).FromMaybe(0) << ":" << message->GetStartColumn() << ")" << std::endl << std::endl; ss << "StackTrace: " << std::endl << stackTraceMessage << std::endl; diff --git a/NativeScript/runtime/Runtime.h b/NativeScript/runtime/Runtime.h index f406116b..106e4ec4 100644 --- a/NativeScript/runtime/Runtime.h +++ b/NativeScript/runtime/Runtime.h @@ -2,6 +2,7 @@ #define Runtime_h #include +#include #include "Caches.h" #include "Common.h" @@ -17,11 +18,21 @@ typedef struct napi_env__* napi_env; namespace tns { +// Per-isolate caps handed to Isolate::New. Every entry is optional; an absent +// one leaves V8's own default in place. Values are bytes. +struct IsolateLimits { + std::optional maxOldGenerationSizeBytes; + std::optional maxYoungGenerationSizeBytes; + // Only honored by a V8 build that defines + // V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM; ignored otherwise. + std::optional jsDispatchTableReservationBytes; +}; + class Runtime { public: Runtime(); ~Runtime(); - v8::Isolate* CreateIsolate(); + v8::Isolate* CreateIsolate(const IsolateLimits& limits = {}); void Init(v8::Isolate* isolate, bool isWorker = false); void RunMainScript(); v8::Isolate* GetIsolate(); diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index a5d7a58f..15610799 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -316,7 +316,7 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { return static_cast(isolate->GetData(Constants::RUNTIME_SLOT)); } -Isolate* Runtime::CreateIsolate() { +Isolate* Runtime::CreateIsolate(const IsolateLimits& limits) { if (!v8Initialized_) { // Runtime::platform_ = RuntimeConfig.IsDebug // ? v8_inspector::V8InspectorPlatform::CreateDefaultPlatform() @@ -345,6 +345,19 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { Isolate::CreateParams create_params; create_params.array_buffer_allocator = &allocator_; + if (limits.maxOldGenerationSizeBytes.has_value()) { + create_params.constraints.set_max_old_generation_size_in_bytes( + *limits.maxOldGenerationSizeBytes); + } + if (limits.maxYoungGenerationSizeBytes.has_value()) { + create_params.constraints.set_max_young_generation_size_in_bytes( + *limits.maxYoungGenerationSizeBytes); + } +#ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM + if (limits.jsDispatchTableReservationBytes.has_value()) { + create_params.js_dispatch_table_reservation_size = *limits.jsDispatchTableReservationBytes; + } +#endif Isolate* isolate = Isolate::New(create_params); runtimeLoop_ = CFRunLoopGetCurrent(); // v8 already asked for this isolate's task runner during Isolate::New, so diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index 85dffd63..233b2935 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -1,5 +1,6 @@ #include "Worker.h" #include +#include #include #include #include @@ -48,6 +49,18 @@ bool MapPriorityName(const std::string& name, int& qos) { throw NativeScriptException(isolate, error, message); } +[[noreturn]] void ThrowOptionRangeError(Isolate* isolate, const std::string& message) { + Local error = Exception::RangeError(tns::ToV8String(isolate, message)); + throw NativeScriptException(isolate, error, message); +} + +#ifndef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM +[[noreturn]] void ThrowOptionError(Isolate* isolate, const std::string& message) { + Local error = Exception::Error(tns::ToV8String(isolate, message)); + throw NativeScriptException(isolate, error, message); +} +#endif + // Reads `key` from `object`. A false return means the getter threw: the // exception is already pending on the isolate and construction must stop // without running anything else on it. @@ -107,6 +120,113 @@ bool ParseQualityOfService(Isolate* isolate, Local context, Local context, Local resourceLimits, + const char* key, std::optional& megabytes) { + Local value; + if (!ReadOption(isolate, context, resourceLimits, key, value)) { + return false; + } + if (value->IsUndefined()) { + return true; + } + + std::string name = std::string("resourceLimits.") + key; + if (!value->IsNumber()) { + ThrowOptionTypeError(isolate, "Worker option \"" + name + "\" must be a number."); + } + + double parsed = value.As()->Value(); + if (!std::isfinite(parsed) || parsed * kBytesPerMegabyte < 1 || parsed > kMaxLimitMegabytes) { + ThrowOptionRangeError(isolate, "Worker option \"" + name + + "\" must be a finite number of megabytes worth at least " + "one byte and at most 1048576."); + } + + megabytes = parsed; + return true; +} + +// The JS dispatch table is reserved as one contiguous range at isolate +// creation, so V8 only accepts whole megabytes up to its own hard ceiling. +constexpr double kMaxJsDispatchTableSizeMb = 256; + +#ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM +// iOS caps a process's address space by device RAM, and every isolate reserves +// 256 MB for its JS dispatch table by default; 64 MB still holds four million +// dispatch entries, far more than a worker allocates. Only workers get the +// smaller reservation — the main isolate keeps V8's default. +constexpr size_t kDefaultWorkerJsDispatchTableBytes = 64 * 1024 * 1024; +#endif + +// Node's `resourceLimits` shape. Unknown keys are ignored, so the options Node +// has and this runtime cannot honor (codeRangeSizeMb, stackSizeMb) stay +// harmless to pass. Returns false when a getter threw (see ReadOption). +bool ParseResourceLimits(Isolate* isolate, Local context, Local options, + IsolateLimits& limits) { + Local value; + if (!ReadOption(isolate, context, options, "resourceLimits", value)) { + return false; + } + if (value->IsNullOrUndefined()) { + return true; + } + + if (!value->IsObject()) { + ThrowOptionTypeError(isolate, "Worker option \"resourceLimits\" must be an object."); + } + Local resourceLimits = value.As(); + + std::optional megabytes; + + if (!ReadMegabyteLimit(isolate, context, resourceLimits, "maxOldGenerationSizeMb", megabytes)) { + return false; + } + if (megabytes) { + limits.maxOldGenerationSizeBytes = static_cast(*megabytes * kBytesPerMegabyte); + } + + megabytes.reset(); + if (!ReadMegabyteLimit(isolate, context, resourceLimits, "maxYoungGenerationSizeMb", + megabytes)) { + return false; + } + if (megabytes) { + limits.maxYoungGenerationSizeBytes = static_cast(*megabytes * kBytesPerMegabyte); + } + + megabytes.reset(); + if (!ReadMegabyteLimit(isolate, context, resourceLimits, "jsDispatchTableSizeMb", megabytes)) { + return false; + } + if (megabytes) { + if (*megabytes != std::floor(*megabytes) || *megabytes < 1 || + *megabytes > kMaxJsDispatchTableSizeMb) { + ThrowOptionRangeError(isolate, + "Worker option \"resourceLimits.jsDispatchTableSizeMb\" must be a " + "whole number of megabytes between 1 and 256."); + } +#ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM + limits.jsDispatchTableReservationBytes = + static_cast(*megabytes) * static_cast(kBytesPerMegabyte); +#else + ThrowOptionError(isolate, + "Worker option \"resourceLimits.jsDispatchTableSizeMb\" requires a V8 build " + "with a configurable JS dispatch table."); +#endif + } + + return true; +} + } // namespace void Worker::Init(Isolate* isolate, Local globalTemplate) { @@ -241,12 +361,21 @@ throw NativeScriptException( } std::optional qos; + IsolateLimits resourceLimits; if (info.Length() >= 2 && info[1]->IsObject()) { - if (!ParseQualityOfService(isolate, context, info[1].As(), qos)) { + Local options = info[1].As(); + if (!ParseQualityOfService(isolate, context, options, qos) || + !ParseResourceLimits(isolate, context, options, resourceLimits)) { return; } } +#ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM + if (!resourceLimits.jsDispatchTableReservationBytes.has_value()) { + resourceLimits.jsDispatchTableReservationBytes = kDefaultWorkerJsDispatchTableBytes; + } +#endif + WorkerWrapper* worker = new WorkerWrapper(isolate, Worker::OnMessageCallback); tns::SetValue(isolate, thiz, worker); std::shared_ptr> poWorker = ObjectManager::Register(context, thiz); @@ -259,7 +388,7 @@ throw NativeScriptException( // vocabulary updates). tns::LoaderVocabulary inheritedVocabulary = tns::CaptureLoaderVocabulary(isolate); - std::function func([worker, workerPath, inheritedVocabulary]() { + std::function func([worker, workerPath, inheritedVocabulary, resourceLimits]() { // Name the looper thread after its entry script so a crash report // identifies which worker died instead of an anonymous NSOperationQueue // thread. Darwin caps thread names at 63 bytes; keep the basename only. @@ -286,8 +415,11 @@ throw NativeScriptException( } tns::Runtime* runtime = new tns::Runtime(); - Isolate* isolate = runtime->CreateIsolate(); + Isolate* isolate = runtime->CreateIsolate(resourceLimits); v8::Locker locker(isolate); + // Armed for every worker isolate, capped or not: without it V8 aborts the + // whole process when a worker exhausts its heap. + worker->WatchHeapLimit(isolate, resolvedPath, resourceLimits.maxOldGenerationSizeBytes); runtime->Init(isolate, true); // Before any module load runs in this isolate. tns::InstallLoaderVocabulary(isolate, inheritedVocabulary); @@ -326,6 +458,12 @@ throw NativeScriptException( ex.ReThrowToV8(isolate); } + // The near-heap-limit callback has already reported to the parent and + // asked V8 to terminate this isolate; everything below would run JS on it. + if (worker->HeapLimitExceeded()) { + return isolate; + } + // WHATWG parity: enable the implicit port's message queue once the // entry has finished evaluating. RunModule returns settled for classic // scripts and pumped HTTP entries; a local top-level-await entry that diff --git a/NativeScript/runtime/WorkerWrapper.mm b/NativeScript/runtime/WorkerWrapper.mm index 8ecf8b01..8dd67a63 100644 --- a/NativeScript/runtime/WorkerWrapper.mm +++ b/NativeScript/runtime/WorkerWrapper.mm @@ -165,6 +165,15 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a // is deleted below. this->DestroyInspector(); + // The callback closes over this wrapper, which ~Runtime may delete below, + // while V8 keeps the registration until the isolate is disposed - and + // disposal can be deferred past that point. + if (this->heapLimitIsolate_ != nullptr) { + v8::Locker locker(this->heapLimitIsolate_); + this->heapLimitIsolate_->RemoveNearHeapLimitCallback(WorkerWrapper::OnNearHeapLimit, 0); + this->heapLimitIsolate_ = nullptr; + } + this->isDisposed_ = true; Runtime* runtime = Runtime::GetCurrentRuntime(); if (runtime != nullptr) { @@ -221,6 +230,52 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a } } +void WorkerWrapper::WatchHeapLimit(Isolate* isolate, const std::string& scriptPath, + std::optional maxOldGenerationSizeBytes) { + this->heapLimitIsolate_ = isolate; + this->heapLimitSource_ = scriptPath.empty() ? "Worker" : scriptPath; + this->heapLimitMessage_ = "Worker JS heap out of memory"; + if (maxOldGenerationSizeBytes.has_value()) { + this->heapLimitMessage_ += + " (maxOldGenerationSizeMb: " + std::to_string(*maxOldGenerationSizeBytes / (1024 * 1024)) + + ")"; + } + isolate->AddNearHeapLimitCallback(WorkerWrapper::OnNearHeapLimit, this); +} + +size_t WorkerWrapper::OnNearHeapLimit(void* data, size_t current_heap_limit, + size_t initial_heap_limit) { + auto* worker = static_cast(data); + + // Node's allowance: raising the limit lets the in-progress GC finish instead + // of aborting the process, and the isolate is being torn down anyway. The + // same raised limit has to come back on every later invocation, because + // returning a lower one is fatal to V8. + constexpr size_t kHeapLimitAllowance = 16 * 1024 * 1024; + size_t raisedLimit = current_heap_limit + kHeapLimitAllowance; + + if (worker->heapLimitExceeded_.exchange(true, std::memory_order_acq_rel)) { + return raisedLimit; + } + + // Marshals strings onto the parent's loop and touches no V8 handle, which is + // the only kind of reporting allowed from inside a GC. + worker->PassUncaughtExceptionFromWorkerToMain(worker->heapLimitMessage_, worker->heapLimitSource_, + "", 0, true); + + // Terminate() only reaches an isolate BackgroundLooper has already published, + // which happens after the entry script finished evaluating — and a worker + // that exhausts its heap usually does so inside that entry. Ask the isolate + // this callback belongs to directly. + if (Runtime* runtime = Runtime::GetRuntime(worker->heapLimitIsolate_)) { + runtime->RequestTermination(); + } + worker->heapLimitIsolate_->TerminateExecution(); + worker->Terminate(); + + return raisedLimit; +} + void WorkerWrapper::CreateInspector(Isolate* isolate, const std::string& scriptPath) { if (!RuntimeConfig.IsDebug) { return; diff --git a/TestRunner/app/tests/WorkerResourceLimitsTests.js b/TestRunner/app/tests/WorkerResourceLimitsTests.js new file mode 100644 index 00000000..ec09269b --- /dev/null +++ b/TestRunner/app/tests/WorkerResourceLimitsTests.js @@ -0,0 +1,119 @@ +describe("Worker resourceLimits", function () { + var echoEntry = "./workerResourceLimits/echoWorker.js"; + var oomEntry = "./workerResourceLimits/oomWorker.js"; + + // Jasmine arms a spec's async timeout before calling it, so the interval + // has to be raised ahead of the spec, not inside it. + var originalTimeout; + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; + }); + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + var expectStarts = function (options, done) { + var worker = options === undefined ? new Worker(echoEntry) : new Worker(echoEntry, options); + var settled = false; + var finish = function () { + if (settled) { + return; + } + settled = true; + worker.terminate(); + done(); + }; + worker.onmessage = function (msg) { + expect(msg.data.started).toBe(true); + finish(); + }; + worker.onerror = function (e) { + expect(String(e && e.message ? e.message : e)).toBe(""); + finish(); + }; + }; + + it("starts a worker under a maxYoungGenerationSizeMb cap", function (done) { + expectStarts({ resourceLimits: { maxYoungGenerationSizeMb: 8 } }, done); + }); + + it("starts a worker under both heap caps", function (done) { + expectStarts({ resourceLimits: { maxOldGenerationSizeMb: 64, maxYoungGenerationSizeMb: 8 } }, done); + }); + + it("treats resourceLimits: null like an absent resourceLimits", function (done) { + expectStarts({ resourceLimits: null }, done); + }); + + it("ignores unknown keys inside resourceLimits", function (done) { + expectStarts({ resourceLimits: { maxOldGenerationSizeMb: 64, somethingElse: 42 } }, done); + }); + + it("reports a worker that runs out of heap through onerror", function (done) { + var worker = new Worker(oomEntry, { resourceLimits: { maxOldGenerationSizeMb: 32 } }); + var settled = false; + + // The entry never returns, so nothing can post: a message here means the + // cap was not applied at all. + worker.onmessage = function () { + expect("worker posted a message").toBe("worker exhausted its heap"); + }; + + worker.onerror = function (e) { + if (settled) { + return; + } + settled = true; + var message = String(e && e.message ? e.message : e); + expect(message).toMatch(/out of memory/i); + // Naming the cap is what tells this apart from any other failure + // the worker could have reported. + expect(message).toMatch(/maxOldGenerationSizeMb: 32/); + worker.terminate(); + done(); + }; + }); + + it("throws a TypeError when resourceLimits is not an object", function () { + expect(function () { + new Worker(echoEntry, { resourceLimits: 5 }); + }).toThrowError(TypeError, /"resourceLimits"/); + }); + + it("throws a TypeError for a non-numeric maxOldGenerationSizeMb", function () { + expect(function () { + new Worker(echoEntry, { resourceLimits: { maxOldGenerationSizeMb: "64" } }); + }).toThrowError(TypeError, /"resourceLimits\.maxOldGenerationSizeMb"/); + }); + + it("throws a RangeError for a maxOldGenerationSizeMb of zero", function () { + expect(function () { + new Worker(echoEntry, { resourceLimits: { maxOldGenerationSizeMb: 0 } }); + }).toThrowError(RangeError, /"resourceLimits\.maxOldGenerationSizeMb"/); + }); + + it("throws a RangeError for a NaN maxYoungGenerationSizeMb", function () { + expect(function () { + new Worker(echoEntry, { resourceLimits: { maxYoungGenerationSizeMb: NaN } }); + }).toThrowError(RangeError, /"resourceLimits\.maxYoungGenerationSizeMb"/); + }); + + it("throws a RangeError for a jsDispatchTableSizeMb above the ceiling", function () { + expect(function () { + new Worker(echoEntry, { resourceLimits: { jsDispatchTableSizeMb: 300 } }); + }).toThrowError(RangeError, /"resourceLimits\.jsDispatchTableSizeMb"/); + }); + + it("throws a RangeError for a fractional jsDispatchTableSizeMb", function () { + expect(function () { + new Worker(echoEntry, { resourceLimits: { jsDispatchTableSizeMb: 1.5 } }); + }).toThrowError(RangeError, /"resourceLimits\.jsDispatchTableSizeMb"/); + }); + + // Enable once the prebuilt V8 carries the JS dispatch table reservation + // parameter; until then the option is rejected as unsupported. + xit("starts a worker under a jsDispatchTableSizeMb reservation", function (done) { + expectStarts({ resourceLimits: { jsDispatchTableSizeMb: 64 } }, done); + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 06e580a0..2f73c060 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -132,6 +132,7 @@ require("./NsRuntimeTests"); require("./GCFinalizerTests"); require("./WorkerConcurrentStartupTests"); require("./WorkerOptionsTests"); +require("./WorkerResourceLimitsTests"); require("./DeclarationConflicts"); // require("./Promises"); diff --git a/TestRunner/app/tests/workerResourceLimits/echoWorker.js b/TestRunner/app/tests/workerResourceLimits/echoWorker.js new file mode 100644 index 00000000..855fa6c1 --- /dev/null +++ b/TestRunner/app/tests/workerResourceLimits/echoWorker.js @@ -0,0 +1,3 @@ +// Entry for WorkerResourceLimitsTests: reports that the isolate came up under +// whatever resourceLimits the parent passed. +postMessage({ started: true }); diff --git a/TestRunner/app/tests/workerResourceLimits/oomWorker.js b/TestRunner/app/tests/workerResourceLimits/oomWorker.js new file mode 100644 index 00000000..6f0b1490 --- /dev/null +++ b/TestRunner/app/tests/workerResourceLimits/oomWorker.js @@ -0,0 +1,6 @@ +// Entry for WorkerResourceLimitsTests: allocates and never releases, so the +// isolate walks into the maxOldGenerationSizeMb cap the parent set. +var keep = []; +for (;;) { + keep.push(new Array(100000).fill(1)); +} From acc1764c46de464bfeb6fece16ed7a652df00ff3 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sat, 5 Sep 2026 23:06:06 -0300 Subject: [PATCH 2/4] docs(worker): state the dispatch table size constraint precisely --- NativeScript/runtime/Worker.mm | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index 233b2935..b3ccb801 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -155,8 +155,9 @@ bool ReadMegabyteLimit(Isolate* isolate, Local context, Local r return true; } -// The JS dispatch table is reserved as one contiguous range at isolate -// creation, so V8 only accepts whole megabytes up to its own hard ceiling. +// V8 needs the reservation to be a whole number of table segments and no larger +// than its compile-time maximum; whole megabytes satisfy the first on every +// platform's segment size, and 256 is the maximum. constexpr double kMaxJsDispatchTableSizeMb = 256; #ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM From 77bb4c285d843ba44591cb6e1729ae390693777e Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sun, 6 Sep 2026 15:54:30 -0300 Subject: [PATCH 3/4] test(worker): cover oversized heap caps and throwing resourceLimits getters --- .../app/tests/WorkerResourceLimitsTests.js | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/TestRunner/app/tests/WorkerResourceLimitsTests.js b/TestRunner/app/tests/WorkerResourceLimitsTests.js index ec09269b..1225d845 100644 --- a/TestRunner/app/tests/WorkerResourceLimitsTests.js +++ b/TestRunner/app/tests/WorkerResourceLimitsTests.js @@ -99,6 +99,37 @@ describe("Worker resourceLimits", function () { }).toThrowError(RangeError, /"resourceLimits\.maxYoungGenerationSizeMb"/); }); + it("throws a RangeError for a maxOldGenerationSizeMb too large to hold in bytes", function () { + expect(function () { + new Worker(echoEntry, { resourceLimits: { maxOldGenerationSizeMb: Number.MAX_VALUE } }); + }).toThrowError(RangeError, /"resourceLimits\.maxOldGenerationSizeMb"/); + }); + + it("throws a RangeError for a maxYoungGenerationSizeMb below one byte", function () { + expect(function () { + new Worker(echoEntry, { resourceLimits: { maxYoungGenerationSizeMb: 1e-9 } }); + }).toThrowError(RangeError, /"resourceLimits\.maxYoungGenerationSizeMb"/); + }); + + it("propagates the error thrown by a resourceLimits getter", function () { + var boom = new Error("boom"); + var options = { resourceLimits: new Proxy({}, { + get: function (target, key) { + if (key === "maxOldGenerationSizeMb") { + throw boom; + } + return undefined; + } + }) }; + var thrown; + try { + new Worker(echoEntry, options); + } catch (e) { + thrown = e; + } + expect(thrown).toBe(boom); + }); + it("throws a RangeError for a jsDispatchTableSizeMb above the ceiling", function () { expect(function () { new Worker(echoEntry, { resourceLimits: { jsDispatchTableSizeMb: 300 } }); From 94206947929f53102d7cf1a517900504271d241c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sun, 6 Sep 2026 15:59:25 -0300 Subject: [PATCH 4/4] chore(v8): pin v8-14.9.207.39-7, which carries the JS dispatch table reservation parameter Enables the jsDispatchTableSizeMb path and the 64 MB worker default that were compiled behind V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM. --- TestRunner/app/tests/WorkerResourceLimitsTests.js | 8 +++++--- V8_RELEASE | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/TestRunner/app/tests/WorkerResourceLimitsTests.js b/TestRunner/app/tests/WorkerResourceLimitsTests.js index 1225d845..236ef4e8 100644 --- a/TestRunner/app/tests/WorkerResourceLimitsTests.js +++ b/TestRunner/app/tests/WorkerResourceLimitsTests.js @@ -142,9 +142,11 @@ describe("Worker resourceLimits", function () { }).toThrowError(RangeError, /"resourceLimits\.jsDispatchTableSizeMb"/); }); - // Enable once the prebuilt V8 carries the JS dispatch table reservation - // parameter; until then the option is rejected as unsupported. - xit("starts a worker under a jsDispatchTableSizeMb reservation", function (done) { + it("starts a worker under a jsDispatchTableSizeMb reservation", function (done) { expectStarts({ resourceLimits: { jsDispatchTableSizeMb: 64 } }, done); }); + + it("starts a worker under the smallest jsDispatchTableSizeMb reservation", function (done) { + expectStarts({ resourceLimits: { jsDispatchTableSizeMb: 1 } }, done); + }); }); diff --git a/V8_RELEASE b/V8_RELEASE index 1449e4b7..ac872018 100644 --- a/V8_RELEASE +++ b/V8_RELEASE @@ -1 +1 @@ -v8-14.9.207.39-6 +v8-14.9.207.39-7