diff --git a/V8_RELEASE b/V8_RELEASE index 1449e4b73..ac872018f 100644 --- a/V8_RELEASE +++ b/V8_RELEASE @@ -1 +1 @@ -v8-14.9.207.39-6 +v8-14.9.207.39-7 diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index 0473d207f..62aeb52eb 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -28,6 +28,7 @@ require("./tests/testEventLoop"); require("./tests/testMultithreadedJavascript"); require("./tests/testWorkerTerminateDuringLoad"); require("./tests/testWorkerOptions"); +require("./tests/testWorkerResourceLimits"); require("./tests/testInterfaceDefaultMethods"); require("./tests/testInterfaceStaticMethods"); require("./tests/testMetadata"); diff --git a/test-app/app/src/main/assets/app/tests/testWorkerResourceLimits.js b/test-app/app/src/main/assets/app/tests/testWorkerResourceLimits.js new file mode 100644 index 000000000..6dfeac03f --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testWorkerResourceLimits.js @@ -0,0 +1,156 @@ +describe("Worker resourceLimits", function () { + var echoEntry = "./workerResourceLimitsEchoWorker.js"; + var oomEntry = "./workerResourceLimitsOomWorker.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 = 60000; + }); + 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"/); + }); + + // The cap is derived from size_t, so it differs per ABI: 4095 MB where + // size_t is 32 bits (armeabi-v7a, x86), (2^44 - 1) MB where it is 64. The + // value has to sit above both for the spec to mean anything on every ABI. + it("throws a RangeError for a maxOldGenerationSizeMb too large to hold in bytes", function () { + expect(function () { + new Worker(echoEntry, { resourceLimits: { maxOldGenerationSizeMb: Math.pow(2, 53) } }); + }).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 } }); + }).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"/); + }); + + 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/test-app/app/src/main/assets/app/tests/workerResourceLimitsEchoWorker.js b/test-app/app/src/main/assets/app/tests/workerResourceLimitsEchoWorker.js new file mode 100644 index 000000000..113f5fed5 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/workerResourceLimitsEchoWorker.js @@ -0,0 +1,3 @@ +// Entry for testWorkerResourceLimits: reports that the isolate came up under +// whatever resourceLimits the parent passed. +postMessage({ started: true }); diff --git a/test-app/app/src/main/assets/app/tests/workerResourceLimitsOomWorker.js b/test-app/app/src/main/assets/app/tests/workerResourceLimitsOomWorker.js new file mode 100644 index 000000000..3c18bdf6b --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/workerResourceLimitsOomWorker.js @@ -0,0 +1,6 @@ +// Entry for testWorkerResourceLimits: 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)); +} diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index 381728a72..8d2b18135 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -10,7 +10,10 @@ #include "JsArgToArrayConverter.h" #include "ArgConverter.h" #include "v8-profiler.h" +#include #include +#include +#include #include #include #include @@ -1172,6 +1175,18 @@ std::optional ClampWorkerPriority(Local value) { throw NativeScriptException(isolate, error, message); } +[[noreturn]] void ThrowWorkerOptionRangeError(Isolate *isolate, const std::string &message) { + Local error = Exception::RangeError(ArgConverter::ConvertToV8String(isolate, message)); + throw NativeScriptException(isolate, error, message); +} + +#ifndef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM +[[noreturn]] void ThrowWorkerOptionError(Isolate *isolate, const std::string &message) { + Local error = Exception::Error(ArgConverter::ConvertToV8String(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. @@ -1265,6 +1280,128 @@ bool GetWorkerThreadPriority(Isolate *isolate, Local context, kWorkerPriorityNames + "."); } +constexpr double kBytesPerMegabyte = 1024 * 1024; +// Bounds the double-to-size_t conversion below: converting a byte count that +// does not fit size_t is undefined, and size_t is 32 bits on armeabi-v7a and +// x86. The division floors, so the product always fits. v8 clamps heap sizes +// far under this on every device, so nothing real is excluded. +constexpr size_t kMaxLimitMegabytes = + std::numeric_limits::max() / static_cast(kBytesPerMegabyte); + +// 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 +// Every isolate otherwise reserves 256 MB of address space for its JS dispatch +// table; 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 + +// Reads one megabyte-valued `resourceLimits` key into `megabytes`, leaving it +// empty when the key is absent (v8's own default stays in place). Returns false +// when the getter threw (see ReadWorkerOption). A present value must be a +// finite number worth at least one byte and at most kMaxLimitMegabytes. +bool ReadMegabyteLimit(Isolate *isolate, Local context, Local resourceLimits, + const char *key, std::optional &megabytes) { + Local value; + if (!ReadWorkerOption(isolate, context, resourceLimits, key, value)) { + return false; + } + if (value->IsUndefined()) { + return true; + } + + std::string name = std::string("resourceLimits.") + key; + if (!value->IsNumber()) { + ThrowWorkerOptionTypeError(isolate, "Worker option \"" + name + "\" must be a number."); + } + + double parsed = value.As()->Value(); + if (!std::isfinite(parsed) || parsed * kBytesPerMegabyte < 1 || + parsed > static_cast(kMaxLimitMegabytes)) { + ThrowWorkerOptionRangeError(isolate, + "Worker option \"" + name + + "\" must be a finite number of megabytes worth at " + "least one byte and at most " + + std::to_string(kMaxLimitMegabytes) + "."); + } + + megabytes = parsed; + return true; +} + +/* + * 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 ReadWorkerOption). + */ +bool ParseWorkerResourceLimits(Isolate *isolate, Local context, + const v8::FunctionCallbackInfo &args, + IsolateLimits &limits) { + if (args.Length() < 2 || !args[1]->IsObject()) { + return true; + } + + Local value; + if (!ReadWorkerOption(isolate, context, args[1].As(), "resourceLimits", value)) { + return false; + } + if (value->IsNullOrUndefined()) { + return true; + } + if (!value->IsObject()) { + ThrowWorkerOptionTypeError(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) { + ThrowWorkerOptionRangeError( + 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 + ThrowWorkerOptionError(isolate, + "Worker option \"resourceLimits.jsDispatchTableSizeMb\" requires a " + "v8 build with a configurable JS dispatch table."); +#endif + } + + return true; +} + } // namespace void CallbackHandlers::NewThreadCallback(const v8::FunctionCallbackInfo &args) { @@ -1322,10 +1459,18 @@ void CallbackHandlers::NewThreadCallback(const v8::FunctionCallbackInfo(isolate, workerId, entryPath, - currentDir, priority, thiz); + auto wrapper = std::make_shared(isolate, workerId, entryPath, currentDir, + priority, std::move(resourceLimits), thiz); WorkerWrapper::Insert(workerId, wrapper); DEBUG_WRITE("Called Worker constructor id=%d", workerId); diff --git a/test-app/runtime/src/main/cpp/NativeScriptException.cpp b/test-app/runtime/src/main/cpp/NativeScriptException.cpp index d89ee6592..8d9f1e1e0 100644 --- a/test-app/runtime/src/main/cpp/NativeScriptException.cpp +++ b/test-app/runtime/src/main/cpp/NativeScriptException.cpp @@ -1039,6 +1039,11 @@ string NativeScriptException::GetFullMessage(const TryCatch& tc, v8::Local context = isolate->GetEnteredOrMicrotaskContext(); auto message = tc.Message(); + // 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; + } stringstream ss; ss << jsExceptionMessage; diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 493f1e7d6..d7ab015e8 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -777,9 +777,25 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, const bool forceLog) { tns::instrumentation::Frame frame("Runtime.PrepareV8Runtime"); + PendingIsolateSetup setup = std::exchange(s_pendingIsolateSetup, {}); + Isolate::CreateParams create_params; create_params.array_buffer_allocator = &g_allocator; + if (setup.limits.maxOldGenerationSizeBytes.has_value()) { + create_params.constraints.set_max_old_generation_size_in_bytes( + *setup.limits.maxOldGenerationSizeBytes); + } + if (setup.limits.maxYoungGenerationSizeBytes.has_value()) { + create_params.constraints.set_max_young_generation_size_in_bytes( + *setup.limits.maxYoungGenerationSizeBytes); + } +#ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM + if (setup.limits.jsDispatchTableReservationBytes.has_value()) { + create_params.js_dispatch_table_reservation_size = + *setup.limits.jsDispatchTableReservationBytes; + } +#endif // Also initializes V8 for the process if this runtime wins the election, and // otherwise waits for the runtime that did. @@ -811,6 +827,11 @@ Isolate* Runtime::PrepareV8Runtime(const string& filesPath, Isolate::Scope isolate_scope(isolate); HandleScope handleScope(isolate); + if (setup.nearHeapLimitCallback != nullptr) { + isolate->AddNearHeapLimitCallback(setup.nearHeapLimitCallback, + setup.nearHeapLimitData); + } + // Sets a structure with v8 String constants on the isolate object at slot 1 auto consts = new V8StringConstants::PerIsolateV8Constants(isolate); isolate->SetData((uint32_t)Runtime::IsolateData::RUNTIME, this); @@ -1232,6 +1253,11 @@ int Runtime::m_androidVersion = Runtime::GetAndroidVersion(); std::shared_ptr Runtime::s_mainEventLoop; thread_local Runtime* Runtime::s_currentRuntime = nullptr; +thread_local PendingIsolateSetup Runtime::s_pendingIsolateSetup; + +void Runtime::SetPendingIsolateSetup(PendingIsolateSetup setup) { + s_pendingIsolateSetup = std::move(setup); +} napi_env Runtime::GetNapiEnvIfAlive(const Runtime* runtime) { if (runtime == nullptr) { diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index f60f69e9f..22f0436fd 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -14,6 +14,7 @@ #include "EventLoop.h" #include #include +#include #include #include #include @@ -27,6 +28,31 @@ namespace tns { class PromiseRejectionTracker; class RuntimeState; +// 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; +}; + +/* + * What a worker's isolate needs at creation time. The isolate is created on + * the worker thread deep inside the Java initWorkerRuntime call, with no + * parameter to carry any of this, so WorkerWrapper leaves it in a thread-local + * slot immediately before that call and PrepareV8Runtime takes it. The main + * isolate never sets one and gets an empty setup. + */ +struct PendingIsolateSetup { + IsolateLimits limits; + // Armed before anything runs in the isolate: without it a worker that + // exhausts its heap aborts the whole process. + v8::NearHeapLimitCallback nearHeapLimitCallback = nullptr; + void* nearHeapLimitData = nullptr; +}; + class Runtime { public: enum IsolateData { @@ -49,6 +75,13 @@ class Runtime { ~Runtime(); + /* + * Arms the setup the next isolate created on THIS thread is given. + * Consumed by PrepareV8Runtime, so it has to be set on the thread that + * will create the isolate and immediately before creation. + */ + static void SetPendingIsolateSetup(PendingIsolateSetup setup); + static Runtime* GetRuntime(int runtimeId); static Runtime* GetRuntime(v8::Isolate* isolate); @@ -391,6 +424,7 @@ class Runtime { static std::shared_ptr s_mainEventLoop; static thread_local Runtime* s_currentRuntime; + static thread_local PendingIsolateSetup s_pendingIsolateSetup; #ifdef APPLICATION_IN_DEBUG std::mutex m_fileWriteMutex; diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index 4f4bf797d..04abc68fd 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -79,7 +79,7 @@ void ReportEntryRejection(Isolate* isolate, Local reason, } // namespace WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string workerPath, - std::string callingDir, int priority, + std::string callingDir, int priority, IsolateLimits limits, Local workerObject) : parentIsolate_(parentIsolate), // runs on the parent's thread, where the parent runtime is alive @@ -92,6 +92,7 @@ WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string w // workerPath_ (not workerPath) - the parameter was just moved from threadName_("W" + std::to_string(workerId) + ": " + workerPath_), priority_(priority), + limits_(std::move(limits)), // Runs on the parent's thread, so this is the parent's live vocabulary. inheritedVocabulary_(CaptureLoaderVocabulary(parentIsolate)), poWorker_(new Persistent(parentIsolate, workerObject)), @@ -99,7 +100,15 @@ WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string w isTerminating_(false), isDisposed_(false), messagesEnabled_(false), - javaLooperRef_(nullptr) {} + heapLimitExceeded_(false), + javaLooperRef_(nullptr) { + heapLimitMessage_ = "Worker JS heap out of memory"; + if (limits_.maxOldGenerationSizeBytes.has_value()) { + heapLimitMessage_ += " (maxOldGenerationSizeMb: " + + std::to_string(*limits_.maxOldGenerationSizeBytes / (1024 * 1024)) + + ")"; + } +} void WorkerWrapper::Start() { auto self = shared_from_this(); @@ -171,6 +180,38 @@ void WorkerWrapper::Terminate() { QuitLooper(); } +size_t WorkerWrapper::OnNearHeapLimit(void* data, size_t currentHeapLimit, + size_t initialHeapLimit) { + 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 = currentHeapLimit + 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->PassUncaughtExceptionFromWorkerToParent(worker->heapLimitMessage_, worker->workerPath_, + "", 0); + + // Terminate() below only reaches workerIsolate_, which the bootstrap + // publishes once the runtime is up - and a worker can exhaust its heap + // before that. The isolate being collected is the one to interrupt. + Isolate* isolate = Isolate::GetCurrent(); + if (isolate != nullptr) { + isolate->TerminateExecution(); + } + worker->Terminate(); + + return raisedLimit; +} + void WorkerWrapper::Close() { bool wasClosing = isClosing_.exchange(true); if (wasClosing) { @@ -429,6 +470,13 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { // per-worker com.tns.Runtime (which creates the worker isolate on // this thread via initNativeScript -> PrepareV8Runtime). JniLocalRef callingDir(env.NewStringUTF(callingDir_.c_str())); + // The isolate is created on this thread inside the call below, + // which has no parameter to carry any of this; the thread-local + // slot is the channel. The near-heap-limit callback is armed for + // every worker, capped or not - without it an isolate that + // exhausts its heap aborts the whole process instead of surfacing + // on the parent's Worker object. + Runtime::SetPendingIsolateSetup({limits_, WorkerWrapper::OnNearHeapLimit, this}); runtimeId = env.CallStaticIntMethod(RUNTIME_CLASS, INIT_WORKER_RUNTIME_METHOD_ID, workerId_, (jstring) callingDir); runtime_ = Runtime::GetRuntime(runtimeId); @@ -478,60 +526,65 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { if (!isTerminating_) { runtime_->RunWorker(workerPath_); - // WHATWG parity: enable the implicit port's message queue - // once the entry has finished evaluating. RunWorker returns - // settled for classic scripts and pumped HTTP entries; a - // local top-level-await entry that outlived its settle - // window enables when its evaluation promise settles — - // rejected included, since a broken worker still drains its - // inbox into a listenerless global, as on the web. - Local pendingEntry; - if (!ModuleInternal::PendingEntryEvaluation(isolate, workerPath_) - .ToLocal(&pendingEntry)) { - EnableMessageQueue(); - } else { - // Neither handler may capture anything: they resolve the - // wrapper by id because the worker may be gone by the - // time the entry settles. Both run on this thread, in - // this isolate. - auto onFulfilled = [](const v8::FunctionCallbackInfo& info) { - auto wrapper = WorkerWrapper::GetById( - info.Data().As()->Value()); - if (wrapper != nullptr) { + // 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 (!HeapLimitExceeded()) { + // WHATWG parity: enable the implicit port's message queue + // once the entry has finished evaluating. RunWorker returns + // settled for classic scripts and pumped HTTP entries; a + // local top-level-await entry that outlived its settle + // window enables when its evaluation promise settles — + // rejected included, since a broken worker still drains its + // inbox into a listenerless global, as on the web. + Local pendingEntry; + if (!ModuleInternal::PendingEntryEvaluation(isolate, workerPath_) + .ToLocal(&pendingEntry)) { + EnableMessageQueue(); + } else { + // Neither handler may capture anything: they resolve the + // wrapper by id because the worker may be gone by the + // time the entry settles. Both run on this thread, in + // this isolate. + auto onFulfilled = [](const v8::FunctionCallbackInfo& info) { + auto wrapper = WorkerWrapper::GetById( + info.Data().As()->Value()); + if (wrapper != nullptr) { + wrapper->EnableMessageQueue(); + } + }; + // A rejection needs its own handler: sharing the fulfill + // one would mark the entry's evaluation promise handled + // and drop the failure on the floor. + auto onRejected = [](const v8::FunctionCallbackInfo& info) { + auto wrapper = WorkerWrapper::GetById( + info.Data().As()->Value()); + if (wrapper == nullptr) { + return; + } wrapper->EnableMessageQueue(); + if (wrapper->IsTerminating() || wrapper->IsDisposed()) { + return; + } + auto isolate = info.GetIsolate(); + ReportEntryRejection(isolate, + info.Length() > 0 + ? info[0] + : Undefined(isolate).As(), + wrapper); + }; + auto workerIdData = v8::Integer::New(isolate, workerId_); + Local enableFn; + Local reportFn; + if (Function::New(context, onFulfilled, workerIdData) + .ToLocal(&enableFn) && + Function::New(context, onRejected, workerIdData) + .ToLocal(&reportFn)) { + pendingEntry->Then(context, enableFn, reportFn) + .FromMaybe(Local()); + } else { + EnableMessageQueue(); } - }; - // A rejection needs its own handler: sharing the fulfill - // one would mark the entry's evaluation promise handled - // and drop the failure on the floor. - auto onRejected = [](const v8::FunctionCallbackInfo& info) { - auto wrapper = WorkerWrapper::GetById( - info.Data().As()->Value()); - if (wrapper == nullptr) { - return; - } - wrapper->EnableMessageQueue(); - if (wrapper->IsTerminating() || wrapper->IsDisposed()) { - return; - } - auto isolate = info.GetIsolate(); - ReportEntryRejection(isolate, - info.Length() > 0 - ? info[0] - : Undefined(isolate).As(), - wrapper); - }; - auto workerIdData = v8::Integer::New(isolate, workerId_); - Local enableFn; - Local reportFn; - if (Function::New(context, onFulfilled, workerIdData) - .ToLocal(&enableFn) && - Function::New(context, onRejected, workerIdData) - .ToLocal(&reportFn)) { - pendingEntry->Then(context, enableFn, reportFn) - .FromMaybe(Local()); - } else { - EnableMessageQueue(); } } } @@ -609,6 +662,9 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { v8::Locker locker(isolate); Isolate::Scope isolate_scope(isolate); HandleScope handle_scope(isolate); + // v8 keeps the registration until the isolate is disposed, and a + // final GC during disposal would find `this` mid-teardown. + isolate->RemoveNearHeapLimitCallback(WorkerWrapper::OnNearHeapLimit, 0); runtime_->DestroyRuntime(); } isolate->Dispose(); diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.h b/test-app/runtime/src/main/cpp/WorkerWrapper.h index 3b7be441a..5dd3b2fa8 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.h +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.h @@ -17,6 +17,7 @@ #include "ConcurrentQueue.h" #include "ModuleInternalCallbacks.h" +#include "Runtime.h" #include "WorkerMessage.h" #include "v8.h" @@ -45,7 +46,7 @@ class WorkerInspectorClient; class WorkerWrapper : public std::enable_shared_from_this { public: WorkerWrapper(v8::Isolate* parentIsolate, int workerId, std::string workerPath, - std::string callingDir, int priority, + std::string callingDir, int priority, IsolateLimits limits, v8::Local workerObject); int WorkerId() const { return workerId_; } @@ -105,6 +106,14 @@ class WorkerWrapper : public std::enable_shared_from_this { */ void EnableMessageQueue(); + /* + * Whether the worker's heap cap was hit. The bootstrap checks it to stop + * before running anything else in an isolate v8 is terminating. + */ + bool HeapLimitExceeded() const { + return heapLimitExceeded_.load(std::memory_order_acquire); + } + /* * Registry of live workers, keyed by workerId. Replaces the old * CallbackHandlers::id2WorkerMap. Guarded by a mutex because the worker @@ -180,6 +189,7 @@ class WorkerWrapper : public std::enable_shared_from_this { const std::string callingDir_; const std::string threadName_; const int priority_; + const IsolateLimits limits_; // The parent's loader vocabulary, copied on the parent's thread when this // wrapper is constructed and installed on the worker's own isolate before @@ -199,6 +209,20 @@ class WorkerWrapper : public std::enable_shared_from_this { // belt-and-braces, not a cross-thread channel. std::atomic_bool messagesEnabled_; + // Reporting material for OnNearHeapLimit, built on the parent's thread: + // the callback runs inside a GC, where nothing may be allocated on the + // worker isolate and no handle may be created. + std::string heapLimitMessage_; + std::atomic_bool heapLimitExceeded_; + + /* + * Runs on the worker thread from inside a GC. Reports the exhausted heap + * to the parent, asks v8 to terminate this isolate and hands back a raised + * limit so the in-progress GC can finish. + */ + static size_t OnNearHeapLimit(void* data, size_t currentHeapLimit, + size_t initialHeapLimit); + ConcurrentQueue queue_; std::mutex looperMutex_;