Skip to content

Commit 0ffff1b

Browse files
authored
feat(worker): resourceLimits for worker isolates (#2042)
* feat: resourceLimits for worker isolates Adds Node's `resourceLimits` worker option, at the top level of the options object rather than under `android`: 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. A worker's isolate is created on the worker thread deep inside the Java initWorkerRuntime call, a path with no parameter to carry them, so WorkerWrapper leaves them -- together with the near-heap-limit callback -- in a thread-local slot that PrepareV8Runtime consumes; the main isolate never sets one and is unaffected. 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, which v8-14.9.207.39-7 carries. Worker isolates default to a 64 MB reservation instead of V8's 256 MB, and the main isolate keeps the default; against an older prebuilt the option is rejected as unsupported. 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 once the worker's runtime is up, and a worker that exhausts its heap can do so while its entry is still loading. Building the exception detail for a terminating isolate could crash on the empty v8::Message such an isolate reports, so GetFullMessage now returns the plain JS message when there is none. * fix: cap resourceLimits to the platform's size_t range
1 parent 34fd3ce commit 0ffff1b

11 files changed

Lines changed: 515 additions & 59 deletions

‎V8_RELEASE‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v8-14.9.207.39-6
1+
v8-14.9.207.39-7

‎test-app/app/src/main/assets/app/mainpage.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ require("./tests/testEventLoop");
2828
require("./tests/testMultithreadedJavascript");
2929
require("./tests/testWorkerTerminateDuringLoad");
3030
require("./tests/testWorkerOptions");
31+
require("./tests/testWorkerResourceLimits");
3132
require("./tests/testInterfaceDefaultMethods");
3233
require("./tests/testInterfaceStaticMethods");
3334
require("./tests/testMetadata");
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
describe("Worker resourceLimits", function () {
2+
var echoEntry = "./workerResourceLimitsEchoWorker.js";
3+
var oomEntry = "./workerResourceLimitsOomWorker.js";
4+
5+
// Jasmine arms a spec's async timeout before calling it, so the interval
6+
// has to be raised ahead of the spec, not inside it.
7+
var originalTimeout;
8+
beforeEach(function () {
9+
originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL;
10+
jasmine.DEFAULT_TIMEOUT_INTERVAL = 60000;
11+
});
12+
afterEach(function () {
13+
jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout;
14+
});
15+
16+
var expectStarts = function (options, done) {
17+
var worker = options === undefined ? new Worker(echoEntry) : new Worker(echoEntry, options);
18+
var settled = false;
19+
var finish = function () {
20+
if (settled) {
21+
return;
22+
}
23+
settled = true;
24+
worker.terminate();
25+
done();
26+
};
27+
worker.onmessage = function (msg) {
28+
expect(msg.data.started).toBe(true);
29+
finish();
30+
};
31+
worker.onerror = function (e) {
32+
expect(String(e && e.message ? e.message : e)).toBe("<no worker error>");
33+
finish();
34+
};
35+
};
36+
37+
it("starts a worker under a maxYoungGenerationSizeMb cap", function (done) {
38+
expectStarts({ resourceLimits: { maxYoungGenerationSizeMb: 8 } }, done);
39+
});
40+
41+
it("starts a worker under both heap caps", function (done) {
42+
expectStarts({ resourceLimits: { maxOldGenerationSizeMb: 64, maxYoungGenerationSizeMb: 8 } },
43+
done);
44+
});
45+
46+
it("treats resourceLimits: null like an absent resourceLimits", function (done) {
47+
expectStarts({ resourceLimits: null }, done);
48+
});
49+
50+
it("ignores unknown keys inside resourceLimits", function (done) {
51+
expectStarts({ resourceLimits: { maxOldGenerationSizeMb: 64, somethingElse: 42 } }, done);
52+
});
53+
54+
it("reports a worker that runs out of heap through onerror", function (done) {
55+
var worker = new Worker(oomEntry, { resourceLimits: { maxOldGenerationSizeMb: 32 } });
56+
var settled = false;
57+
58+
// The entry never returns, so nothing can post: a message here means the
59+
// cap was not applied at all.
60+
worker.onmessage = function () {
61+
expect("worker posted a message").toBe("worker exhausted its heap");
62+
};
63+
64+
worker.onerror = function (e) {
65+
if (settled) {
66+
return;
67+
}
68+
settled = true;
69+
var message = String(e && e.message ? e.message : e);
70+
expect(message).toMatch(/out of memory/i);
71+
// Naming the cap is what tells this apart from any other failure
72+
// the worker could have reported.
73+
expect(message).toMatch(/maxOldGenerationSizeMb: 32/);
74+
worker.terminate();
75+
done();
76+
};
77+
});
78+
79+
it("throws a TypeError when resourceLimits is not an object", function () {
80+
expect(function () {
81+
new Worker(echoEntry, { resourceLimits: 5 });
82+
}).toThrowError(TypeError, /"resourceLimits"/);
83+
});
84+
85+
it("throws a TypeError for a non-numeric maxOldGenerationSizeMb", function () {
86+
expect(function () {
87+
new Worker(echoEntry, { resourceLimits: { maxOldGenerationSizeMb: "64" } });
88+
}).toThrowError(TypeError, /"resourceLimits\.maxOldGenerationSizeMb"/);
89+
});
90+
91+
it("throws a RangeError for a maxOldGenerationSizeMb of zero", function () {
92+
expect(function () {
93+
new Worker(echoEntry, { resourceLimits: { maxOldGenerationSizeMb: 0 } });
94+
}).toThrowError(RangeError, /"resourceLimits\.maxOldGenerationSizeMb"/);
95+
});
96+
97+
it("throws a RangeError for a NaN maxYoungGenerationSizeMb", function () {
98+
expect(function () {
99+
new Worker(echoEntry, { resourceLimits: { maxYoungGenerationSizeMb: NaN } });
100+
}).toThrowError(RangeError, /"resourceLimits\.maxYoungGenerationSizeMb"/);
101+
});
102+
103+
// The cap is derived from size_t, so it differs per ABI: 4095 MB where
104+
// size_t is 32 bits (armeabi-v7a, x86), (2^44 - 1) MB where it is 64. The
105+
// value has to sit above both for the spec to mean anything on every ABI.
106+
it("throws a RangeError for a maxOldGenerationSizeMb too large to hold in bytes", function () {
107+
expect(function () {
108+
new Worker(echoEntry, { resourceLimits: { maxOldGenerationSizeMb: Math.pow(2, 53) } });
109+
}).toThrowError(RangeError, /"resourceLimits\.maxOldGenerationSizeMb"/);
110+
});
111+
112+
it("throws a RangeError for a maxYoungGenerationSizeMb below one byte", function () {
113+
expect(function () {
114+
new Worker(echoEntry, { resourceLimits: { maxYoungGenerationSizeMb: 1e-9 } });
115+
}).toThrowError(RangeError, /"resourceLimits\.maxYoungGenerationSizeMb"/);
116+
});
117+
118+
it("propagates the error thrown by a resourceLimits getter", function () {
119+
var boom = new Error("boom");
120+
var options = { resourceLimits: new Proxy({}, {
121+
get: function (target, key) {
122+
if (key === "maxOldGenerationSizeMb") {
123+
throw boom;
124+
}
125+
return undefined;
126+
}
127+
}) };
128+
var thrown;
129+
try {
130+
new Worker(echoEntry, options);
131+
} catch (e) {
132+
thrown = e;
133+
}
134+
expect(thrown).toBe(boom);
135+
});
136+
137+
it("throws a RangeError for a jsDispatchTableSizeMb above the ceiling", function () {
138+
expect(function () {
139+
new Worker(echoEntry, { resourceLimits: { jsDispatchTableSizeMb: 300 } });
140+
}).toThrowError(RangeError, /"resourceLimits\.jsDispatchTableSizeMb"/);
141+
});
142+
143+
it("throws a RangeError for a fractional jsDispatchTableSizeMb", function () {
144+
expect(function () {
145+
new Worker(echoEntry, { resourceLimits: { jsDispatchTableSizeMb: 1.5 } });
146+
}).toThrowError(RangeError, /"resourceLimits\.jsDispatchTableSizeMb"/);
147+
});
148+
149+
it("starts a worker under a jsDispatchTableSizeMb reservation", function (done) {
150+
expectStarts({ resourceLimits: { jsDispatchTableSizeMb: 64 } }, done);
151+
});
152+
153+
it("starts a worker under the smallest jsDispatchTableSizeMb reservation", function (done) {
154+
expectStarts({ resourceLimits: { jsDispatchTableSizeMb: 1 } }, done);
155+
});
156+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Entry for testWorkerResourceLimits: reports that the isolate came up under
2+
// whatever resourceLimits the parent passed.
3+
postMessage({ started: true });
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Entry for testWorkerResourceLimits: allocates and never releases, so the
2+
// isolate walks into the maxOldGenerationSizeMb cap the parent set.
3+
var keep = [];
4+
for (;;) {
5+
keep.push(new Array(100000).fill(1));
6+
}

‎test-app/runtime/src/main/cpp/CallbackHandlers.cpp‎

Lines changed: 148 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
#include "JsArgToArrayConverter.h"
1111
#include "ArgConverter.h"
1212
#include "v8-profiler.h"
13+
#include <cmath>
1314
#include <iostream>
15+
#include <limits>
16+
#include <optional>
1417
#include <sstream>
1518
#include <fstream>
1619
#include <cstdio>
@@ -1172,6 +1175,18 @@ std::optional<int> ClampWorkerPriority(Local<Value> value) {
11721175
throw NativeScriptException(isolate, error, message);
11731176
}
11741177

1178+
[[noreturn]] void ThrowWorkerOptionRangeError(Isolate *isolate, const std::string &message) {
1179+
Local<Value> error = Exception::RangeError(ArgConverter::ConvertToV8String(isolate, message));
1180+
throw NativeScriptException(isolate, error, message);
1181+
}
1182+
1183+
#ifndef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM
1184+
[[noreturn]] void ThrowWorkerOptionError(Isolate *isolate, const std::string &message) {
1185+
Local<Value> error = Exception::Error(ArgConverter::ConvertToV8String(isolate, message));
1186+
throw NativeScriptException(isolate, error, message);
1187+
}
1188+
#endif
1189+
11751190
// Reads `key` from `object`. A false return means the getter threw: the
11761191
// exception is already pending on the isolate and construction must stop
11771192
// without running anything else on it.
@@ -1265,6 +1280,128 @@ bool GetWorkerThreadPriority(Isolate *isolate, Local<Context> context,
12651280
kWorkerPriorityNames + ".");
12661281
}
12671282

1283+
constexpr double kBytesPerMegabyte = 1024 * 1024;
1284+
// Bounds the double-to-size_t conversion below: converting a byte count that
1285+
// does not fit size_t is undefined, and size_t is 32 bits on armeabi-v7a and
1286+
// x86. The division floors, so the product always fits. v8 clamps heap sizes
1287+
// far under this on every device, so nothing real is excluded.
1288+
constexpr size_t kMaxLimitMegabytes =
1289+
std::numeric_limits<size_t>::max() / static_cast<size_t>(kBytesPerMegabyte);
1290+
1291+
// v8 needs the reservation to be a whole number of table segments and no larger
1292+
// than its compile-time maximum; whole megabytes satisfy the first on every
1293+
// platform's segment size, and 256 is the maximum.
1294+
constexpr double kMaxJsDispatchTableSizeMb = 256;
1295+
1296+
#ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM
1297+
// Every isolate otherwise reserves 256 MB of address space for its JS dispatch
1298+
// table; 64 MB still holds four million dispatch entries, far more than a
1299+
// worker allocates. Only workers get the smaller reservation - the main
1300+
// isolate keeps v8's default.
1301+
constexpr size_t kDefaultWorkerJsDispatchTableBytes = 64 * 1024 * 1024;
1302+
#endif
1303+
1304+
// Reads one megabyte-valued `resourceLimits` key into `megabytes`, leaving it
1305+
// empty when the key is absent (v8's own default stays in place). Returns false
1306+
// when the getter threw (see ReadWorkerOption). A present value must be a
1307+
// finite number worth at least one byte and at most kMaxLimitMegabytes.
1308+
bool ReadMegabyteLimit(Isolate *isolate, Local<Context> context, Local<Object> resourceLimits,
1309+
const char *key, std::optional<double> &megabytes) {
1310+
Local<Value> value;
1311+
if (!ReadWorkerOption(isolate, context, resourceLimits, key, value)) {
1312+
return false;
1313+
}
1314+
if (value->IsUndefined()) {
1315+
return true;
1316+
}
1317+
1318+
std::string name = std::string("resourceLimits.") + key;
1319+
if (!value->IsNumber()) {
1320+
ThrowWorkerOptionTypeError(isolate, "Worker option \"" + name + "\" must be a number.");
1321+
}
1322+
1323+
double parsed = value.As<Number>()->Value();
1324+
if (!std::isfinite(parsed) || parsed * kBytesPerMegabyte < 1 ||
1325+
parsed > static_cast<double>(kMaxLimitMegabytes)) {
1326+
ThrowWorkerOptionRangeError(isolate,
1327+
"Worker option \"" + name +
1328+
"\" must be a finite number of megabytes worth at "
1329+
"least one byte and at most " +
1330+
std::to_string(kMaxLimitMegabytes) + ".");
1331+
}
1332+
1333+
megabytes = parsed;
1334+
return true;
1335+
}
1336+
1337+
/*
1338+
* Node's `resourceLimits` shape. Unknown keys are ignored, so the options Node
1339+
* has and this runtime cannot honor (codeRangeSizeMb, stackSizeMb) stay
1340+
* harmless to pass. Returns false when a getter threw (see ReadWorkerOption).
1341+
*/
1342+
bool ParseWorkerResourceLimits(Isolate *isolate, Local<Context> context,
1343+
const v8::FunctionCallbackInfo<v8::Value> &args,
1344+
IsolateLimits &limits) {
1345+
if (args.Length() < 2 || !args[1]->IsObject()) {
1346+
return true;
1347+
}
1348+
1349+
Local<Value> value;
1350+
if (!ReadWorkerOption(isolate, context, args[1].As<Object>(), "resourceLimits", value)) {
1351+
return false;
1352+
}
1353+
if (value->IsNullOrUndefined()) {
1354+
return true;
1355+
}
1356+
if (!value->IsObject()) {
1357+
ThrowWorkerOptionTypeError(isolate, "Worker option \"resourceLimits\" must be an object.");
1358+
}
1359+
Local<Object> resourceLimits = value.As<Object>();
1360+
1361+
std::optional<double> megabytes;
1362+
1363+
if (!ReadMegabyteLimit(isolate, context, resourceLimits, "maxOldGenerationSizeMb", megabytes)) {
1364+
return false;
1365+
}
1366+
if (megabytes) {
1367+
limits.maxOldGenerationSizeBytes =
1368+
static_cast<size_t>(*megabytes * kBytesPerMegabyte);
1369+
}
1370+
1371+
megabytes.reset();
1372+
if (!ReadMegabyteLimit(isolate, context, resourceLimits, "maxYoungGenerationSizeMb",
1373+
megabytes)) {
1374+
return false;
1375+
}
1376+
if (megabytes) {
1377+
limits.maxYoungGenerationSizeBytes =
1378+
static_cast<size_t>(*megabytes * kBytesPerMegabyte);
1379+
}
1380+
1381+
megabytes.reset();
1382+
if (!ReadMegabyteLimit(isolate, context, resourceLimits, "jsDispatchTableSizeMb", megabytes)) {
1383+
return false;
1384+
}
1385+
if (megabytes) {
1386+
if (*megabytes != std::floor(*megabytes) || *megabytes < 1 ||
1387+
*megabytes > kMaxJsDispatchTableSizeMb) {
1388+
ThrowWorkerOptionRangeError(
1389+
isolate, "Worker option \"resourceLimits.jsDispatchTableSizeMb\" must be a "
1390+
"whole number of megabytes between 1 and 256.");
1391+
}
1392+
#ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM
1393+
limits.jsDispatchTableReservationBytes =
1394+
static_cast<size_t>(*megabytes) * static_cast<size_t>(kBytesPerMegabyte);
1395+
#else
1396+
ThrowWorkerOptionError(isolate,
1397+
"Worker option \"resourceLimits.jsDispatchTableSizeMb\" requires a "
1398+
"v8 build with a configurable JS dispatch table.");
1399+
#endif
1400+
}
1401+
1402+
return true;
1403+
}
1404+
12681405
} // namespace
12691406

12701407
void CallbackHandlers::NewThreadCallback(const v8::FunctionCallbackInfo<v8::Value> &args) {
@@ -1322,10 +1459,18 @@ void CallbackHandlers::NewThreadCallback(const v8::FunctionCallbackInfo<v8::Valu
13221459
}
13231460

13241461
int priority;
1325-
if (!GetWorkerThreadPriority(isolate, context, args, priority)) {
1462+
IsolateLimits resourceLimits;
1463+
if (!GetWorkerThreadPriority(isolate, context, args, priority) ||
1464+
!ParseWorkerResourceLimits(isolate, context, args, resourceLimits)) {
13261465
return;
13271466
}
13281467

1468+
#ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM
1469+
if (!resourceLimits.jsDispatchTableReservationBytes.has_value()) {
1470+
resourceLimits.jsDispatchTableReservationBytes = kDefaultWorkerJsDispatchTableBytes;
1471+
}
1472+
#endif
1473+
13291474
// An http(s) entry has no filesystem form to validate or to resolve
13301475
// against the caller's directory: it is already absolute, and the
13311476
// module loader's HTTP branch fetches it on the worker's own thread
@@ -1401,8 +1546,8 @@ void CallbackHandlers::NewThreadCallback(const v8::FunctionCallbackInfo<v8::Valu
14011546
// here on the main thread where class loading is safe.
14021547
WorkerWrapper::EnsureJniCached();
14031548

1404-
auto wrapper = std::make_shared<WorkerWrapper>(isolate, workerId, entryPath,
1405-
currentDir, priority, thiz);
1549+
auto wrapper = std::make_shared<WorkerWrapper>(isolate, workerId, entryPath, currentDir,
1550+
priority, std::move(resourceLimits), thiz);
14061551
WorkerWrapper::Insert(workerId, wrapper);
14071552

14081553
DEBUG_WRITE("Called Worker constructor id=%d", workerId);

‎test-app/runtime/src/main/cpp/NativeScriptException.cpp‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,11 @@ string NativeScriptException::GetFullMessage(const TryCatch& tc,
10391039
v8::Local<v8::Context> context = isolate->GetEnteredOrMicrotaskContext();
10401040

10411041
auto message = tc.Message();
1042+
// An isolate v8 has been told to terminate hands back an exception with no
1043+
// v8::Message at all, and every read below needs a real handle.
1044+
if (message.IsEmpty()) {
1045+
return jsExceptionMessage;
1046+
}
10421047

10431048
stringstream ss;
10441049
ss << jsExceptionMessage;

0 commit comments

Comments
 (0)