diff --git a/.github/workflows/npm_release.yml b/.github/workflows/npm_release.yml index 905a6720..c003aacd 100644 --- a/.github/workflows/npm_release.yml +++ b/.github/workflows/npm_release.yml @@ -174,6 +174,46 @@ jobs: # "Existing file at -resultBundlePath". on_retry_command: rm -rf $TEST_FOLDER/test_results_attempt1.xcresult; mv $TEST_FOLDER/test_results.xcresult $TEST_FOLDER/test_results_attempt1.xcresult 2>/dev/null; for f in $TEST_FOLDER/test_results*; do [ "$f" = "$TEST_FOLDER/test_results_attempt1.xcresult" ] || rm -rf "$f"; done; xcrun simctl shutdown all new_command_on_retry: xcodebuild -project v8ios.xcodeproj -scheme TestRunner -resultBundlePath $TEST_FOLDER/test_results -destination platform\=iOS\ Simulator,OS\=latest,name\=iPhone\ 16\ Pro build test + # When the runtime suite fails it is almost always because the in-app + # Jasmine run died before POSTing results (crash or hang). The xcresult is + # black-box and captures nothing from inside the app, so collect the two + # things that actually explain it: the native crash report (.ips) and the + # simulator's unified log (the app's console.log / last spec before a stall). + # The watchdog in TestRunnerTests.swift prints which artifact to look at. + - name: Collect crash reports & simulator log (on failure) + if: ${{ failure() }} + run: | + DIAG="$TEST_FOLDER/diagnostics" + mkdir -p "$DIAG" + # Simulator app crashes land in the host's DiagnosticReports. + cp -R ~/Library/Logs/DiagnosticReports/. "$DIAG/DiagnosticReports/" 2>/dev/null || true + cp -R ~/Library/Logs/CoreSimulator/. "$DIAG/CoreSimulator/" 2>/dev/null || true + # Unified log = the app's console output (so the last spec before a hang + # is visible even when nothing was POSTed). `log collect` needs a booted + # device; don't rely on the `booted` alias (the prior collect failed + # because the sim wasn't booted at that moment). Resolve a concrete UDID + # — prefer one already booted from the test run, else the test device, + # booting it so the persisted log store can be collected. + UDID="$(xcrun simctl list devices booted | grep -oE '[0-9A-Fa-f-]{36}' | head -1)" + if [ -z "$UDID" ]; then + UDID="$(xcrun simctl list devices 'iPhone 16 Pro' | grep -oE '[0-9A-Fa-f-]{36}' | head -1)" + [ -n "$UDID" ] && xcrun simctl boot "$UDID" 2>/dev/null || true + [ -n "$UDID" ] && xcrun simctl bootstatus "$UDID" 2>/dev/null || true + fi + if [ -n "$UDID" ]; then + echo "Collecting unified log from simulator $UDID" + xcrun simctl spawn "$UDID" log collect --output "$DIAG/simulator.logarchive" 2>/dev/null || true + else + echo "No simulator UDID resolved; skipping logarchive collection." + fi + echo "Collected diagnostics:"; ls -laR "$DIAG" 2>/dev/null || true + - name: Upload test diagnostics (on failure) + if: ${{ failure() }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: test-diagnostics + path: ${{ env.TEST_FOLDER }}/diagnostics + if-no-files-found: ignore - name: Validate Test Results run: | xcparse attachments $TEST_FOLDER/test_results.xcresult $TEST_FOLDER/test-out diff --git a/.gitignore b/.gitignore index fbfa1d78..01fd062c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ Thumbs.db # VSCode .vscode +# Generated by tools/js2c.mjs (Xcode "Generate RuntimeBuiltins" build phase) +NativeScript/runtime/generated/ + # Other node_modules/ package-lock.json diff --git a/NativeScript/NativeScript.mm b/NativeScript/NativeScript.mm index caf37186..1ed91eb9 100644 --- a/NativeScript/NativeScript.mm +++ b/NativeScript/NativeScript.mm @@ -3,6 +3,7 @@ #include "inspector/JsV8InspectorClient.h" #include "runtime/Console.h" #include "runtime/Helpers.h" +#include "runtime/ModuleInternalCallbacks.h" #include "runtime/Runtime.h" #include "runtime/RuntimeConfig.h" #include "runtime/Tasks.h" @@ -43,6 +44,23 @@ - (void)runMainApplication { CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0, true); tns::Tasks::Drain(); + + // Async-pipeline boot handoff. For UI apps Tasks::Drain() invokes + // UIApplicationMain and never returns — the app's main runloop services + // any in-flight async module loads. When Drain returns (the entry never + // called UIApplicationMain — e.g. a top-level-await entry still loading + // its graph), pump a manual runloop until the pending module work + // settles, Node-like. A load completion may itself register the + // UIApplicationMain task, so drain after each slice; if that drain calls + // UIApplicationMain, it takes over from here and never returns. + if (tns::HasPendingAsyncModuleGraphWork()) { + const CFAbsoluteTime deadline = CFAbsoluteTimeGetCurrent() + 120.0; + while (tns::HasPendingAsyncModuleGraphWork() && CFAbsoluteTimeGetCurrent() < deadline) { + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.01, true); + tns::Tasks::Drain(); + } + tns::Tasks::Drain(); + } } - (bool)liveSync { diff --git a/NativeScript/runtime/BuiltinLoader.cpp b/NativeScript/runtime/BuiltinLoader.cpp new file mode 100644 index 00000000..f5c1c909 --- /dev/null +++ b/NativeScript/runtime/BuiltinLoader.cpp @@ -0,0 +1,212 @@ +#include "BuiltinLoader.h" + +#include +#include + +#include "Caches.h" +#include "Helpers.h" +#include "NsBuiltinModules.h" + +using namespace v8; + +namespace tns { + +namespace { + +// Process-wide bytecode cache shared across isolates (main + workers). +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`, reaches sibling builtin modules through +// `require`, 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* kRequireParamName = "require"; +constexpr const char* kModuleParamName = "module"; +constexpr const char* kBindingParamName = "binding"; +constexpr const char* kPrimordialsParamName = "primordials"; +constexpr int kParamCount = 5; + +// The `require` every builtin receives: builtin specifiers only, so a builtin +// can never reach application code or the filesystem. +void BuiltinRequireCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsString()) { + isolate->ThrowException(Exception::TypeError( + tns::ToV8String(isolate, "require() expects a specifier string"))); + return; + } + + Local context = isolate->GetCurrentContext(); + std::string specifier = tns::ToString(isolate, info[0].As()); + Local exports; + if (NsBuiltinModules::GetExports(context, specifier).ToLocal(&exports)) { + info.GetReturnValue().Set(exports); + } else if (!NsBuiltinModules::IsRegistered(specifier)) { + isolate->ThrowException(Exception::Error(tns::ToV8String( + isolate, NsBuiltinModules::NotFoundMessage(specifier)))); + } +} + +MaybeLocal GetBuiltinRequire(Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + std::shared_ptr cache = Caches::Get(isolate); + if (cache->BuiltinRequire != nullptr) { + return cache->BuiltinRequire->Get(isolate); + } + + Local require; + if (!v8::Function::New(context, BuiltinRequireCallback, Local(), 1, + ConstructorBehavior::kThrow) + .ToLocal(&require)) { + return MaybeLocal(); + } + cache->BuiltinRequire = + std::make_unique>(isolate, require); + return require; +} + +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(tns::ToV8String(isolate, builtin.name), + 0, // line offset + 0, // column offset + false, // shared_cross_origin + -1, // script_id + Local(), + false, // is_opaque + false, // is_wasm + false // is_module + ); + Local sourceText = tns::ToV8String( + isolate, builtin.source, static_cast(builtin.length)); + Local params[] = { + tns::ToV8String(isolate, kExportsParamName), + tns::ToV8String(isolate, kRequireParamName), + tns::ToV8String(isolate, kModuleParamName), + tns::ToV8String(isolate, kBindingParamName), + tns::ToV8String(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 require; + if (!GetBuiltinRequire(context).ToLocal(&require)) { + return MaybeLocal(); + } + + Local exportsObj = Object::New(isolate); + Local moduleObj = Object::New(isolate); + Local exportsKey = tns::ToV8String(isolate, kExportsParamName); + if (!moduleObj->Set(context, exportsKey, exportsObj).FromMaybe(false)) { + return MaybeLocal(); + } + + Local args[] = { + exportsObj, require, moduleObj, + binding.IsEmpty() ? v8::Undefined(isolate).As() : binding, + primordials}; + if (fn->Call(context, v8::Undefined(isolate), 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::shared_ptr cache = Caches::Get(isolate); + if (cache->Primordials != nullptr) { + return cache->Primordials->Get(isolate); + } + + Local result; + if (!CallBuiltin(context, BuiltinId::kPrimordials, Local(), + v8::Undefined(isolate)) + .ToLocal(&result) || + !result->IsObject()) { + return MaybeLocal(); + } + + Local primordials = result.As(); + cache->Primordials = + std::make_unique>(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); +} + +} // namespace tns diff --git a/NativeScript/runtime/BuiltinLoader.h b/NativeScript/runtime/BuiltinLoader.h new file mode 100644 index 00000000..fe7ae8c4 --- /dev/null +++ b/NativeScript/runtime/BuiltinLoader.h @@ -0,0 +1,32 @@ +#ifndef BuiltinLoader_h +#define BuiltinLoader_h + +#include "Common.h" +#include "RuntimeBuiltins.h" + +namespace tns { + +class BuiltinLoader { + public: + // Compiles the builtin identified by id as a function body with the fixed + // parameters `exports`, `require`, `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`. `require` + // reaches the builtin modules (NsBuiltinModules) and nothing else. 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) consume it + // instead of re-parsing the source. + static v8::MaybeLocal RunBuiltin( + v8::Local context, BuiltinId id, + v8::Local binding = v8::Local()); +}; + +} // namespace tns + +#endif /* BuiltinLoader_h */ diff --git a/NativeScript/runtime/Caches.h b/NativeScript/runtime/Caches.h index 40391fcf..1b62c19b 100644 --- a/NativeScript/runtime/Caches.h +++ b/NativeScript/runtime/Caches.h @@ -152,8 +152,13 @@ class Caches { std::unique_ptr>(nullptr); std::unique_ptr> WeakRefClearFunc = std::unique_ptr>(nullptr); - std::unique_ptr> SmartJSONStringifyFunc = + // console formatter (internal/inspect.js), initialized by Console::Init. + std::unique_ptr> InspectFunc = std::unique_ptr>(nullptr); + // ns:util's format, used by console.* for %-substitution. + std::unique_ptr> FormatFunc = + std::unique_ptr>(nullptr); + bool FormatFuncUnavailable = false; std::unique_ptr> InteropReferenceCtorFunc = std::unique_ptr>(nullptr); std::unique_ptr> PointerCtorFunc = @@ -163,6 +168,28 @@ class Caches { std::unique_ptr> UnmanagedTypeCtorFunc = std::unique_ptr>(nullptr); + // `ns:`/`node:` builtin modules (NsBuiltinModules), keyed by specifier. Both + // are per isolate: a builtin module is a singleton per realm, so workers get + // their own exports objects and their own synthetic modules. + robin_hood::unordered_map>> + BuiltinModuleExports; + robin_hood::unordered_map>> + BuiltinModules; + // Specifiers currently being built, so a shim requiring back into the module + // that is loading it fails instead of recursing. + robin_hood::unordered_set BuiltinModulesInProgress; + // The `require` handed to every builtin, resolving builtin specifiers only. + std::unique_ptr> BuiltinRequire = + std::unique_ptr>(nullptr); + + // Frozen intrinsics snapshot returned by internal/primordials.js, passed to + // every builtin as its second fixed parameter (BuiltinLoader::RunBuiltin). + // Per isolate, so workers snapshot their own realm's intrinsics. + std::unique_ptr> Primordials = + std::unique_ptr>(nullptr); + // Internal EventTarget instance backing the global, returned by the generic // event-primitives bootstrap IIFE (Events::Init). Holds the real listener // store, so native layers dispatch through it without going through diff --git a/NativeScript/runtime/ClassBuilder.mm b/NativeScript/runtime/ClassBuilder.mm index 4b7a552b..75936775 100644 --- a/NativeScript/runtime/ClassBuilder.mm +++ b/NativeScript/runtime/ClassBuilder.mm @@ -3,6 +3,7 @@ #include #include #include "ArgConverter.h" +#include "BuiltinLoader.h" #include "Caches.h" #include "FastEnumerationAdapter.h" #include "Helpers.h" @@ -162,27 +163,10 @@ return; } - std::string extendsFuncScript = "(function() { " - " function __extends(d, b) { " - " for (var p in b) {" - " if (b.hasOwnProperty(p)) {" - " d[p] = b[p];" - " }" - " }" - " function __() { this.constructor = d; }" - " d.prototype = b === null ? Object.create(b) : " - "(__.prototype = b.prototype, new __());" - " } " - " return __extends;" - "})()"; - - Local