From 2185f915a0ab7d2a6777b2d96f837aebb71ba265 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sat, 5 Sep 2026 22:39:34 -0300 Subject: [PATCH 1/5] feat(worker): accept platform options under ios, deprecate iosPriority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker options now carry iOS-specific settings in an `ios` namespace object: new Worker("./w.js", { ios: { priority: "userInitiated" } }) `ios.priority` is validated strictly — a non-object `ios`, a non-string priority or an unrecognized priority name throws a TypeError — while unknown keys inside `ios` are ignored so later options can be added without breaking older runtimes. `iosPriority` stays supported with its lenient behavior and logs a one-time deprecation warning; `ios.priority` takes precedence when both are given. The quality of service is carried as `std::optional` so an explicit "default" is distinguishable from no option at all. --- NativeScript/runtime/DataWrapper.h | 4 +- NativeScript/runtime/Worker.mm | 101 ++++++++++++++---- NativeScript/runtime/WorkerWrapper.mm | 6 +- TestRunner/app/tests/WorkerOptionsTests.js | 93 ++++++++++++++++ TestRunner/app/tests/index.js | 1 + .../app/tests/workerOptions/qosWorker.js | 4 + 6 files changed, 187 insertions(+), 22 deletions(-) create mode 100644 TestRunner/app/tests/WorkerOptionsTests.js create mode 100644 TestRunner/app/tests/workerOptions/qosWorker.js diff --git a/NativeScript/runtime/DataWrapper.h b/NativeScript/runtime/DataWrapper.h index 73e8f59a..becc9ca9 100644 --- a/NativeScript/runtime/DataWrapper.h +++ b/NativeScript/runtime/DataWrapper.h @@ -5,6 +5,7 @@ #include #include +#include #include #include "Common.h" @@ -548,7 +549,8 @@ class WorkerWrapper : public BaseDataWrapper { void DestroyInspector(); void Start(std::shared_ptr> poWorker, - std::function func, int qualityOfService = -1); + std::function func, + std::optional qualityOfService = std::nullopt); void CallOnErrorHandlers(v8::TryCatch& tc); // Reports a rejected entry-evaluation promise. A rejection carries a reason // rather than a TryCatch, so it cannot go through CallOnErrorHandlers, but it diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index 31368507..22688529 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -1,6 +1,8 @@ #include "Worker.h" #include #include +#include +#include #include "Caches.h" #include "Constants.h" #include "Helpers.h" @@ -17,6 +19,85 @@ std::vector Worker::GlobalFunctions = {"postMessage", "close"}; +namespace { + +// The five names below are the whole public priority surface; anything else is +// a caller error under `ios.priority` and ignored under the deprecated +// `iosPriority`. +bool MapPriorityName(const std::string& name, int& qos) { + if (name == "userInteractive") { + qos = NSQualityOfServiceUserInteractive; + } else if (name == "userInitiated") { + qos = NSQualityOfServiceUserInitiated; + } else if (name == "default") { + qos = NSQualityOfServiceDefault; + } else if (name == "utility") { + qos = NSQualityOfServiceUtility; + } else if (name == "background") { + qos = NSQualityOfServiceBackground; + } else { + return false; + } + return true; +} + +// Carries a real TypeError instance so `catch (e) { e instanceof TypeError }` +// holds in JS; the constructor's catch block rethrows it unchanged. +[[noreturn]] void ThrowOptionTypeError(Isolate* isolate, const std::string& message) { + Local error = Exception::TypeError(tns::ToV8String(isolate, message)); + throw NativeScriptException(isolate, error, message); +} + +// Returns the quality of service the caller asked for, or nullopt to leave the +// worker thread at the operation queue's own default. +std::optional ParseQualityOfService(Isolate* isolate, Local context, + Local options) { + std::optional qos; + + Local iosVal; + if (options->Get(context, tns::ToV8String(isolate, "ios")).ToLocal(&iosVal) && + !iosVal->IsUndefined()) { + if (!iosVal->IsObject()) { + ThrowOptionTypeError(isolate, "Worker option \"ios\" must be an object."); + } + + Local priorityVal; + if (iosVal.As() + ->Get(context, tns::ToV8String(isolate, "priority")) + .ToLocal(&priorityVal) && + !priorityVal->IsUndefined()) { + int mapped; + if (!IsString(priorityVal) || !MapPriorityName(ToString(isolate, priorityVal), mapped)) { + ThrowOptionTypeError(isolate, + "Worker option \"ios.priority\" must be one of \"userInteractive\", " + "\"userInitiated\", \"default\", \"utility\" or \"background\"."); + } + qos = mapped; + } + } + + Local legacyVal; + if (options->Get(context, tns::ToV8String(isolate, "iosPriority")).ToLocal(&legacyVal) && + !legacyVal->IsUndefined()) { + static std::once_flag warnedDeprecated; + std::call_once(warnedDeprecated, []() { + Log(@"NativeScript: the Worker option \"iosPriority\" is deprecated. Use " + @"\"ios\": { \"priority\": ... } instead."); + }); + + int mapped; + // Lenient by contract: an unusable legacy value is ignored, never fatal. + if (!qos.has_value() && IsString(legacyVal) && + MapPriorityName(ToString(isolate, legacyVal), mapped)) { + qos = mapped; + } + } + + return qos; +} + +} // namespace + void Worker::Init(Isolate* isolate, Local globalTemplate) { Worker::Init(isolate, globalTemplate, Caches::Get(isolate)->isWorker); } @@ -148,25 +229,9 @@ throw NativeScriptException( } } - int qos = -1; + std::optional qos; if (info.Length() >= 2 && info[1]->IsObject()) { - Local options = info[1].As(); - Local iosPriorityVal; - if (options->Get(context, tns::ToV8String(isolate, "iosPriority")).ToLocal(&iosPriorityVal) && - IsString(iosPriorityVal)) { - std::string priority = ToString(isolate, iosPriorityVal); - if (priority == "userInteractive") { - qos = NSQualityOfServiceUserInteractive; - } else if (priority == "userInitiated") { - qos = NSQualityOfServiceUserInitiated; - } else if (priority == "default") { - qos = NSQualityOfServiceDefault; - } else if (priority == "utility") { - qos = NSQualityOfServiceUtility; - } else if (priority == "background") { - qos = NSQualityOfServiceBackground; - } - } + qos = ParseQualityOfService(isolate, context, info[1].As()); } WorkerWrapper* worker = new WorkerWrapper(isolate, Worker::OnMessageCallback); diff --git a/NativeScript/runtime/WorkerWrapper.mm b/NativeScript/runtime/WorkerWrapper.mm index ff5a44e3..8ecf8b01 100644 --- a/NativeScript/runtime/WorkerWrapper.mm +++ b/NativeScript/runtime/WorkerWrapper.mm @@ -73,7 +73,7 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a } void WorkerWrapper::Start(std::shared_ptr> poWorker, - std::function func, int qualityOfService) { + std::function func, std::optional qualityOfService) { this->poWorker_ = poWorker; this->workerId_ = nextId_.fetch_add(1, std::memory_order_relaxed) + 1; @@ -81,8 +81,8 @@ static void PostToRuntimeLoop(Runtime* runtime, std::function fn, bool a this->BackgroundLooper(func); }]; - if (qualityOfService >= 0) { - op.qualityOfService = static_cast(qualityOfService); + if (qualityOfService.has_value()) { + op.qualityOfService = static_cast(*qualityOfService); } [workers_ addOperation:op]; diff --git a/TestRunner/app/tests/WorkerOptionsTests.js b/TestRunner/app/tests/WorkerOptionsTests.js new file mode 100644 index 00000000..bb435e25 --- /dev/null +++ b/TestRunner/app/tests/WorkerOptionsTests.js @@ -0,0 +1,93 @@ +describe("Worker platform options", function () { + var entry = "./workerOptions/qosWorker.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 reportQos = function (options, done, check) { + var worker = options === undefined ? new Worker(entry) : new Worker(entry, options); + var settled = false; + var finish = function () { + if (settled) { + return; + } + settled = true; + worker.terminate(); + done(); + }; + worker.onmessage = function (msg) { + check(msg.data.qos); + finish(); + }; + worker.onerror = function (e) { + expect(String(e && e.message ? e.message : e)).toBe(""); + finish(); + }; + }; + + var priorities = [ + ["userInteractive", NSQualityOfService.UserInteractive], + ["userInitiated", NSQualityOfService.UserInitiated], + ["default", NSQualityOfService.Default], + ["utility", NSQualityOfService.Utility], + ["background", NSQualityOfService.Background] + ]; + + priorities.forEach(function (pair) { + it("runs the worker thread at " + pair[0] + " quality of service", function (done) { + reportQos({ ios: { priority: pair[0] } }, done, function (qos) { + expect(qos).toBe(pair[1]); + }); + }); + }); + + it("still honors the deprecated iosPriority option", function (done) { + reportQos({ iosPriority: "utility" }, done, function (qos) { + expect(qos).toBe(NSQualityOfService.Utility); + }); + }); + + it("prefers ios.priority over iosPriority when both are given", function (done) { + reportQos({ ios: { priority: "userInteractive" }, iosPriority: "background" }, done, function (qos) { + expect(qos).toBe(NSQualityOfService.UserInteractive); + }); + }); + + it("ignores unknown keys inside ios", function (done) { + reportQos({ ios: { priority: "utility", somethingElse: 42 } }, done, function (qos) { + expect(qos).toBe(NSQualityOfService.Utility); + }); + }); + + it("starts a worker given no options at all", function (done) { + reportQos(undefined, done, function (qos) { + expect(typeof qos).toBe("number"); + }); + }); + + it("throws a TypeError when ios is not an object", function () { + expect(function () { + new Worker(entry, { ios: 42 }); + }).toThrowError(TypeError, /"ios"/); + }); + + it("throws a TypeError for an unknown ios.priority", function () { + expect(function () { + new Worker(entry, { ios: { priority: "highest" } }); + }).toThrowError(TypeError, /"ios\.priority"/); + }); + + it("throws a TypeError for a non-string ios.priority", function () { + expect(function () { + new Worker(entry, { ios: { priority: 3 } }); + }).toThrowError(TypeError, /"ios\.priority"/); + }); +}); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index bf167bfb..06e580a0 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -131,6 +131,7 @@ require("./ApiTests"); require("./NsRuntimeTests"); require("./GCFinalizerTests"); require("./WorkerConcurrentStartupTests"); +require("./WorkerOptionsTests"); require("./DeclarationConflicts"); // require("./Promises"); diff --git a/TestRunner/app/tests/workerOptions/qosWorker.js b/TestRunner/app/tests/workerOptions/qosWorker.js new file mode 100644 index 00000000..4537fdf8 --- /dev/null +++ b/TestRunner/app/tests/workerOptions/qosWorker.js @@ -0,0 +1,4 @@ +// Entry for WorkerOptionsTests: reports the quality of service the runtime +// gave this worker's thread, which is the only observable effect of the +// `ios.priority` option. +postMessage({ qos: NSThread.currentThread.qualityOfService }); From 5360bc40e56423257132c7828c57ad81e6f6cfc2 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sat, 5 Sep 2026 22:43:02 -0300 Subject: [PATCH 2/5] feat(worker): treat ios: null as an absent ios option --- NativeScript/runtime/Worker.mm | 2 +- TestRunner/app/tests/WorkerOptionsTests.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index 22688529..82bdd337 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -56,7 +56,7 @@ bool MapPriorityName(const std::string& name, int& qos) { Local iosVal; if (options->Get(context, tns::ToV8String(isolate, "ios")).ToLocal(&iosVal) && - !iosVal->IsUndefined()) { + !iosVal->IsNullOrUndefined()) { if (!iosVal->IsObject()) { ThrowOptionTypeError(isolate, "Worker option \"ios\" must be an object."); } diff --git a/TestRunner/app/tests/WorkerOptionsTests.js b/TestRunner/app/tests/WorkerOptionsTests.js index bb435e25..006d635a 100644 --- a/TestRunner/app/tests/WorkerOptionsTests.js +++ b/TestRunner/app/tests/WorkerOptionsTests.js @@ -73,6 +73,12 @@ describe("Worker platform options", function () { }); }); + it("treats ios: null like an absent ios", function (done) { + reportQos({ ios: null, iosPriority: "background" }, done, function (qos) { + expect(qos).toBe(NSQualityOfService.Background); + }); + }); + it("throws a TypeError when ios is not an object", function () { expect(function () { new Worker(entry, { ios: 42 }); From cbbb97f1a7892b567819b179b7f5d8ccfa51b60e Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sun, 6 Sep 2026 01:41:16 -0300 Subject: [PATCH 3/5] test(worker): give low quality-of-service workers time to boot on a loaded host --- TestRunner/app/tests/WorkerOptionsTests.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/TestRunner/app/tests/WorkerOptionsTests.js b/TestRunner/app/tests/WorkerOptionsTests.js index 006d635a..eeb5741a 100644 --- a/TestRunner/app/tests/WorkerOptionsTests.js +++ b/TestRunner/app/tests/WorkerOptionsTests.js @@ -2,11 +2,13 @@ describe("Worker platform options", function () { var entry = "./workerOptions/qosWorker.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. + // has to be raised ahead of the spec, not inside it. A utility or + // background thread boots a whole isolate under throttled CPU and I/O, and + // on a contended CI host that alone has taken over 30 s. var originalTimeout; beforeEach(function () { originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; - jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 120000; }); afterEach(function () { jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; From d85eb2cd177aa1643a9399825b518dd9773acc86 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sun, 6 Sep 2026 02:10:24 -0300 Subject: [PATCH 4/5] test(worker): stop waiting on a background-class thread to boot an isolate --- TestRunner/app/tests/WorkerOptionsTests.js | 25 ++++++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/TestRunner/app/tests/WorkerOptionsTests.js b/TestRunner/app/tests/WorkerOptionsTests.js index eeb5741a..c8162f91 100644 --- a/TestRunner/app/tests/WorkerOptionsTests.js +++ b/TestRunner/app/tests/WorkerOptionsTests.js @@ -2,9 +2,9 @@ describe("Worker platform options", function () { var entry = "./workerOptions/qosWorker.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. A utility or - // background thread boots a whole isolate under throttled CPU and I/O, and - // on a contended CI host that alone has taken over 30 s. + // has to be raised ahead of the spec, not inside it. A utility thread boots + // a whole isolate under throttled CPU and I/O; on a contended host that has + // taken well over 10 s. var originalTimeout; beforeEach(function () { originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; @@ -35,12 +35,15 @@ describe("Worker platform options", function () { }; }; + // Background is deliberately absent: the system defines that class as work + // that may take minutes, and on a loaded host a background thread has not + // finished booting an isolate within two minutes. It is covered below + // without waiting on it. var priorities = [ ["userInteractive", NSQualityOfService.UserInteractive], ["userInitiated", NSQualityOfService.UserInitiated], ["default", NSQualityOfService.Default], - ["utility", NSQualityOfService.Utility], - ["background", NSQualityOfService.Background] + ["utility", NSQualityOfService.Utility] ]; priorities.forEach(function (pair) { @@ -51,6 +54,14 @@ describe("Worker platform options", function () { }); }); + it("accepts background priority", function () { + var worker; + expect(function () { + worker = new Worker(entry, { ios: { priority: "background" } }); + }).not.toThrow(); + worker.terminate(); + }); + it("still honors the deprecated iosPriority option", function (done) { reportQos({ iosPriority: "utility" }, done, function (qos) { expect(qos).toBe(NSQualityOfService.Utility); @@ -76,8 +87,8 @@ describe("Worker platform options", function () { }); it("treats ios: null like an absent ios", function (done) { - reportQos({ ios: null, iosPriority: "background" }, done, function (qos) { - expect(qos).toBe(NSQualityOfService.Background); + reportQos({ ios: null, iosPriority: "utility" }, done, function (qos) { + expect(qos).toBe(NSQualityOfService.Utility); }); }); From 5958bf06179b461a55483dc0ef3cff9c1e10fb10 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sun, 6 Sep 2026 15:52:42 -0300 Subject: [PATCH 5/5] fix(worker): stop construction when an option getter throws --- NativeScript/runtime/Worker.mm | 43 ++++++++++++++-------- TestRunner/app/tests/WorkerOptionsTests.js | 35 ++++++++++++++++-- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index 82bdd337..85dffd63 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -48,24 +48,33 @@ bool MapPriorityName(const std::string& name, int& qos) { throw NativeScriptException(isolate, error, message); } -// Returns the quality of service the caller asked for, or nullopt to leave the -// worker thread at the operation queue's own default. -std::optional ParseQualityOfService(Isolate* isolate, Local context, - Local options) { - std::optional qos; +// 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. +bool ReadOption(Isolate* isolate, Local context, Local object, const char* key, + Local& out) { + return object->Get(context, tns::ToV8String(isolate, key)).ToLocal(&out); +} +// Fills `qos` with the quality of service the caller asked for, or leaves it +// empty for the operation queue's own default. Returns false when a getter +// threw (see ReadOption). +bool ParseQualityOfService(Isolate* isolate, Local context, Local options, + std::optional& qos) { Local iosVal; - if (options->Get(context, tns::ToV8String(isolate, "ios")).ToLocal(&iosVal) && - !iosVal->IsNullOrUndefined()) { + if (!ReadOption(isolate, context, options, "ios", iosVal)) { + return false; + } + if (!iosVal->IsNullOrUndefined()) { if (!iosVal->IsObject()) { ThrowOptionTypeError(isolate, "Worker option \"ios\" must be an object."); } Local priorityVal; - if (iosVal.As() - ->Get(context, tns::ToV8String(isolate, "priority")) - .ToLocal(&priorityVal) && - !priorityVal->IsUndefined()) { + if (!ReadOption(isolate, context, iosVal.As(), "priority", priorityVal)) { + return false; + } + if (!priorityVal->IsUndefined()) { int mapped; if (!IsString(priorityVal) || !MapPriorityName(ToString(isolate, priorityVal), mapped)) { ThrowOptionTypeError(isolate, @@ -77,8 +86,10 @@ bool MapPriorityName(const std::string& name, int& qos) { } Local legacyVal; - if (options->Get(context, tns::ToV8String(isolate, "iosPriority")).ToLocal(&legacyVal) && - !legacyVal->IsUndefined()) { + if (!ReadOption(isolate, context, options, "iosPriority", legacyVal)) { + return false; + } + if (!legacyVal->IsUndefined()) { static std::once_flag warnedDeprecated; std::call_once(warnedDeprecated, []() { Log(@"NativeScript: the Worker option \"iosPriority\" is deprecated. Use " @@ -93,7 +104,7 @@ bool MapPriorityName(const std::string& name, int& qos) { } } - return qos; + return true; } } // namespace @@ -231,7 +242,9 @@ throw NativeScriptException( std::optional qos; if (info.Length() >= 2 && info[1]->IsObject()) { - qos = ParseQualityOfService(isolate, context, info[1].As()); + if (!ParseQualityOfService(isolate, context, info[1].As(), qos)) { + return; + } } WorkerWrapper* worker = new WorkerWrapper(isolate, Worker::OnMessageCallback); diff --git a/TestRunner/app/tests/WorkerOptionsTests.js b/TestRunner/app/tests/WorkerOptionsTests.js index c8162f91..3848d249 100644 --- a/TestRunner/app/tests/WorkerOptionsTests.js +++ b/TestRunner/app/tests/WorkerOptionsTests.js @@ -25,13 +25,21 @@ describe("Worker platform options", function () { worker.terminate(); done(); }; + // A throw inside either handler must still settle the spec and + // terminate the worker; Jasmine only guards the spec body itself. worker.onmessage = function (msg) { - check(msg.data.qos); - finish(); + try { + check(msg.data.qos); + } finally { + finish(); + } }; worker.onerror = function (e) { - expect(String(e && e.message ? e.message : e)).toBe(""); - finish(); + try { + expect(String(e && e.message ? e.message : e)).toBe(""); + } finally { + finish(); + } }; }; @@ -92,6 +100,25 @@ describe("Worker platform options", function () { }); }); + it("propagates the error thrown by an option getter", function () { + var boom = new Error("boom"); + var options = new Proxy({}, { + get: function (target, key) { + if (key === "ios") { + throw boom; + } + return undefined; + } + }); + var thrown; + try { + new Worker(entry, options); + } catch (e) { + thrown = e; + } + expect(thrown).toBe(boom); + }); + it("throws a TypeError when ios is not an object", function () { expect(function () { new Worker(entry, { ios: 42 });