Skip to content

Commit 458a774

Browse files
committed
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.
1 parent 557139c commit 458a774

11 files changed

Lines changed: 506 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: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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+
it("throws a RangeError for a maxOldGenerationSizeMb too large to hold in bytes", function () {
104+
expect(function () {
105+
new Worker(echoEntry, { resourceLimits: { maxOldGenerationSizeMb: Number.MAX_VALUE } });
106+
}).toThrowError(RangeError, /"resourceLimits\.maxOldGenerationSizeMb"/);
107+
});
108+
109+
it("throws a RangeError for a maxYoungGenerationSizeMb below one byte", function () {
110+
expect(function () {
111+
new Worker(echoEntry, { resourceLimits: { maxYoungGenerationSizeMb: 1e-9 } });
112+
}).toThrowError(RangeError, /"resourceLimits\.maxYoungGenerationSizeMb"/);
113+
});
114+
115+
it("propagates the error thrown by a resourceLimits getter", function () {
116+
var boom = new Error("boom");
117+
var options = { resourceLimits: new Proxy({}, {
118+
get: function (target, key) {
119+
if (key === "maxOldGenerationSizeMb") {
120+
throw boom;
121+
}
122+
return undefined;
123+
}
124+
}) };
125+
var thrown;
126+
try {
127+
new Worker(echoEntry, options);
128+
} catch (e) {
129+
thrown = e;
130+
}
131+
expect(thrown).toBe(boom);
132+
});
133+
134+
it("throws a RangeError for a jsDispatchTableSizeMb above the ceiling", function () {
135+
expect(function () {
136+
new Worker(echoEntry, { resourceLimits: { jsDispatchTableSizeMb: 300 } });
137+
}).toThrowError(RangeError, /"resourceLimits\.jsDispatchTableSizeMb"/);
138+
});
139+
140+
it("throws a RangeError for a fractional jsDispatchTableSizeMb", function () {
141+
expect(function () {
142+
new Worker(echoEntry, { resourceLimits: { jsDispatchTableSizeMb: 1.5 } });
143+
}).toThrowError(RangeError, /"resourceLimits\.jsDispatchTableSizeMb"/);
144+
});
145+
146+
it("starts a worker under a jsDispatchTableSizeMb reservation", function (done) {
147+
expectStarts({ resourceLimits: { jsDispatchTableSizeMb: 64 } }, done);
148+
});
149+
150+
it("starts a worker under the smallest jsDispatchTableSizeMb reservation", function (done) {
151+
expectStarts({ resourceLimits: { jsDispatchTableSizeMb: 1 } }, done);
152+
});
153+
});
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: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
#include "JsArgToArrayConverter.h"
1111
#include "ArgConverter.h"
1212
#include "v8-profiler.h"
13+
#include <cmath>
1314
#include <iostream>
15+
#include <optional>
1416
#include <sstream>
1517
#include <fstream>
1618
#include <cstdio>
@@ -1166,6 +1168,18 @@ int ClampWorkerPriority(Local<Context> context, Local<Value> value) {
11661168
throw NativeScriptException(isolate, error, message);
11671169
}
11681170

1171+
[[noreturn]] void ThrowWorkerOptionRangeError(Isolate *isolate, const std::string &message) {
1172+
Local<Value> error = Exception::RangeError(ArgConverter::ConvertToV8String(isolate, message));
1173+
throw NativeScriptException(isolate, error, message);
1174+
}
1175+
1176+
#ifndef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM
1177+
[[noreturn]] void ThrowWorkerOptionError(Isolate *isolate, const std::string &message) {
1178+
Local<Value> error = Exception::Error(ArgConverter::ConvertToV8String(isolate, message));
1179+
throw NativeScriptException(isolate, error, message);
1180+
}
1181+
#endif
1182+
11691183
// Reads `key` from `object`. A false return means the getter threw: the
11701184
// exception is already pending on the isolate and construction must stop
11711185
// without running anything else on it.
@@ -1250,6 +1264,123 @@ bool GetWorkerThreadPriority(Isolate *isolate, Local<Context> context,
12501264
kWorkerPriorityNames + ".");
12511265
}
12521266

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

12551386
void CallbackHandlers::NewThreadCallback(const v8::FunctionCallbackInfo<v8::Value> &args) {
@@ -1307,10 +1438,18 @@ void CallbackHandlers::NewThreadCallback(const v8::FunctionCallbackInfo<v8::Valu
13071438
}
13081439

13091440
int priority;
1310-
if (!GetWorkerThreadPriority(isolate, context, args, priority)) {
1441+
IsolateLimits resourceLimits;
1442+
if (!GetWorkerThreadPriority(isolate, context, args, priority) ||
1443+
!ParseWorkerResourceLimits(isolate, context, args, resourceLimits)) {
13111444
return;
13121445
}
13131446

1447+
#ifdef V8_HAS_JS_DISPATCH_TABLE_RESERVATION_PARAM
1448+
if (!resourceLimits.jsDispatchTableReservationBytes.has_value()) {
1449+
resourceLimits.jsDispatchTableReservationBytes = kDefaultWorkerJsDispatchTableBytes;
1450+
}
1451+
#endif
1452+
13141453
// An http(s) entry has no filesystem form to validate or to resolve
13151454
// against the caller's directory: it is already absolute, and the
13161455
// module loader's HTTP branch fetches it on the worker's own thread
@@ -1386,8 +1525,8 @@ void CallbackHandlers::NewThreadCallback(const v8::FunctionCallbackInfo<v8::Valu
13861525
// here on the main thread where class loading is safe.
13871526
WorkerWrapper::EnsureJniCached();
13881527

1389-
auto wrapper = std::make_shared<WorkerWrapper>(isolate, workerId, entryPath,
1390-
currentDir, priority, thiz);
1528+
auto wrapper = std::make_shared<WorkerWrapper>(isolate, workerId, entryPath, currentDir,
1529+
priority, std::move(resourceLimits), thiz);
13911530
WorkerWrapper::Insert(workerId, wrapper);
13921531

13931532
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)