diff --git a/.gitignore b/.gitignore index 67618ca90..215d72492 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,10 @@ thumbs.db .classpath android-runtime.iml + +# Emitted by tools/js2c.mjs from test-app/runtime/src/main/cpp/js during the build. +test-app/runtime/src/main/cpp/generated/ + test-app/build-tools/*.log test-app/analytics/build-statistics.json package-lock.json diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 000000000..9c042cef0 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,80 @@ +// Lint setup for the runtime's builtin JavaScript +// (test-app/runtime/src/main/cpp/js). Each file is compiled by BuiltinLoader +// as a FUNCTION BODY with the fixed parameters `exports`, `module`, `binding` +// and `primordials` (see that directory's README.md), which are declared as +// globals here. no-undef is the typo net for binding-bag destructures and +// native-global usage alike; no-restricted-properties keeps the captured +// intrinsics from being read off the live globals again. +import globals from 'globals'; + +// Statics that primordials.js captures, mapped to their replacement. Instance +// methods (Array.prototype.slice and friends) cannot be matched by +// no-restricted-properties on the receiver, so uncurried use of those stays a +// review rule. +const capturedStatics = [ + ['Array', 'isArray', 'ArrayIsArray'], + ['ArrayBuffer', 'isView', 'ArrayBufferIsView'], + ['JSON', 'stringify', 'JSONStringify'], + ['Object', 'create', 'ObjectCreate'], + ['Object', 'defineProperty', 'ObjectDefineProperty'], + ['Object', 'getOwnPropertyDescriptor', 'ObjectGetOwnPropertyDescriptor'], + ['Object', 'getOwnPropertySymbols', 'ObjectGetOwnPropertySymbols'], + ['Object', 'getPrototypeOf', 'ObjectGetPrototypeOf'], + ['Object', 'is', 'ObjectIs'], + ['Object', 'keys', 'ObjectKeys'], +]; + +// Captured constructors. A destructure from `primordials` shadows the global, +// so these only fire on the unguarded reference. +const restrictedGlobals = ['Date', 'Map', 'Proxy', 'Set', 'String', 'TypeError'].map((name) => ({ + name, + message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`, +})); + +const restrictedProperties = capturedStatics.map(([object, property, primordial]) => ({ + object, + property, + message: `Use the ${primordial} primordial instead of ${object}.${property} — builtins must not read intrinsics off globals user code can replace.`, +})); + +export default [ + { + files: ['test-app/runtime/src/main/cpp/js/**/*.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'script', + globals: { + ...globals.es2021, + exports: 'readonly', + module: 'readonly', + binding: 'readonly', + primordials: 'readonly', + global: 'readonly', + console: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + Blob: 'readonly', + File: 'readonly', + WebAssembly: 'readonly', + // Java package roots resolved through the metadata interceptor at + // runtime: + java: 'readonly', + org: 'readonly', + }, + }, + rules: { + 'no-undef': 'error', + 'no-unused-vars': ['error', { args: 'none', caughtErrors: 'none' }], + 'no-restricted-properties': ['error', ...restrictedProperties], + 'no-restricted-globals': ['error', ...restrictedGlobals], + }, + }, + { + // The file that does the capturing. + files: ['test-app/runtime/src/main/cpp/js/primordials.js'], + rules: { + 'no-restricted-properties': 'off', + 'no-restricted-globals': 'off', + }, + }, +]; diff --git a/package.json b/package.json index 3e1ae3033..1c0a019f9 100644 --- a/package.json +++ b/package.json @@ -27,11 +27,14 @@ }, "scripts": { "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", + "lint": "eslint test-app/runtime/src/main/cpp/js", "version": "npm run changelog && git add CHANGELOG.md" }, "devDependencies": { "conventional-changelog-cli": "^2.1.1", "dayjs": "^1.11.7", + "eslint": "^9.15.0", + "globals": "^15.12.0", "semver": "^7.5.0" } } diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index 2ff19f79d..7e37e0869 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -78,6 +78,9 @@ require('./tests/testErrorEvents'); require('./tests/testUnhandledRejections'); require('./tests/testEscapeException'); require('./tests/testUncaughtErrorPolicy'); +// Runtime builtins keep working when app code replaces the intrinsics they use +require('./tests/testPrimordials'); +require('./tests/testInspect'); require("./tests/testConcurrentAccess"); require("./tests/testESModules.mjs"); diff --git a/test-app/app/src/main/assets/app/tests/testInspect.js b/test-app/app/src/main/assets/app/tests/testInspect.js new file mode 100644 index 000000000..4f7dfd7ac --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testInspect.js @@ -0,0 +1,158 @@ +describe("inspect", function () { + it("formats primitives and plain objects", function () { + expect(__inspect(42)).toBe("42"); + expect(__inspect("hi")).toBe('"hi"'); + expect(__inspect({ a: 1, b: "x" })).toBe('{ a: 1, b: "x" }'); + expect(__inspect([1, [2, 3]])).toBe("[ 1, [ 2, 3 ] ]"); + }); + + it("limits depth", function () { + expect(__inspect({ a: { b: { c: { d: 1 } } } })).toBe("{ a: { b: { c: [Object] } } }"); + expect(__inspect({ a: { b: { c: { d: 1 } } } }, { depth: 3 })).toBe("{ a: { b: { c: { d: 1 } } } }"); + }); + + it("reports true cycles and only true cycles", function () { + var cyc = {}; + cyc.self = cyc; + expect(__inspect(cyc)).toBe("{ self: [Circular] }"); + + var shared = { x: 1 }; + expect(__inspect({ a: shared, b: shared })).toBe("{ a: { x: 1 }, b: { x: 1 } }"); + }); + + it("caps arrays and total output", function () { + var big = []; + for (var i = 0; i < 250; i++) { + big[i] = i; + } + expect(__inspect(big).indexOf("... 150 more items")).toBeGreaterThan(-1); + + var huge = {}; + for (var k = 0; k < 100000; k++) { + huge["key" + k] = k; + } + var start = Date.now(); + var out = __inspect(huge); + var elapsed = Date.now() - start; + expect(out.length).toBeLessThan(20000); + // The old JSON path would serialize all 100k keys; the budgeted + // formatter must not take anywhere near a second. + expect(elapsed).toBeLessThan(1000); + }); + + it("caps long strings", function () { + var long = new Array(12001).join("a"); + var out = __inspect(long); + expect(out.indexOf("... 2000 more characters")).toBeGreaterThan(-1); + }); + + it("never invokes getters", function () { + var invoked = false; + var obj = {}; + Object.defineProperty(obj, "x", { + enumerable: true, + get: function () { + invoked = true; + return 1; + } + }); + expect(__inspect(obj)).toBe("{ x: [Getter] }"); + expect(invoked).toBe(false); + }); + + it("formats collections, dates, regexes, errors and functions", function () { + expect(__inspect(new Map([["k", 1]]))).toBe('Map(1) { "k" => 1 }'); + expect(__inspect(new Set([1, 2]))).toBe("Set(2) { 1, 2 }"); + expect(__inspect(new Date(0))).toBe("1970-01-01T00:00:00.000Z"); + expect(__inspect(/ab+c/gi)).toBe("/ab+c/gi"); + expect(__inspect(function foo() {})).toBe("[Function: foo]"); + expect(__inspect(class Foo {})).toBe("[class Foo]"); + expect(__inspect(new Uint8Array(3))).toBe("Uint8Array(3)"); + expect(__inspect(10n)).toBe("10n"); + + var errOut = __inspect(new Error("boom")); + expect(errOut.indexOf("Error: boom")).toBe(0); + }); + + it("identifies java objects without walking them", function () { + var out = __inspect(new java.lang.Object()); + expect(out.indexOf("[")).toBe(0); + expect(out.indexOf("java.lang.Object")).toBeGreaterThan(-1); + + var listOut = __inspect(new java.util.ArrayList()); + expect(listOut.indexOf("java.util.ArrayList")).toBeGreaterThan(-1); + + // The wrapper hint replaces the whole graph, so a java object nested in + // a plain object stays a single token. + var nested = __inspect({ v: new java.lang.Object() }); + expect(nested.indexOf("{ v: [")).toBe(0); + }); + + it("does not materialize java packages", function () { + // Package children are native data properties, so reading descriptors + // off one would build every class it contains. + var start = Date.now(); + var out = __inspect(java); + expect(out.indexOf("[package java")).toBe(0); + expect(__inspect(java.lang).indexOf("[package java.lang")).toBe(0); + expect(Date.now() - start).toBeLessThan(1000); + }); + + it("renders java classes as callables, not as graphs", function () { + // Class wrappers are constructor functions, so they take the callable + // branch before the native-wrapper hint is consulted. + var out = __inspect(java.lang.Object); + expect(/^\[(Function|class)\b/.test(out)).toBe(true); + }); + + it("leaves plain javascript objects to structural rendering", function () { + expect(__inspect({ a: 1 })).toBe("{ a: 1 }"); + expect(__inspect([1])).toBe("[ 1 ]"); + }); + + it("console.log of a huge cyclic object completes quickly", function () { + var huge = { name: "root" }; + var cursor = huge; + for (var i = 0; i < 5000; i++) { + cursor = cursor.child = { i: i, parent: huge }; + } + huge.self = huge; + var start = Date.now(); + console.log(huge); + expect(Date.now() - start).toBeLessThan(1000); + }); + + it("honors custom toString overrides (NativeScript core convention)", function () { + function ViewLike() { this.id = 42; } + ViewLike.prototype.toString = function () { return "Button(42)"; }; + expect(__inspect(new ViewLike())).toBe("Button(42)"); + expect(__inspect({ v: new ViewLike() })).toBe("{ v: Button(42) }"); + expect(__inspect({ toString: function () { return "custom!"; } })).toBe("custom!"); + // A broken override degrades to structural rendering instead of hiding data. + var broken = { a: 1, toString: function () { throw new Error("x"); } }; + expect(__inspect(broken).indexOf("a: 1")).toBeGreaterThan(-1); + }); + + it("formats under tampered prototypes", function () { + var savedSlice = Array.prototype.slice; + var savedIndexOf = Array.prototype.indexOf; + var savedKeys = Object.keys; + var savedStringify = JSON.stringify; + var boom = function () { throw new Error("tampered"); }; + var out; + try { + Array.prototype.slice = boom; + Array.prototype.indexOf = boom; + Object.keys = boom; + JSON.stringify = boom; + out = __inspect({ a: [1, 2], m: new Map([[1, 2]]) }); + } finally { + Array.prototype.slice = savedSlice; + Array.prototype.indexOf = savedIndexOf; + Object.keys = savedKeys; + JSON.stringify = savedStringify; + } + expect(out).toBe("{ a: [ 1, 2 ], m: Map(1) { 1 => 2 } }"); + }); +}); + diff --git a/test-app/app/src/main/assets/app/tests/testPrimordials.js b/test-app/app/src/main/assets/app/tests/testPrimordials.js new file mode 100644 index 000000000..f2a91dd62 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testPrimordials.js @@ -0,0 +1,214 @@ +describe("primordials", function () { + const boom = function () { + throw new Error("intrinsic tampered"); + }; + + // Tampering with the intrinsics breaks Jasmine and most of the runtime as + // well, so the tampered window stays synchronous and assertion-free: + // results go into locals, the originals come back in a finally, and only + // then do the expectations run. Nothing inside the window may use an array + // method or `.call` either — plain indexing and direct calls only. + function withTampered(patches, body) { + const originals = []; + for (let i = 0; i < patches.length; i++) { + originals[i] = patches[i][0][patches[i][1]]; + } + try { + for (let i = 0; i < patches.length; i++) { + patches[i][0][patches[i][1]] = boom; + } + return body(); + } finally { + for (let i = 0; i < patches.length; i++) { + patches[i][0][patches[i][1]] = originals[i]; + } + } + } + + const arrayAndCall = [ + [Array.prototype, "slice"], + [Array.prototype, "indexOf"], + [Array.prototype, "push"], + [Array.prototype, "splice"], + [Function.prototype, "call"], + ]; + + it("the tampering used by this suite is actually observable", function () { + const outcome = withTampered(arrayAndCall, function () { + try { + [1, 2].slice(0); + return "no throw"; + } catch (e) { + return e.message; + } + }); + + expect(outcome).toBe("intrinsic tampered"); + expect([1, 2].slice(0).length).toBe(2); + }); + + it("global dispatchEvent delivers to every listener while intrinsics are tampered", function () { + const seen = []; + const first = function (e) { seen[seen.length] = "first:" + e.type; }; + const second = { handleEvent: function (e) { seen[seen.length] = "second:" + e.type; } }; + const event = new Event("primordials-dispatch"); + + global.addEventListener("primordials-dispatch", first); + global.addEventListener("primordials-dispatch", second); + + let dispatchResult; + try { + dispatchResult = withTampered(arrayAndCall, function () { + return global.dispatchEvent(event); + }); + } finally { + global.removeEventListener("primordials-dispatch", first); + global.removeEventListener("primordials-dispatch", second); + } + + expect(dispatchResult).toBe(true); + expect(seen.join(",")).toBe("first:primordials-dispatch,second:primordials-dispatch"); + }); + + it("addEventListener/removeEventListener and once work while intrinsics are tampered", function () { + const calls = []; + const persistent = function () { calls[calls.length] = "persistent"; }; + const onceOnly = function () { calls[calls.length] = "once"; }; + + try { + withTampered(arrayAndCall, function () { + global.addEventListener("primordials-registration", persistent); + global.addEventListener("primordials-registration", onceOnly, { once: true }); + global.dispatchEvent(new Event("primordials-registration")); + global.dispatchEvent(new Event("primordials-registration")); + global.removeEventListener("primordials-registration", persistent); + global.dispatchEvent(new Event("primordials-registration")); + }); + } finally { + global.removeEventListener("primordials-registration", persistent); + global.removeEventListener("primordials-registration", onceOnly); + } + + expect(calls.join(",")).toBe("persistent,once,persistent"); + }); + + it("reportError still reaches an error listener while intrinsics are tampered", function () { + let received = null; + // preventDefault keeps the unhandled tail (which aborts the process) + // out of the picture. + const onError = function (e) { + received = e; + e.preventDefault(); + }; + const error = new Error("primordials-report"); + + global.addEventListener("error", onError); + try { + withTampered(arrayAndCall, function () { + global.reportError(error); + }); + } finally { + global.removeEventListener("error", onError); + } + + expect(received).not.toBeNull(); + expect(received.type).toBe("error"); + expect(received.error).toBe(error); + expect(received.message).toBe("primordials-report"); + }); + + it("console.log of a circular object neither throws nor crashes with JSON.stringify tampered", function () { + // The inspect builtin quotes strings through JSON.stringify and builds + // its parts list with Array.prototype.push. Its logcat output is not + // reachable from JS, so this only pins down that the tampered path + // stays non-fatal; testInspect covers the formatting itself. + const circular = { name: "primordials" }; + circular.self = circular; + + let threw = null; + try { + withTampered([ + [JSON, "stringify"], + [Array.prototype, "indexOf"], + [Array.prototype, "push"], + ], function () { + console.log(circular); + }); + } catch (e) { + threw = e; + } + + expect(threw).toBeNull(); + }); + + it("the searchParams accessor works while Object.defineProperty is tampered", function () { + const url = new URL("https://example.com/path?a=1"); + + let readBack = null; + let searchAfterAppend = null; + let threw = null; + try { + withTampered([[Object, "defineProperty"]], function () { + const params = url.searchParams; + readBack = params.get("a"); + params.append("b", "2"); + searchAfterAppend = url.search; + }); + } catch (e) { + threw = e; + } + + expect(threw).toBeNull(); + expect(readBack).toBe("1"); + expect(searchAfterAppend).toBe("?a=1&b=2"); + }); + + it("revokeObjectURL and InternalAccessor.getData work while Map methods are tampered", function () { + let data; + let threw = null; + try { + withTampered([ + [Map.prototype, "get"], + [Map.prototype, "set"], + [Map.prototype, "delete"], + ], function () { + URL.revokeObjectURL("blob:nativescript/primordials-missing"); + data = URL.InternalAccessor.getData("blob:nativescript/primordials-missing"); + }); + } catch (e) { + threw = e; + } + + expect(threw).toBeNull(); + expect(data).toBeUndefined(); + }); + + it("org.json.JSONObject.from works while the intrinsics json-helper uses are tampered", function () { + const source = { + text: "primordials", + when: new Date(1570696661136), + list: [1, 2], + }; + + let converted = null; + let threw = null; + try { + withTampered([ + [Array, "isArray"], + [Array.prototype, "forEach"], + [Object, "keys"], + [Date.prototype, "toJSON"], + ], function () { + converted = org.json.JSONObject.from(source); + }); + } catch (e) { + threw = e; + } + + expect(threw).toBeNull(); + expect(converted instanceof org.json.JSONObject).toBe(true); + expect(converted.getString("text")).toBe("primordials"); + expect(converted.getString("when")).toBe("2019-10-10T08:37:41.136Z"); + expect(converted.getJSONArray("list").length()).toBe(2); + }); +}); diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index 166f8ab2e..759752aaa 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -58,6 +58,42 @@ include_directories( src/main/cpp/ada ) +# The runtime's builtin JavaScript (src/main/cpp/js) embedded into a generated +# C++ table by tools/js2c.mjs. The list is explicit rather than globbed so that +# adding a file is a visible build change; --check-dir fails the build when it +# drifts from the directory contents. +set(RUNTIME_BUILTIN_JS_DIR ${PROJECT_SOURCE_DIR}/src/main/cpp/js) +set(RUNTIME_BUILTIN_JS + ${RUNTIME_BUILTIN_JS_DIR}/blob-url.js + ${RUNTIME_BUILTIN_JS_DIR}/error-events.js + ${RUNTIME_BUILTIN_JS_DIR}/events.js + ${RUNTIME_BUILTIN_JS_DIR}/inspect.js + ${RUNTIME_BUILTIN_JS_DIR}/json-helper.js + ${RUNTIME_BUILTIN_JS_DIR}/message-loop-timer.js + ${RUNTIME_BUILTIN_JS_DIR}/primordials.js + ${RUNTIME_BUILTIN_JS_DIR}/require-factory.js + ${RUNTIME_BUILTIN_JS_DIR}/weak-ref.js +) +set(RUNTIME_BUILTINS_GENERATED_DIR ${PROJECT_SOURCE_DIR}/src/main/cpp/generated) +get_filename_component(RUNTIME_BUILTINS_JS2C ${PROJECT_SOURCE_DIR}/../../tools/js2c.mjs ABSOLUTE) + +find_program(NODE_EXECUTABLE NAMES node nodejs) +if (NOT NODE_EXECUTABLE) + message(FATAL_ERROR "node was not found on PATH; it is required to generate RuntimeBuiltins") +endif () + +add_custom_command( + OUTPUT ${RUNTIME_BUILTINS_GENERATED_DIR}/RuntimeBuiltins.h + ${RUNTIME_BUILTINS_GENERATED_DIR}/RuntimeBuiltins.cpp + COMMAND ${NODE_EXECUTABLE} ${RUNTIME_BUILTINS_JS2C} + --out-dir ${RUNTIME_BUILTINS_GENERATED_DIR} + --check-dir ${RUNTIME_BUILTIN_JS_DIR} + ${RUNTIME_BUILTIN_JS} + DEPENDS ${RUNTIME_BUILTIN_JS} ${RUNTIME_BUILTINS_JS2C} + COMMENT "Generating RuntimeBuiltins from src/main/cpp/js" + VERBATIM +) + # This branch also produces runtime-regular-release.aar, shipped as # nativescript-regular.aar and selected for apps that set useV8Symbols, so it # must carry the release flags. Only a local Debug build keeps plain -g. @@ -106,6 +142,7 @@ add_library( src/main/cpp/ArrayElementAccessor.cpp src/main/cpp/ArrayHelper.cpp src/main/cpp/AssetExtractor.cpp + src/main/cpp/BuiltinLoader.cpp src/main/cpp/CallbackHandlers.cpp src/main/cpp/ConcurrentQueue.cpp src/main/cpp/Constants.cpp @@ -165,6 +202,8 @@ add_library( src/main/cpp/HMRSupport.cpp src/main/cpp/DevFlags.cpp + ${RUNTIME_BUILTINS_GENERATED_DIR}/RuntimeBuiltins.cpp + # V8 inspector source files will be included only in Release mode ${INSPECTOR_SOURCES} ) diff --git a/test-app/runtime/src/main/cpp/BuiltinLoader.cpp b/test-app/runtime/src/main/cpp/BuiltinLoader.cpp new file mode 100644 index 000000000..c276303bf --- /dev/null +++ b/test-app/runtime/src/main/cpp/BuiltinLoader.cpp @@ -0,0 +1,179 @@ +#include "BuiltinLoader.h" + +#include +#include + +#include "ArgConverter.h" +#include "robin_hood.h" + +using namespace v8; + +namespace tns { + +namespace { + +/* + * Process-wide bytecode cache shared across isolates. Worker runtimes + * initialize on their own threads, so every access is under the mutex. + */ +std::mutex builtinCacheMutex; +std::vector builtinCache[static_cast(BuiltinId::kCount)]; + +/* + * Every builtin is compiled as a function body receiving these fixed + * parameters, mirroring Node's module wrapper: a file exports through + * `module.exports`/`exports`, natives arrive as properties of the `binding` + * bag (Node's internalBinding idiom) and intrinsics as properties of + * `primordials`; each file destructures what it needs. + */ +constexpr const char* kExportsParamName = "exports"; +constexpr const char* kModuleParamName = "module"; +constexpr const char* kBindingParamName = "binding"; +constexpr const char* kPrimordialsParamName = "primordials"; +constexpr size_t kParamCount = 4; + +/* + * Per-isolate intrinsics snapshot. Worker runtimes initialize on their own + * threads, so every access is under the mutex. + */ +std::mutex primordialsMutex; +robin_hood::unordered_map*> isolateToPrimordials; + +MaybeLocal CompileBuiltin(Local context, BuiltinId id) { + Isolate* isolate = v8::Isolate::GetCurrent(); + const BuiltinSource& builtin = GetBuiltinSource(id); + const unsigned index = static_cast(id); + + // Copy the blob out so the shared slot can be refreshed concurrently while + // this compile still reads from the copy. + std::vector blob; + { + std::lock_guard lock(builtinCacheMutex); + blob = builtinCache[index]; + } + + ScriptOrigin origin(ArgConverter::ConvertToV8String(isolate, builtin.name)); + Local sourceText = ArgConverter::ConvertToV8String( + isolate, builtin.source, static_cast(builtin.length)); + Local params[] = { + ArgConverter::ConvertToV8String(isolate, kExportsParamName), + ArgConverter::ConvertToV8String(isolate, kModuleParamName), + ArgConverter::ConvertToV8String(isolate, kBindingParamName), + ArgConverter::ConvertToV8String(isolate, kPrimordialsParamName)}; + + Local fn; + if (!blob.empty()) { + // The Source owns and deletes the CachedData object; BufferNotOwned + // keeps the underlying bytes (our copy) out of its hands. + auto* cachedData = new ScriptCompiler::CachedData( + blob.data(), static_cast(blob.size()), + ScriptCompiler::CachedData::BufferNotOwned); + ScriptCompiler::Source source(sourceText, origin, cachedData); + if (ScriptCompiler::CompileFunction(context, &source, kParamCount, params, 0, nullptr, + ScriptCompiler::kConsumeCodeCache) + .ToLocal(&fn) && + !cachedData->rejected) { + return fn; + } + // Rejected cache (e.g. produced under different flags): fall through + // and recompile eagerly so the refreshed blob covers inner functions + // again. + } + + ScriptCompiler::Source source(sourceText, origin); + if (!ScriptCompiler::CompileFunction(context, &source, kParamCount, params, 0, nullptr, + ScriptCompiler::kEagerCompile) + .ToLocal(&fn)) { + return MaybeLocal(); + } + + std::unique_ptr produced( + ScriptCompiler::CreateCodeCacheForFunction(fn)); + if (produced != nullptr && produced->data != nullptr && produced->length > 0) { + std::lock_guard lock(builtinCacheMutex); + builtinCache[index].assign(produced->data, produced->data + produced->length); + } + + return fn; +} + +MaybeLocal CallBuiltin(Local context, BuiltinId id, Local binding, + Local primordials) { + Isolate* isolate = v8::Isolate::GetCurrent(); + + Local fn; + if (!CompileBuiltin(context, id).ToLocal(&fn)) { + return MaybeLocal(); + } + + Local exportsObj = Object::New(isolate); + Local moduleObj = Object::New(isolate); + Local exportsKey = ArgConverter::ConvertToV8String(isolate, kExportsParamName); + if (!moduleObj->Set(context, exportsKey, exportsObj).FromMaybe(false)) { + return MaybeLocal(); + } + + Local args[] = {exportsObj, moduleObj, + binding.IsEmpty() ? Undefined(isolate).As() : binding, + primordials}; + if (fn->Call(context, Undefined(isolate), static_cast(kParamCount), args).IsEmpty()) { + return MaybeLocal(); + } + + return moduleObj->Get(context, exportsKey); +} + +/* + * Snapshot of the intrinsics, taken the first time any builtin runs in this + * isolate — during runtime init, before user code can replace a global. + * Builtins compiled later in the isolate's life get the same pristine + * snapshot. + */ +MaybeLocal GetPrimordials(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + + { + std::lock_guard lock(primordialsMutex); + auto it = isolateToPrimordials.find(isolate); + if (it != isolateToPrimordials.end()) { + return it->second->Get(isolate); + } + } + + Local result; + if (!CallBuiltin(context, BuiltinId::kPrimordials, Local(), Undefined(isolate)) + .ToLocal(&result) || + !result->IsObject()) { + return MaybeLocal(); + } + + Local primordials = result.As(); + { + std::lock_guard lock(primordialsMutex); + isolateToPrimordials.emplace(isolate, new Persistent(isolate, primordials)); + } + return primordials; +} + +} // namespace + +MaybeLocal BuiltinLoader::RunBuiltin(Local context, BuiltinId id, + Local binding) { + Local primordials; + if (!GetPrimordials(context).ToLocal(&primordials)) { + return MaybeLocal(); + } + + return CallBuiltin(context, id, binding, primordials); +} + +void BuiltinLoader::onDisposeIsolate(Isolate* isolate) { + std::lock_guard lock(primordialsMutex); + auto it = isolateToPrimordials.find(isolate); + if (it != isolateToPrimordials.end()) { + delete it->second; + isolateToPrimordials.erase(it); + } +} + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/BuiltinLoader.h b/test-app/runtime/src/main/cpp/BuiltinLoader.h new file mode 100644 index 000000000..89c242a5c --- /dev/null +++ b/test-app/runtime/src/main/cpp/BuiltinLoader.h @@ -0,0 +1,34 @@ +#ifndef BUILTINLOADER_H_ +#define BUILTINLOADER_H_ + +#include "generated/RuntimeBuiltins.h" +#include "v8.h" + +namespace tns { + +class BuiltinLoader { +public: + /* + * Compiles the builtin identified by id as a function body with the fixed + * parameters `exports`, `module`, `binding` (Node's module wrapper plus + * its internalBinding idiom) and `primordials`, calls it with the given + * bag of natives (or undefined when omitted) plus this isolate's frozen + * intrinsics snapshot, and returns the resulting `module.exports`. The + * snapshot is produced by the kPrimordials builtin on first use and cached + * per isolate, so it is taken before any user code can replace a global. + * Scripts carry an "internal/.js" origin so runtime frames are + * identifiable in stack traces. Compilation goes through a process-wide + * bytecode cache: the first run in the process compiles eagerly and + * populates the cache, later isolates (workers, which run on their own + * threads) consume it instead of re-parsing the source. + */ + static v8::MaybeLocal RunBuiltin( + v8::Local context, BuiltinId id, + v8::Local binding = v8::Local()); + + static void onDisposeIsolate(v8::Isolate* isolate); +}; + +} // namespace tns + +#endif /* BUILTINLOADER_H_ */ diff --git a/test-app/runtime/src/main/cpp/ErrorEvents.cpp b/test-app/runtime/src/main/cpp/ErrorEvents.cpp index 071c53b6e..b41ff1486 100644 --- a/test-app/runtime/src/main/cpp/ErrorEvents.cpp +++ b/test-app/runtime/src/main/cpp/ErrorEvents.cpp @@ -1,6 +1,7 @@ #include "ErrorEvents.h" #include "ArgConverter.h" +#include "BuiltinLoader.h" #include "NativeScriptAssert.h" #include "NativeScriptException.h" #include "Runtime.h" @@ -19,7 +20,7 @@ static Runtime* GetRuntimeOrNull(Isolate* isolate) { } /* - * Native function handed to the bootstrap IIFE as `nativeReportFatal(error, + * Native function handed to internal/error-events.js as `nativeReportFatal(error, * stackString)`. It runs the terminal tail (shim + log) WITHOUT re-dispatching * an event: reportError and listener-thrown errors have already gone through * JS dispatch, so dispatching again here would recurse. @@ -36,111 +37,6 @@ static void NativeReportFatalCallback(const FunctionCallbackInfo& info) { } void ErrorEvents::Init(Local context) { - /* - * WHATWG error-events layer, layered on top of the generic event - * primitives installed by Events::Init and ported from the iOS runtime. - * Plain (module-free) script, strict inside the IIFE, ES5-ish so it never - * depends on other runtime extensions. The IIFE is invoked with two - * arguments - the internal EventTarget backing the global (so native - * dispatch survives app code overwriting globalThis.dispatchEvent) and - * the native nativeReportFatal(error, stack) function that runs the - * terminal tail - and returns three closures bound to that backing store. - * ErrorEvent/PromiseRejectionEvent subclass the Event captured off - * globalThis at init time, which runs before any user code. - */ - auto source = R"js( - (function (globalTarget, nativeReportFatal) { - "use strict"; - var g = globalThis; - var Event = g.Event; - - function ErrorEvent(type, opts) { - opts = opts || {}; - Event.call(this, type, opts); - this.message = opts.message !== undefined ? String(opts.message) : ""; - this.filename = opts.filename !== undefined ? String(opts.filename) : ""; - this.lineno = opts.lineno !== undefined ? (opts.lineno | 0) : 0; - this.colno = opts.colno !== undefined ? (opts.colno | 0) : 0; - this.error = opts.error !== undefined ? opts.error : null; - } - ErrorEvent.prototype = Object.create(Event.prototype); - ErrorEvent.prototype.constructor = ErrorEvent; - - function PromiseRejectionEvent(type, opts) { - opts = opts || {}; - Event.call(this, type, opts); - this.promise = opts.promise; - this.reason = opts.reason; - } - PromiseRejectionEvent.prototype = Object.create(Event.prototype); - PromiseRejectionEvent.prototype.constructor = PromiseRejectionEvent; - - // A listener that throws must not stop other listeners: route the thrown - // value to the native fatal tail instead of ever recursively dispatching - // another `error` event from inside dispatch. - globalTarget._installListenerErrorReporter(function (e) { - try { nativeReportFatal(e, (e && e.stack) || ""); } catch (ignored) {} - }); - - g.reportError = function (e) { - if (arguments.length === 0) { - throw new TypeError("Failed to execute 'reportError': 1 argument required, but only 0 present."); - } - var ev = new ErrorEvent("error", { - message: (e && e.message !== undefined && e.message !== null) ? String(e.message) : String(e), - error: e, - cancelable: true - }); - if (globalTarget.dispatchEvent(ev)) { - nativeReportFatal(e, (e && e.stack) || ""); - } - }; - - g.ErrorEvent = ErrorEvent; - g.PromiseRejectionEvent = PromiseRejectionEvent; - - // Closures called by C++. They never look up globalThis.dispatchEvent, - // so they keep working even if app code overwrites it. - function dispatchErrorEvent(error, message, stack) { - var ev = new ErrorEvent("error", { - message: message !== undefined && message !== null ? String(message) : "", - error: error, - cancelable: true - }); - globalTarget.dispatchEvent(ev); - return ev.defaultPrevented; - } - function dispatchUnhandledRejection(promise, reason) { - var ev = new PromiseRejectionEvent("unhandledrejection", { - promise: promise, - reason: reason, - cancelable: true - }); - globalTarget.dispatchEvent(ev); - return ev.defaultPrevented; - } - function dispatchRejectionHandled(promise, reason) { - var ev = new PromiseRejectionEvent("rejectionhandled", { - promise: promise, - reason: reason, - cancelable: false - }); - globalTarget.dispatchEvent(ev); - } - function dispatchNativeUncaughtError(error, message, stack) { - var ev = new ErrorEvent("nativeuncaughterror", { - message: message !== undefined && message !== null ? String(message) : "", - error: error, - cancelable: true - }); - globalTarget.dispatchEvent(ev); - return ev.defaultPrevented; - } - - return [dispatchErrorEvent, dispatchUnhandledRejection, dispatchRejectionHandled, dispatchNativeUncaughtError]; - }) - )js"; - auto isolate = v8::Isolate::GetCurrent(); auto runtime = GetRuntimeOrNull(isolate); if (runtime == nullptr) { @@ -149,34 +45,25 @@ void ErrorEvents::Init(Local context) { if (runtime->GlobalEventTarget().IsEmpty()) { throw NativeScriptException("ErrorEvents::Init: Events::Init must run first"); } - Local globalTarget = runtime->GlobalEventTarget().Get(isolate); - - Local