From 0e3513d99e940001ac8630e8770756a43c3768da Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 12 Aug 2026 17:26:40 -0700 Subject: [PATCH 01/36] feat(runtime): ESM resolver hardening and async module-graph loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonicalize module identity into three registry shapes — http(s) URLs, custom schemes (node:, blob:, optional:), and absolute file paths — and key the module registries by v8::Isolate instead of thread_local storage. import() now rejects missing bare specifiers instead of installing placeholders; optional-module placeholders are built without string interpolation, detection is unified in IsLikelyOptionalModule, and module source preserves embedded NUL bytes. Thenables handed to the loader from JS are adopted properly. Blob URLs (blob:nativescript/) become first-class module identities via URL.createObjectURL and URL.InternalAccessor. The prewarm/prefetch machinery is replaced by an async module-graph loader; boot hands off to a manual runloop that pumps pending module work when the entry script has not reached the main looper yet (e.g. a top-level-await entry still loading its graph). Load surfaces the failure cause to callers, and relative import() against a filesystem referrer keeps the already-absolute path instead of prefixing the application root twice. --- test-app/runtime/CMakeLists.txt | 3 +- test-app/runtime/src/main/cpp/HttpLoader.cpp | 1046 ++++ test-app/runtime/src/main/cpp/HttpLoader.h | 179 + .../runtime/src/main/cpp/MetadataNode.cpp | 60 +- .../runtime/src/main/cpp/ModuleInternal.cpp | 170 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 4405 +++++++++++++---- .../src/main/cpp/ModuleInternalCallbacks.h | 139 +- test-app/runtime/src/main/cpp/Runtime.cpp | 30 +- test-app/runtime/src/main/cpp/Runtime.h | 4 + .../src/main/java/com/tns/DexFactory.java | 2 +- 10 files changed, 5046 insertions(+), 992 deletions(-) create mode 100644 test-app/runtime/src/main/cpp/HttpLoader.cpp create mode 100644 test-app/runtime/src/main/cpp/HttpLoader.h diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index ef8a0d782..8b1fcdb9c 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -227,8 +227,7 @@ add_library( src/main/cpp/URLImpl.cpp src/main/cpp/URLSearchParamsImpl.cpp src/main/cpp/URLPatternImpl.cpp - src/main/cpp/HMRSupport.cpp - src/main/cpp/DevFlags.cpp + src/main/cpp/HttpLoader.cpp # Node-API: vendored upstream implementation plus the embedder half # (env lifecycle, module registry, async work, threadsafe functions) diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp new file mode 100644 index 000000000..8d26e6f12 --- /dev/null +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -0,0 +1,1046 @@ +#include "HttpLoader.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ArgConverter.h" +#include "JEnv.h" +#include "ModuleInternalCallbacks.h" +#include "NativeScriptAssert.h" +#include "NativeScriptException.h" +#include "Runtime.h" +#include "robin_hood.h" + +namespace tns { + +static inline bool StartsWith(const std::string& s, const char* prefix) { + size_t n = strlen(prefix); + return s.size() >= n && s.compare(0, n, prefix) == 0; +} + +static inline v8::Local ToV8String(v8::Isolate* isolate, const char* str) { + return ArgConverter::ConvertToV8String(isolate, str ? std::string(str) : std::string()); +} + +static inline v8::Local ToV8String(v8::Isolate* isolate, const std::string& str) { + return ArgConverter::ConvertToV8String(isolate, str); +} + +// ───────────────────────────────────────────────────────────── +// Live ns:runtime log flags (boot default from Java, then setConfig) + +static std::atomic g_logScriptLoading{false}; +static std::atomic g_httpFetchUrlLog{false}; +static std::once_flag s_logFlagsInitFlag; + +static void EnsureLogFlagsInitialized() { + std::call_once(s_logFlagsInitFlag, []() { + try { + JEnv env; + jclass runtimeClass = env.FindClass("com/tns/Runtime"); + if (runtimeClass == nullptr) { + return; + } + jmethodID logMid = + env.GetStaticMethodID(runtimeClass, "getLogScriptLoadingEnabled", "()Z"); + if (logMid != nullptr) { + g_logScriptLoading.store(env.CallStaticBooleanMethod(runtimeClass, logMid) == + JNI_TRUE, + std::memory_order_relaxed); + } + jmethodID urlLogMid = + env.GetStaticMethodID(runtimeClass, "getHttpFetchUrlLogEnabled", "()Z"); + if (urlLogMid != nullptr) { + g_httpFetchUrlLog.store(env.CallStaticBooleanMethod(runtimeClass, urlLogMid) == + JNI_TRUE, + std::memory_order_relaxed); + } + } catch (...) { + // keep defaults (false) + } + }); +} + +bool IsScriptLoadingLogEnabled() { + EnsureLogFlagsInitialized(); + return g_logScriptLoading.load(std::memory_order_relaxed); +} + +void SetScriptLoadingLogEnabled(bool enabled) { + EnsureLogFlagsInitialized(); + g_logScriptLoading.store(enabled, std::memory_order_relaxed); +} + +bool IsHttpFetchUrlLogEnabled() { + EnsureLogFlagsInitialized(); + return g_httpFetchUrlLog.load(std::memory_order_relaxed); +} + +void SetHttpFetchUrlLogEnabled(bool enabled) { + EnsureLogFlagsInitialized(); + g_httpFetchUrlLog.store(enabled, std::memory_order_relaxed); +} + +// ───────────────────────────────────────────────────────────── +// Remote-module security gate + +static std::once_flag s_securityConfigInitFlag; +static bool s_allowRemoteModules = false; +static std::vector s_remoteModuleAllowlist; +static bool s_isDebuggable = false; + +static bool RemoteUrlMatchesAllowlistEntry(const std::string& url, const std::string& entry) { + if (entry.empty()) return false; + if (url.size() < entry.size()) return false; + if (url.compare(0, entry.size(), entry) != 0) return false; + if (url.size() == entry.size()) return true; + if (entry.back() == '/') return true; + const char next = url[entry.size()]; + return next == '/' || next == '?' || next == '#'; +} + +static void InitializeSecurityConfig() { + std::call_once(s_securityConfigInitFlag, []() { + try { + JEnv env; + jclass runtimeClass = env.FindClass("com/tns/Runtime"); + if (runtimeClass == nullptr) { + return; + } + + jmethodID isDebuggableMid = env.GetStaticMethodID(runtimeClass, "isDebuggable", "()Z"); + if (isDebuggableMid != nullptr) { + s_isDebuggable = env.CallStaticBooleanMethod(runtimeClass, isDebuggableMid) == + JNI_TRUE; + } + + if (s_isDebuggable) { + s_allowRemoteModules = true; + return; + } + + jmethodID allowRemoteMid = + env.GetStaticMethodID(runtimeClass, "isRemoteModulesAllowed", "()Z"); + if (allowRemoteMid != nullptr) { + s_allowRemoteModules = + env.CallStaticBooleanMethod(runtimeClass, allowRemoteMid) == JNI_TRUE; + } + + jmethodID getAllowlistMid = env.GetStaticMethodID( + runtimeClass, "getRemoteModuleAllowlist", "()[Ljava/lang/String;"); + if (getAllowlistMid != nullptr) { + jobjectArray allowlistArray = static_cast( + env.CallStaticObjectMethod(runtimeClass, getAllowlistMid)); + if (allowlistArray != nullptr) { + jsize len = env.GetArrayLength(allowlistArray); + for (jsize i = 0; i < len; i++) { + jstring jstr = + static_cast(env.GetObjectArrayElement(allowlistArray, i)); + if (jstr != nullptr) { + const char* str = env.GetStringUTFChars(jstr, nullptr); + if (str != nullptr) { + s_remoteModuleAllowlist.emplace_back(str); + env.ReleaseStringUTFChars(jstr, str); + } + env.DeleteLocalRef(jstr); + } + } + env.DeleteLocalRef(allowlistArray); + } + } + } catch (...) { + // Keep defaults (remote modules disabled) + } + }); +} + +bool IsDebuggable() { + InitializeSecurityConfig(); + return s_isDebuggable; +} + +bool IsRemoteModulesAllowed() { + if (IsDebuggable()) { + return true; + } + InitializeSecurityConfig(); + return s_allowRemoteModules; +} + +bool IsRemoteUrlAllowed(const std::string& url) { + if (IsDebuggable()) { + return true; + } + + InitializeSecurityConfig(); + if (!s_allowRemoteModules) { + return false; + } + + if (s_remoteModuleAllowlist.empty()) { + return true; + } + + for (const std::string& entry : s_remoteModuleAllowlist) { + if (RemoteUrlMatchesAllowlistEntry(url, entry)) { + return true; + } + } + + return false; +} + +static void SetBooleanGlobal(v8::Isolate* isolate, v8::Local context, const char* key, + bool value) { + context->Global() + ->Set(context, ToV8String(isolate, key), v8::Boolean::New(isolate, value)) + .FromMaybe(false); +} + +// ───────────────────────────────────────────────────────────── +// Dev-boot completion flag + +static std::atomic g_devSessionBootComplete{false}; + +static inline bool IsDevSessionBootComplete() { + return g_devSessionBootComplete.load(std::memory_order_relaxed); +} + +void SetDevBootComplete(v8::Isolate* isolate, v8::Local context, bool value) { + SetBooleanGlobal(isolate, context, "__NS_HMR_BOOT_COMPLETE__", value); + g_devSessionBootComplete.store(value, std::memory_order_relaxed); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[dev-boot] __NS_HMR_BOOT_COMPLETE__=%s", value ? "true" : "false"); + } +} + +// ───────────────────────────────────────────────────────────── +// Canonicalization vocabulary + +struct CanonicalizationConfig { + std::vector stripParams; + std::vector devPathPrefixes; + std::vector preserveQueryPrefixes; +}; +static CanonicalizationConfig g_canonConfig; +static bool g_canonConfigured = false; + +static void SetCanonicalizationConfig(CanonicalizationConfig config) { + g_canonConfig = std::move(config); + g_canonConfigured = true; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[ns:module configureLoader] canonicalization set (strip=%lu devPrefixes=%lu " + "preserve=%lu)", + (unsigned long)g_canonConfig.stripParams.size(), + (unsigned long)g_canonConfig.devPathPrefixes.size(), + (unsigned long)g_canonConfig.preserveQueryPrefixes.size()); + } +} + +static void ResetCanonicalizationConfig() { + g_canonConfig = CanonicalizationConfig{}; + g_canonConfigured = false; +} + +std::string CanonicalizeHttpUrlKey(const std::string& url) { + std::string normalizedUrl = url; + if (StartsWith(normalizedUrl, "file://http://") || StartsWith(normalizedUrl, "file://https://")) { + normalizedUrl = normalizedUrl.substr(strlen("file://")); + } + if (!(StartsWith(normalizedUrl, "http://") || StartsWith(normalizedUrl, "https://"))) { + return normalizedUrl; + } + size_t hashPos = normalizedUrl.find('#'); + std::string noHash = + (hashPos == std::string::npos) ? normalizedUrl : normalizedUrl.substr(0, hashPos); + + size_t schemePos = noHash.find("://"); + if (schemePos == std::string::npos) { + size_t q = noHash.find('?'); + return (q == std::string::npos) ? noHash : noHash.substr(0, q); + } + size_t pathStart = noHash.find('/', schemePos + 3); + if (pathStart == std::string::npos) { + return noHash; + } + size_t qPos = noHash.find('?', pathStart); + std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos); + std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1); + + { + std::string pathOnly = originAndPath.substr(pathStart); + if (g_canonConfigured) { + for (const auto& p : g_canonConfig.preserveQueryPrefixes) { + if (!p.empty() && pathOnly.find(p) != std::string::npos) { + return noHash; + } + } + bool isDevEndpoint = false; + for (const auto& p : g_canonConfig.devPathPrefixes) { + if (!p.empty() && StartsWith(pathOnly, p.c_str())) { + isDevEndpoint = true; + break; + } + } + if (!isDevEndpoint) { + return noHash; + } + } else { + if (pathOnly.find("/@ng/component") != std::string::npos) { + return noHash; + } + const bool isDevEndpoint = StartsWith(pathOnly, "/ns/") || + StartsWith(pathOnly, "/node_modules/.vite/") || + StartsWith(pathOnly, "/@id/") || + StartsWith(pathOnly, "/@fs/"); + if (!isDevEndpoint) { + return noHash; + } + } + } + + if (query.empty()) return originAndPath; + + std::vector kept; + size_t start = 0; + while (start <= query.size()) { + size_t amp = query.find('&', start); + std::string pair = + (amp == std::string::npos) ? query.substr(start) : query.substr(start, amp - start); + if (!pair.empty()) { + size_t eq = pair.find('='); + std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); + bool drop; + if (g_canonConfigured) { + drop = std::find(g_canonConfig.stripParams.begin(), g_canonConfig.stripParams.end(), + name) != g_canonConfig.stripParams.end(); + } else { + drop = (name == "import" || name == "t" || name == "v"); + } + if (!drop) kept.push_back(pair); + } + if (amp == std::string::npos) break; + start = amp + 1; + } + if (kept.empty()) return originAndPath; + std::sort(kept.begin(), kept.end()); + std::string rebuilt = originAndPath + "?"; + for (size_t i = 0; i < kept.size(); i++) { + if (i > 0) rebuilt += "&"; + rebuilt += kept[i]; + } + return rebuilt; +} + +// ───────────────────────────────────────────────────────────── +// Eviction-driven fetch cache-bust + +static std::mutex g_bustNextFetchMutex; +static robin_hood::unordered_set g_bustNextFetchKeys; + +void MarkUrlsForCacheBust(const std::vector& urls) { + if (urls.empty()) return; + std::lock_guard lock(g_bustNextFetchMutex); + for (const auto& url : urls) { + if (url.empty()) continue; + if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) continue; + g_bustNextFetchKeys.insert(CanonicalizeHttpUrlKey(url)); + } +} + +static bool IsUrlMarkedForCacheBust(const std::string& url) { + std::lock_guard lock(g_bustNextFetchMutex); + if (g_bustNextFetchKeys.empty()) return false; + return g_bustNextFetchKeys.find(CanonicalizeHttpUrlKey(url)) != g_bustNextFetchKeys.end(); +} + +static void ClearCacheBustForUrl(const std::string& url) { + std::lock_guard lock(g_bustNextFetchMutex); + if (g_bustNextFetchKeys.empty()) return; + g_bustNextFetchKeys.erase(CanonicalizeHttpUrlKey(url)); +} + +static void ClearAllCacheBustMarks() { + std::lock_guard lock(g_bustNextFetchMutex); + g_bustNextFetchKeys.clear(); +} + +// ───────────────────────────────────────────────────────────── +// JNI fetch diagnostics + request builder + +static thread_local std::string g_lastHttpFetchErrorReason; + +static void RecordLastHttpFetchError(const char* stage, const std::string& excClass, + const std::string& excMsg) { + g_lastHttpFetchErrorReason.assign("stage="); + g_lastHttpFetchErrorReason.append(stage ? stage : "?"); + g_lastHttpFetchErrorReason.append(" class="); + g_lastHttpFetchErrorReason.append(excClass); + g_lastHttpFetchErrorReason.append(" msg="); + g_lastHttpFetchErrorReason.append(excMsg); +} + +static void ClearLastHttpFetchErrorReason() { + g_lastHttpFetchErrorReason.clear(); +} + +std::string TakeLastHttpFetchErrorReason() { + std::string out = std::move(g_lastHttpFetchErrorReason); + g_lastHttpFetchErrorReason.clear(); + return out; +} + +static bool DrainPendingJniException(JEnv& env, std::string& outClassName, std::string& outMessage) { + outClassName.clear(); + outMessage.clear(); + jthrowable th = env.ExceptionOccurred(); + if (!th) return false; + env.ExceptionClear(); + + jclass clsThrowable = env.GetObjectClass(th); + if (clsThrowable) { + jclass clsClass = env.FindClass("java/lang/Class"); + if (clsClass) { + jmethodID getName = env.GetMethodID(clsClass, "getName", "()Ljava/lang/String;"); + if (getName) { + jstring jName = static_cast(env.CallObjectMethod(clsThrowable, getName)); + env.ExceptionClear(); + if (jName) { + outClassName = ArgConverter::jstringToString(jName); + } + } + } + jmethodID toString = env.GetMethodID(clsThrowable, "toString", "()Ljava/lang/String;"); + if (toString) { + jstring jMsg = static_cast(env.CallObjectMethod(th, toString)); + env.ExceptionClear(); + if (jMsg) { + outMessage = ArgConverter::jstringToString(jMsg); + } + } + } + env.ExceptionClear(); + return true; +} + +static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, + std::string& contentType, int& status); +static void MaybePumpJSThreadDuringBoot(); +static inline void InvokeHttpFetchYield(); + +static std::string ApplyCacheBustNonce(const std::string& url, bool* outBustRequested) { + std::string fetchUrl = url; + const bool bustRequested = IsUrlMarkedForCacheBust(url); + if (outBustRequested) *outBustRequested = bustRequested; + if (bustRequested) { + static std::atomic s_fetchSeq{0}; + const uint64_t seq = s_fetchSeq.fetch_add(1, std::memory_order_relaxed); + const uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count()); + fetchUrl += (url.find('?') == std::string::npos) ? '?' : '&'; + fetchUrl += "__ns_dev_nonce="; + fetchUrl += std::to_string(nowMs); + fetchUrl += "-"; + fetchUrl += std::to_string(seq); + } + return fetchUrl; +} + +static void DisableHttpKeepAliveOnce(JEnv& env) { + static std::atomic sKeepAliveDisabled{false}; + if (sKeepAliveDisabled.exchange(true)) { + return; + } + jclass clsSystem = env.FindClass("java/lang/System"); + if (clsSystem) { + jmethodID setProperty = env.GetStaticMethodID( + clsSystem, "setProperty", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"); + if (setProperty) { + jstring jKey = env.NewStringUTF("http.keepAlive"); + jstring jVal = env.NewStringUTF("false"); + env.CallStaticObjectMethod(clsSystem, setProperty, jKey, jVal); + env.ExceptionClear(); + } + } +} + +static void PermitAllStrictMode(JEnv& env) { + jclass clsStrict = env.FindClass("android/os/StrictMode"); + jclass clsPolicyBuilder = env.FindClass("android/os/StrictMode$ThreadPolicy$Builder"); + if (!clsStrict || !clsPolicyBuilder) { + return; + } + jmethodID builderCtor = env.GetMethodID(clsPolicyBuilder, "", "()V"); + jobject builder = env.NewObject(clsPolicyBuilder, builderCtor); + if (!builder) { + return; + } + jmethodID permitAll = env.GetMethodID(clsPolicyBuilder, "permitAll", + "()Landroid/os/StrictMode$ThreadPolicy$Builder;"); + jobject builder2 = permitAll ? env.CallObjectMethod(builder, permitAll) : builder; + jmethodID build = env.GetMethodID(clsPolicyBuilder, "build", + "()Landroid/os/StrictMode$ThreadPolicy;"); + jobject policy = build ? env.CallObjectMethod(builder2 ? builder2 : builder, build) : nullptr; + if (policy) { + jmethodID setThreadPolicy = env.GetStaticMethodID( + clsStrict, "setThreadPolicy", "(Landroid/os/StrictMode$ThreadPolicy;)V"); + if (setThreadPolicy) { + env.CallStaticVoidMethod(clsStrict, setThreadPolicy, policy); + } + } +} + +bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status) { + out.clear(); + contentType.clear(); + status = 0; + ClearLastHttpFetchErrorReason(); + + if (!IsRemoteUrlAllowed(url)) { + status = 403; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-esm][security][blocked] %s", url.c_str()); + } + return false; + } + + const bool urlLogEnabled = IsHttpFetchUrlLogEnabled(); + const auto netStart = urlLogEnabled ? std::chrono::steady_clock::now() + : std::chrono::steady_clock::time_point{}; + + bool ok = PerformHttpFetchOnceSync(url, out, contentType, status); + if (!ok) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-loader] retrying %s after initial fetch error", url.c_str()); + } + usleep(120 * 1000); + ok = PerformHttpFetchOnceSync(url, out, contentType, status); + } + if (!ok || status < 200 || status >= 300) { + return false; + } + if (out.empty()) { + out = "export {};\n"; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-loader] empty 2xx body for %s — serving canonical empty module", + url.c_str()); + } + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-loader] fetched status=%d content-type=%s bytes=%llu", status, + contentType.empty() ? "" : contentType.c_str(), + (unsigned long long)out.size()); + } + if (urlLogEnabled) { + const auto netMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - netStart) + .count(); + DEBUG_WRITE_FORCE("[http-loader][fetch][network] %s bytes=%lu ms=%lld", url.c_str(), + (unsigned long)out.size(), (long long)netMs); + } + + InvokeHttpFetchYield(); + return true; +} + +static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, + std::string& contentType, int& status) { + out.clear(); + contentType.clear(); + status = 0; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-esm][fetch][enter] url=%s", url.c_str()); + } + + bool bustRequested = false; + const std::string fetchUrl = ApplyCacheBustNonce(url, &bustRequested); + + try { + JEnv env; + DisableHttpKeepAliveOnce(env); + PermitAllStrictMode(env); + + jclass clsURL = env.FindClass("java/net/URL"); + if (!clsURL) return false; + jmethodID urlCtor = env.GetMethodID(clsURL, "", "(Ljava/lang/String;)V"); + jmethodID openConnection = + env.GetMethodID(clsURL, "openConnection", "()Ljava/net/URLConnection;"); + jstring jUrlStr = env.NewStringUTF(fetchUrl.c_str()); + jobject urlObj = env.NewObject(clsURL, urlCtor, jUrlStr); + + { + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("url-ctor", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-esm][fetch][exception] stage=url-ctor url=%s class=%s msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + return false; + } + } + + jobject conn = env.CallObjectMethod(urlObj, openConnection); + { + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("open-connection", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-esm][fetch][exception] stage=open-connection url=%s class=%s " + "msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + return false; + } + } + if (!conn) return false; + + jclass clsConn = env.GetObjectClass(conn); + jmethodID setConnectTimeout = env.GetMethodID(clsConn, "setConnectTimeout", "(I)V"); + jmethodID setReadTimeout = env.GetMethodID(clsConn, "setReadTimeout", "(I)V"); + jmethodID setDoInput = env.GetMethodID(clsConn, "setDoInput", "(Z)V"); + jmethodID setUseCaches = env.GetMethodID(clsConn, "setUseCaches", "(Z)V"); + jmethodID setReqProp = + env.GetMethodID(clsConn, "setRequestProperty", "(Ljava/lang/String;Ljava/lang/String;)V"); + env.CallVoidMethod(conn, setConnectTimeout, 15000); + env.CallVoidMethod(conn, setReadTimeout, 15000); + if (setDoInput) { + env.CallVoidMethod(conn, setDoInput, JNI_TRUE); + } + if (setUseCaches) { + env.CallVoidMethod(conn, setUseCaches, JNI_FALSE); + } + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Accept"), + env.NewStringUTF("application/javascript, text/javascript, */*;q=0.1")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Accept-Encoding"), + env.NewStringUTF("identity")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Cache-Control"), + env.NewStringUTF("no-cache, no-store, max-age=0")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Pragma"), + env.NewStringUTF("no-cache")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Connection"), + env.NewStringUTF("close")); + env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("User-Agent"), + env.NewStringUTF("NativeScript-HTTP-ESM")); + + jclass clsHttp = env.FindClass("java/net/HttpURLConnection"); + bool isHttp = clsHttp && env.IsInstanceOf(conn, clsHttp); + jmethodID getResponseCode = + isHttp ? env.GetMethodID(clsHttp, "getResponseCode", "()I") : nullptr; + jmethodID getErrorStream = + isHttp ? env.GetMethodID(clsHttp, "getErrorStream", "()Ljava/io/InputStream;") + : nullptr; + if (isHttp && getResponseCode) { + status = env.CallIntMethod(conn, getResponseCode); + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("get-response-code", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-esm][fetch][exception] stage=get-response-code url=%s class=%s " + "msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + return false; + } + } + + jmethodID getInputStream = + env.GetMethodID(clsConn, "getInputStream", "()Ljava/io/InputStream;"); + jobject inStream = nullptr; + if (isHttp && status >= 400 && getErrorStream) { + inStream = env.CallObjectMethod(conn, getErrorStream); + } + if (!inStream) { + inStream = env.CallObjectMethod(conn, getInputStream); + } + { + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("get-input-stream", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-esm][fetch][exception] stage=get-input-stream url=%s class=%s " + "msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + return false; + } + } + if (!inStream) return false; + + jclass clsIS = env.GetObjectClass(inStream); + jmethodID readMethod = env.GetMethodID(clsIS, "read", "([B)I"); + jmethodID closeIS = env.GetMethodID(clsIS, "close", "()V"); + + jclass clsBAOS = env.FindClass("java/io/ByteArrayOutputStream"); + jmethodID baosCtor = env.GetMethodID(clsBAOS, "", "()V"); + jmethodID baosWrite = env.GetMethodID(clsBAOS, "write", "([BII)V"); + jmethodID baosToByteArray = env.GetMethodID(clsBAOS, "toByteArray", "()[B"); + jmethodID baosClose = env.GetMethodID(clsBAOS, "close", "()V"); + jobject baos = env.NewObject(clsBAOS, baosCtor); + + jbyteArray buffer = env.NewByteArray(8192); + while (true) { + jint n = env.CallIntMethod(inStream, readMethod, buffer); + if (n < 0) break; + if (n == 0) continue; + env.CallVoidMethod(baos, baosWrite, buffer, 0, n); + } + + env.CallVoidMethod(inStream, closeIS); + jbyteArray bytes = static_cast(env.CallObjectMethod(baos, baosToByteArray)); + env.CallVoidMethod(baos, baosClose); + + if (!bytes) return false; + jsize len = env.GetArrayLength(bytes); + out.resize(static_cast(len)); + if (len > 0) { + env.GetByteArrayRegion(bytes, 0, len, reinterpret_cast(&out[0])); + } + + jmethodID getContentType = + env.GetMethodID(clsConn, "getContentType", "()Ljava/lang/String;"); + jstring jct = static_cast(env.CallObjectMethod(conn, getContentType)); + if (jct) { + contentType = ArgConverter::jstringToString(jct); + } + + if (status == 0) status = 200; + const bool emptyNon2xx = out.empty() && (status < 200 || status >= 300); + if (emptyNon2xx) { + return false; + } + if (status >= 200 && status < 300 && bustRequested) { + ClearCacheBustForUrl(url); + } + return status >= 200 && status < 300; + } catch (NativeScriptException& nse) { + std::string what = nse.what() ? nse.what() : ""; + if (what.empty()) { + what = nse.GetErrorMessage(); + } + RecordLastHttpFetchError("native-script-exception", "tns::NativeScriptException", what); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-esm][fetch][exception] stage=native-script-exception url=%s msg=%s", + url.c_str(), what.c_str()); + } + return false; + } catch (std::exception& ex) { + std::string what = ex.what() ? ex.what() : ""; + RecordLastHttpFetchError("std-exception", "std::exception", what); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-esm][fetch][exception] stage=std-exception url=%s msg=%s", + url.c_str(), what.c_str()); + } + return false; + } catch (...) { + RecordLastHttpFetchError("unknown-cpp-exception", "", ""); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-esm][fetch][exception] stage=unknown-cpp-exception url=%s", + url.c_str()); + } + return false; + } +} + +void FetchModuleBodyAsync(const std::string& url, + std::function completion) { + if (!IsRemoteUrlAllowed(url)) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-esm][security][blocked] %s", url.c_str()); + } + completion(false, 403, std::string()); + return; + } + + std::thread([url, completion = std::move(completion)]() mutable { + std::string out; + std::string contentType; + int status = 0; + const auto start = std::chrono::steady_clock::now(); + bool ok = PerformHttpFetchOnceSync(url, out, contentType, status); + if (!ok) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-loader][fetch-async] retrying %s after transport error", + url.c_str()); + } + usleep(120 * 1000); + ok = PerformHttpFetchOnceSync(url, out, contentType, status); + } + ok = ok && status >= 200 && status < 300; + if (ok && out.empty()) { + out = "export {};\n"; + } + if (!ok && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[http-loader][fetch-async][error] url=%s status=%d", url.c_str(), + status); + } + if (ok && IsHttpFetchUrlLogEnabled()) { + const auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count(); + DEBUG_WRITE_FORCE("[http-loader][fetch][async] %s bytes=%lu ms=%lld", url.c_str(), + (unsigned long)out.size(), (long long)ms); + } + completion(ok, status, std::move(out)); + }).detach(); +} + +static void MaybePumpJSThreadDuringBoot() { + v8::Isolate* isolate = v8::Isolate::TryGetCurrent(); + if (isolate == nullptr) return; + if (IsDevSessionBootComplete()) return; + if (isolate->GetData((uint32_t)Runtime::IsolateData::RUNTIME) == nullptr) return; + + isolate->PerformMicrotaskCheckpoint(); + ALooper_pollOnce(0, nullptr, nullptr, nullptr); + isolate->PerformMicrotaskCheckpoint(); +} + +static std::atomic g_httpFetchYield{&MaybePumpJSThreadDuringBoot}; + +void RegisterHttpFetchYield(void (*callback)()) { + g_httpFetchYield.store(callback, std::memory_order_release); +} + +static inline void InvokeHttpFetchYield() { + auto cb = g_httpFetchYield.load(std::memory_order_acquire); + if (cb != nullptr) cb(); +} + +void CleanupHttpLoaderGlobals() { + ClearAllCacheBustMarks(); + g_devSessionBootComplete.store(false, std::memory_order_relaxed); + ResetCanonicalizationConfig(); +} + +// ───────────────────────────────────────────────────────────── +// ns:module binding + +namespace { + +void InstallDevFunction(v8::Isolate* isolate, v8::Local context, + v8::Local target, const char* name, + v8::FunctionCallback callback) { + v8::Local fnTpl = v8::FunctionTemplate::New(isolate, callback); + v8::Local fn = fnTpl->GetFunction(context).ToLocalChecked(); + fn->SetName(ToV8String(isolate, name)); + target->CreateDataProperty(context, ToV8String(isolate, name), fn).Check(); +} + +void ConfigureLoaderCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + bool logScriptLoading = tns::IsScriptLoadingLogEnabled(); + + if (info.Length() < 1 || !info[0]->IsObject()) { + if (logScriptLoading) { + DEBUG_WRITE_FORCE("[ns:module configureLoader] expected config object argument"); + } + return; + } + + v8::Local config = info[0].As(); + + v8::Local importMapKey = ToV8String(isolate, "importMap"); + v8::Local importMapVal; + if (config->Get(ctx, importMapKey).ToLocal(&importMapVal) && !importMapVal->IsUndefined()) { + std::string jsonStr; + if (importMapVal->IsString()) { + v8::String::Utf8Value utf8(isolate, importMapVal); + if (*utf8) jsonStr = *utf8; + } else if (importMapVal->IsObject()) { + v8::Local jsonObj = + ctx->Global() + ->Get(ctx, ToV8String(isolate, "JSON")) + .ToLocalChecked() + .As(); + v8::Local stringify = + jsonObj->Get(ctx, ToV8String(isolate, "stringify")) + .ToLocalChecked() + .As(); + v8::Local args[] = {importMapVal}; + v8::Local result; + if (stringify->Call(ctx, jsonObj, 1, args).ToLocal(&result) && result->IsString()) { + v8::String::Utf8Value utf8(isolate, result); + if (*utf8) jsonStr = *utf8; + } + } + if (!jsonStr.empty()) { + SetImportMap(jsonStr); + if (logScriptLoading) { + DEBUG_WRITE_FORCE("[ns:module configureLoader] import map set (%zu bytes)", + jsonStr.size()); + } + } + } + + auto readStringArray = [&](v8::Local obj, const char* key, + std::vector& out) -> bool { + v8::Local val; + if (!obj->Get(ctx, ToV8String(isolate, key)).ToLocal(&val) || !val->IsArray()) { + return false; + } + v8::Local arr = val.As(); + for (uint32_t i = 0; i < arr->Length(); i++) { + v8::Local elem; + if (arr->Get(ctx, i).ToLocal(&elem) && elem->IsString()) { + v8::String::Utf8Value utf8(isolate, elem); + if (*utf8) out.push_back(*utf8); + } + } + return true; + }; + + { + std::vector patterns; + if (readStringArray(config, "volatilePatterns", patterns) && !patterns.empty()) { + SetVolatilePatterns(patterns); + if (logScriptLoading) { + DEBUG_WRITE_FORCE("[ns:module configureLoader] %zu volatile patterns set", + patterns.size()); + } + } + } + + { + v8::Local canonVal; + if (config->Get(ctx, ToV8String(isolate, "canonicalization")).ToLocal(&canonVal) && + canonVal->IsObject()) { + v8::Local canonObj = canonVal.As(); + CanonicalizationConfig canon; + readStringArray(canonObj, "stripParams", canon.stripParams); + readStringArray(canonObj, "forPathPrefixes", canon.devPathPrefixes); + readStringArray(canonObj, "preserveQueryFor", canon.preserveQueryPrefixes); + SetCanonicalizationConfig(std::move(canon)); + } + } +} + +void InvalidateModulesCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + if (info.Length() < 1 || !info[0]->IsArray()) { + DEBUG_WRITE_FORCE("[ns:module invalidateModules] expected array of URL strings"); + return; + } + + v8::Local urlsArray = info[0].As(); + std::vector urls; + urls.reserve(urlsArray->Length()); + for (uint32_t index = 0; index < urlsArray->Length(); index++) { + v8::Local value; + if (!urlsArray->Get(ctx, index).ToLocal(&value) || !value->IsString()) { + continue; + } + v8::String::Utf8Value utf8(isolate, value); + if (*utf8) { + urls.emplace_back(*utf8); + } + } + + if (tns::IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE("[ns-hmr][android-invalidate] called urls.count=%zu", urls.size()); + size_t shown = 0; + for (const auto& u : urls) { + if (shown >= 32) break; + DEBUG_WRITE_FORCE("[ns-hmr][android-invalidate] url[%zu]=%s", shown, u.c_str()); + shown++; + } + if (urls.size() > shown) { + DEBUG_WRITE_FORCE("[ns-hmr][android-invalidate] (hidden %zu more URL(s))", + urls.size() - shown); + } + } + + tns::InvalidateModules(isolate, ctx, urls); +} + +void GetLoadedModuleUrlsCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + std::vector urls = tns::GetLoadedModuleUrls(); + v8::Local result = v8::Array::New(isolate, static_cast(urls.size())); + + for (uint32_t index = 0; index < urls.size(); index++) { + result->Set(ctx, index, ToV8String(isolate, urls[index])).FromMaybe(false); + } + + info.GetReturnValue().Set(result); +} + +void SetDevBootCompleteCallback(const v8::FunctionCallbackInfo& info) { + v8::Isolate* isolate = info.GetIsolate(); + v8::HandleScope scope(isolate); + v8::Local ctx = isolate->GetCurrentContext(); + + bool value = true; + if (info.Length() >= 1 && !info[0]->IsUndefined() && !info[0]->IsNull()) { + value = info[0]->BooleanValue(isolate); + } + + tns::SetDevBootComplete(isolate, ctx, value); +} + +} // namespace + +bool BuildNsModuleBinding(v8::Local context, v8::Local binding) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + + InstallDevFunction(isolate, context, binding, "configureLoader", ConfigureLoaderCallback); + InstallDevFunction(isolate, context, binding, "invalidateModules", InvalidateModulesCallback); + InstallDevFunction(isolate, context, binding, "getLoadedModuleUrls", + GetLoadedModuleUrlsCallback); + InstallDevFunction(isolate, context, binding, "setDevBootComplete", SetDevBootCompleteCallback); + + if (IsDebuggable()) { + auto canonicalizeCb = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsString()) { + info.GetReturnValue().SetEmptyString(); + return; + } + v8::String::Utf8Value u(iso, info[0]); + std::string key = CanonicalizeHttpUrlKey(*u ? std::string(*u) : std::string()); + info.GetReturnValue().Set(ToV8String(iso, key)); + }; + v8::Local fn; + if (v8::Function::New(context, canonicalizeCb).ToLocal(&fn)) { + fn->SetName(ToV8String(isolate, "canonicalizeHttpUrlKey")); + if (!binding + ->CreateDataProperty(context, ToV8String(isolate, "canonicalizeHttpUrlKey"), + fn) + .FromMaybe(false)) { + return false; + } + } + } + + return true; +} + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/HttpLoader.h b/test-app/runtime/src/main/cpp/HttpLoader.h new file mode 100644 index 000000000..f1a22ae65 --- /dev/null +++ b/test-app/runtime/src/main/cpp/HttpLoader.h @@ -0,0 +1,179 @@ +#pragma once + +#include +#include +#include + +// Forward declare v8 types to keep this header lightweight and avoid +// requiring V8 headers at include sites. +namespace v8 { +class Isolate; +template +class Local; +class Object; +class Function; +class Context; +class Value; +} // namespace v8 + +namespace tns { + +// HttpLoader: the native half of the NativeScript HTTP module-loader +// contract. +// +// The runtime deliberately exposes *mechanism* only: +// - the synchronous HTTP text fetch backing the HTTP ESM loader's +// fallback path (V8's ResolveModuleCallback is synchronous — still +// true as of 14.9.207.39 — so the fallback must be native), +// - the async background-thread fetch behind the phase-1 module-graph +// walk (StartAsyncHttpModuleGraphLoad), which is how module bodies +// normally arrive, +// - eviction plumbing (an eviction-driven fetch nonce that defeats +// any HTTP cache layer between the runtime and the origin), +// - the dev-boot-complete signal that disarms cold-boot-only +// behaviors (host yield pump), +// - the remote-module security gate, seeded once from nativescript.config +// at boot and never exposed on ns:runtime getConfig/setConfig. + +// ───────────────────────────────────────────────────────────── +// HTTP loader helpers (used by dev/HMR and general-purpose HTTP module loading) +// +// Normalize an HTTP(S) URL into a stable module registry/cache key. +// - Always strips URL fragments. +// - For NativeScript dev endpoints, drops known cache busters (t/v/import) +// and sorts remaining query params for stability. +// - For non-dev/public URLs, preserves the full query string as part of the +// cache key. +// Module identity IS the (canonical) URL — the dev server serves every +// module under exactly one URL and never varies it for freshness. +std::string CanonicalizeHttpUrlKey(const std::string& url); + +// Minimal text fetch for HTTP ESM loader. Returns true on 2xx. +// - out: response body +// - contentType: Content-Type header if present +// - status: HTTP status code +// +// Synchronous fetch with one retry — this is the fallback path for +// anything the async module-graph walk missed. Empty 2xx bodies are +// normalized to the canonical empty module (`export {};\n`). +bool HttpFetchText(const std::string& url, std::string& out, + std::string& contentType, int& status); + +// Asynchronous single-URL module body fetch — the I/O primitive behind the +// phase-1 module-graph walk (see StartAsyncHttpModuleGraphLoad in +// ModuleInternalCallbacks.h). Same semantics as HttpFetchText, minus the +// JS-thread block: +// - security gate (IsRemoteUrlAllowed) checked up front, +// - a JNI HttpURLConnection GET on a background thread with the same +// request shape as the sync path (cache-bust nonce, zero-cache headers, +// no cookies) and one retry on transport error, +// - empty 2xx bodies normalize to the canonical empty module. +// `completion(ok, status, body)` is invoked exactly once, on an arbitrary +// thread — callers must hop to their JS thread before touching V8. +void FetchModuleBodyAsync( + const std::string& url, + std::function completion); + +// Return the most recent low-level fetch error reason for the calling +// thread, or an empty string if the last fetch succeeded (or no fetch +// has run on this thread yet). Take semantics — the slot is cleared on +// read. Android-only diagnostic for splicing JNI exceptions into JS +// errors when HttpFetchText returns status=0. +std::string TakeLastHttpFetchErrorReason(); + +// Register a "yield" callback that `HttpFetchText` should invoke around its +// synchronous network turn so the caller can pump its own runloop (e.g. the +// JS-thread looper so a placeholder UI can repaint during cold-boot). +// +// Default: a built-in pump that no-ops outside the JS thread / after the +// dev boot completes (see `MaybePumpJSThreadDuringBoot` in HttpLoader.cpp). +// +// Pass `nullptr` to disable any yielding (used by hosts that drive their own +// run loop or by tests that want bit-for-bit deterministic fetch timing). +// Safe to call from any thread; reads use acquire/release ordering. +void RegisterHttpFetchYield(void (*callback)()); + +// Mark a URL set (canonicalized internally) so that the NEXT network +// fetch of each URL carries a unique `__ns_dev_nonce` query parameter, +// guaranteeing no HTTP cache layer between the runtime and the origin +// can satisfy the request. Called by `InvalidateModules` for the +// eviction set; marks are consumed when a fresh body arrives. +// The nonce is transport-only and never affects module identity. +void MarkUrlsForCacheBust(const std::vector& urls); + +// Flip the dev-boot-complete signal: sets the JS-visible +// `__NS_HMR_BOOT_COMPLETE__` global and the native atomic that gates the +// cold-boot-only behaviors (JS-thread looper pump between synchronous +// fetches). Exposed to JS as ns:module +// `setDevBootComplete(value?: boolean)`. +void SetDevBootComplete(v8::Isolate* isolate, v8::Local context, + bool value); + +// Clear process-wide HTTP-loader state (cache-bust marks, boot-complete +// flag, canonicalization vocabulary). MUST be called inside +// Runtime::DestroyRuntime() before isolate disposal — and only for the MAIN +// isolate (worker teardown must not wipe shared state the main isolate +// still uses). +void CleanupHttpLoaderGlobals(); + +// ───────────────────────────────────────────────────────────── +// Remote-module security gate +// +// Seeded once from nativescript.config / package.json (`security.allowRemoteModules`, +// `security.remoteModuleAllowlist`) the first time a fetch is gated. Debug +// apps always allow. These values are not readable or writable through +// ns:runtime getConfig/setConfig — only nativescript.config at boot. + +// In debug mode (Runtime.isDebuggable()): always returns true. +// Otherwise returns the boot-time `security.allowRemoteModules` value. +bool IsRemoteModulesAllowed(); + +// Whether `url` may be fetched as a remote ES module. Debug apps always +// allow. Production requires allowRemoteModules, then an allowlist match +// (or all URLs if the allowlist is empty). +bool IsRemoteUrlAllowed(const std::string& url); + +// Mirrors com.tns.Runtime.isDebuggable(), cached once via the security +// config init. Fail-safe false until initialized. +bool IsDebuggable(); + +// Verbose script/module-loading diagnostics. Process-wide ns:runtime key +// `logScriptLoading`; boot default is the nativescript.config / package.json +// value (false when absent). Live value is readable via getConfig and +// writable via setConfig from the main isolate. +bool IsScriptLoadingLogEnabled(); +void SetScriptLoadingLogEnabled(bool enabled); + +// One log line per HTTP fetch URL (high volume). Process-wide ns:runtime +// key `httpFetchUrlLog`; boot default is the nativescript.config / +// package.json value (false when absent). +bool IsHttpFetchUrlLogEnabled(); +void SetHttpFetchUrlLogEnabled(bool enabled); + +// ───────────────────────────────────────────────────────────── +// The `ns:module` builtin binding +// +// Populates the native half of the `ns:module` builtin module — the one +// namespace carrying every JS-callable dev primitive that any tooling can +// depend on. Called from NsBuiltinModules::BuildBinding the first time a +// realm resolves `ns:module` (via require, static import, or import()); +// ns-module.js shapes and freezes the exports. +// +// `ns:module` members: +// - configureLoader(config) (import map + volatile patterns + +// canonicalization vocabulary) +// - invalidateModules(urls) (registry + cache eviction) +// - getLoadedModuleUrls() (registry introspection) +// - setDevBootComplete(value?) (boot-complete signal) +// - canonicalizeHttpUrlKey(url) (debug builds only; test diagnostic) +// +// Worker teardown across HMR cycles is userland: the dev client intercepts +// the global `Worker` constructor and terminates tracked instances +// (worker.terminate() cascades to nested workers via Runtime::DestroyRuntime). +// +// Returns false (with an exception pending or a failed Set) when the +// binding could not be populated. +bool BuildNsModuleBinding(v8::Local context, + v8::Local binding); + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index 7b4ce5d1b..e1a4671e1 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -1853,8 +1853,6 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio } string srcFileName = ArgConverter::ConvertToString(scriptName); - // trim 'file://' to normalize path to always begin with "/data/" - srcFileName = Util::ReplaceAll(srcFileName, "file://", ""); string fullPathToFile; if (srcFileName == "") { @@ -1866,11 +1864,49 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio // preceding the underscore (_) fullPathToFile = "script"; } else { - string hardcodedPathToSkip = Constants::APP_ROOT_FOLDER_PATH; + // srcFileName is not always `file:///.js`: + // HTTP ESM loading (HMR dev workflow) passes a full URL like + // `http://127.0.0.1:5173/ns/core/...` with no `.js` suffix and + // no app-root prefix, so naive scheme/app-root/`.js` stripping + // can yield an empty `fullPathToFile` and crash downstream on + // an empty token list. + string normalized = srcFileName; + + auto stripPrefix = [](string& s, const string& prefix) { + if (s.size() >= prefix.size() && + s.compare(0, prefix.size(), prefix) == 0) { + s.erase(0, prefix.size()); + } + }; + + stripPrefix(normalized, "file://"); + if (normalized.rfind("http://", 0) == 0 || + normalized.rfind("https://", 0) == 0) { + size_t schemeEnd = normalized.find("://"); + size_t pathStart = normalized.find('/', schemeEnd + 3); + if (pathStart == string::npos) { + normalized.clear(); + } else { + normalized.erase(0, pathStart + 1); + } + } - int startIndex = hardcodedPathToSkip.length(); - int strToTakeLen = (srcFileName.length() - startIndex - 3); // 3 refers to .js at the end of file name - fullPathToFile = srcFileName.substr(startIndex, strToTakeLen); + const string& appRoot = Constants::APP_ROOT_FOLDER_PATH; + if (!appRoot.empty()) { + stripPrefix(normalized, appRoot); + } + + auto endsWith = [](const string& s, const string& suffix) { + return s.size() >= suffix.size() && + s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; + }; + if (endsWith(normalized, ".mjs")) { + normalized.resize(normalized.size() - 4); + } else if (endsWith(normalized, ".js")) { + normalized.resize(normalized.size() - 3); + } + + fullPathToFile = normalized; std::replace(fullPathToFile.begin(), fullPathToFile.end(), '/', '_'); std::replace(fullPathToFile.begin(), fullPathToFile.end(), '.', '_'); @@ -1878,10 +1914,18 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio std::replace(fullPathToFile.begin(), fullPathToFile.end(), ' ', '_'); std::vector pathParts; - Util::SplitString(fullPathToFile, "_", pathParts); - std::string lastPathPart = pathParts.back(); + std::string lastPathPart; + for (auto it = pathParts.rbegin(); it != pathParts.rend(); ++it) { + if (!it->empty()) { + lastPathPart = *it; + break; + } + } + if (lastPathPart.empty()) { + lastPathPart = "script"; + } fullPathToFile = lastPathPart; } diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 79f7aa0f6..09f45569b 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -8,6 +8,7 @@ #include "ModuleInternalCallbacks.h" #include "BuiltinLoader.h" #include "File.h" +#include "HttpLoader.h" #include "JniLocalRef.h" #include "ArgConverter.h" #include "V8GlobalHelpers.h" @@ -31,13 +32,59 @@ #include #include #include +#include +#include +#include using namespace v8; using namespace std; using namespace tns; -// Global module registry for ES modules: maps absolute file paths → compiled Module handles -std::unordered_map> g_moduleRegistry; +static bool IsHttpModulePath(const std::string& path) { + return path.rfind("http://", 0) == 0 || path.rfind("https://", 0) == 0 || + path.rfind("file://http://", 0) == 0 || path.rfind("file://https://", 0) == 0; +} + +static std::string NormalizeHttpModuleUrl(const std::string& path) { + if (path.rfind("file://http://", 0) == 0 || path.rfind("file://https://", 0) == 0) { + return path.substr(strlen("file://")); + } + return path; +} + +static std::string PromiseRejectionMessage(Isolate* isolate, Local promise, + const std::string& path) { + std::string errorMessage = "Module evaluation promise rejected: " + path; + Local reason = promise->Result(); + if (reason.IsEmpty()) { + return errorMessage; + } + if (reason->IsObject()) { + Local context = isolate->GetCurrentContext(); + Local errorObj = reason.As(); + Local messageVal; + if (errorObj->Get(context, ArgConverter::ConvertToV8String(isolate, "message")) + .ToLocal(&messageVal) && + messageVal->IsString()) { + v8::String::Utf8Value messageUtf8(isolate, messageVal); + if (*messageUtf8) { + errorMessage.append(" — "); + errorMessage.append(*messageUtf8); + } + } + } else { + Local context = isolate->GetCurrentContext(); + auto maybeReasonStr = reason->ToString(context); + if (!maybeReasonStr.IsEmpty()) { + v8::String::Utf8Value reasonUtf8(isolate, maybeReasonStr.ToLocalChecked()); + if (*reasonUtf8) { + errorMessage.append(" — "); + errorMessage.append(*reasonUtf8); + } + } + } + return errorMessage; +} // Helper function to check if a module name looks like an optional external module bool ModuleInternal::IsLikelyOptionalModule(const std::string& moduleName) { @@ -267,6 +314,10 @@ void ModuleInternal::RequireNativeCallback(const v8::FunctionCallbackInfo context, const string& path) { TNSPERF(); auto isolate = m_isolate; + if (IsHttpModulePath(path) || IsESModule(path)) { + LoadESModule(isolate, path); + return; + } auto globalObject = context->Global(); auto require = globalObject->Get(context, ArgConverter::ConvertToV8String(isolate, "require")).ToLocalChecked().As(); Local args[] = { ArgConverter::ConvertToV8String(isolate, path) }; @@ -278,7 +329,11 @@ void ModuleInternal::LoadWorker(Local context, const string& path) { auto isolate = m_isolate; TryCatch tc(isolate); - Load(context, path); + try { + Load(context, path); + } catch (NativeScriptException& e) { + e.ReThrowToV8(); + } if (tc.HasCaught()) { // This will handle any errors that occur when first loading a script (new worker) @@ -594,54 +649,72 @@ Local ModuleInternal::LoadData(Isolate* isolate, const string& path) { Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& path) { auto context = isolate->GetCurrentContext(); + const bool isHttpModule = IsHttpModulePath(path); + const std::string requestPath = isHttpModule ? NormalizeHttpModuleUrl(path) : path; - // 1) Prepare URL & source - string url = "file://" + path; - string content = Runtime::GetRuntime(isolate)->ReadFileText(path); - - Local sourceText = ArgConverter::ConvertToV8String(isolate, content); - ScriptCompiler::CachedData* cacheData = nullptr; // TODO: Implement cache support for ES modules + Local module; + ScriptCompiler::CachedData* cacheData = nullptr; - Local urlString; - if (!String::NewFromUtf8(isolate, url.c_str(), NewStringType::kNormal).ToLocal(&urlString)) { - throw NativeScriptException(string("Failed to create URL string for ES module ") + path); - } + if (isHttpModule) { + RunAsyncHttpModuleGraphLoadPumped(isolate, context, requestPath, 60.0); + MaybeLocal maybeMod = LoadHttpModuleForUrl(isolate, context, requestPath); + if (!maybeMod.ToLocal(&module)) { + std::string reason = TakeLastHttpFetchErrorReason(); + std::string message = "Cannot load ES module " + requestPath; + if (!reason.empty()) { + message.append(" — "); + message.append(reason); + } + throw NativeScriptException(message); + } + if (module->GetStatus() == Module::kEvaluated) { + UpdateModuleFallback(isolate, CanonicalizeHttpUrlKey(requestPath), module); + return module->GetModuleNamespace(); + } + } else { + // 1) Prepare URL & source + string url = "file://" + path; + string content = Runtime::GetRuntime(isolate)->ReadFileText(path); - ScriptOrigin origin(urlString, 0, 0, false, -1, Local(), false, false, - true // ← is_module - ); - ScriptCompiler::Source source(sourceText, origin, cacheData); + Local sourceText = ArgConverter::ConvertToV8String(isolate, content); - // 2) Compile with its own TryCatch - Local module; - { - TryCatch tcCompile(isolate); - MaybeLocal maybeMod = ScriptCompiler::CompileModule( - isolate, &source, - cacheData ? ScriptCompiler::kConsumeCodeCache : ScriptCompiler::kNoCompileOptions); + Local urlString; + if (!String::NewFromUtf8(isolate, url.c_str(), NewStringType::kNormal).ToLocal(&urlString)) { + throw NativeScriptException(string("Failed to create URL string for ES module ") + path); + } - if (!maybeMod.ToLocal(&module)) { - if (tcCompile.HasCaught()) { - throw NativeScriptException(tcCompile, "Cannot compile ES module " + path); - } else { - throw NativeScriptException(string("Cannot compile ES module ") + path); + ScriptOrigin origin(urlString, 0, 0, false, -1, Local(), false, false, + true // ← is_module + ); + ScriptCompiler::Source source(sourceText, origin, cacheData); + + // 2) Compile with its own TryCatch + { + TryCatch tcCompile(isolate); + MaybeLocal maybeMod = ScriptCompiler::CompileModule( + isolate, &source, + cacheData ? ScriptCompiler::kConsumeCodeCache : ScriptCompiler::kNoCompileOptions); + + if (!maybeMod.ToLocal(&module)) { + if (tcCompile.HasCaught()) { + throw NativeScriptException(tcCompile, "Cannot compile ES module " + path); + } else { + throw NativeScriptException(string("Cannot compile ES module ") + path); + } } } - } - // 3) Register for resolution callback - // Safe Global handle management: Clear any existing entry first - auto it = g_moduleRegistry.find(path); - if (it != g_moduleRegistry.end()) { - // Clear the existing Global handle before replacing it - it->second.Reset(); + // 3) Register for resolution callback + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto it = g_moduleRegistry.find(path); + if (it != g_moduleRegistry.end()) { + it->second.Reset(); + } + g_moduleRegistry[path].Reset(isolate, module); } - // Now safely set the new module handle - g_moduleRegistry[path].Reset(isolate, module); - // 4) Instantiate (link) with ResolveModuleCallback - { + if (module->GetStatus() < Module::kInstantiated) { TryCatch tcLink(isolate); bool linked = module->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false); @@ -669,12 +742,9 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p // Handle the case where evaluation returns a Promise (for top-level await) if (result->IsPromise()) { Local promise = result.As(); - - // Process microtasks to allow Promise resolution - int maxAttempts = 100; - int attempts = 0; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); - while (attempts < maxAttempts) { + while (true) { isolate->PerformMicrotaskCheckpoint(); Promise::PromiseState state = promise->State(); @@ -682,13 +752,17 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p if (state == Promise::kRejected) { Local reason = promise->Result(); isolate->ThrowException(reason); - throw NativeScriptException(string("Module evaluation promise rejected: ") + path); + throw NativeScriptException(PromiseRejectionMessage(isolate, promise, path)); } break; } - attempts++; - usleep(100); // 0.1ms delay + if (std::chrono::steady_clock::now() >= deadline) { + throw NativeScriptException(string("Module evaluation promise timed out: ") + path); + } + + ALooper_pollOnce(10, nullptr, nullptr, nullptr); + usleep(100); } } } diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 828fc0c9a..698ecb45e 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -1,1069 +1,3648 @@ -#include "ModuleInternal.h" -#include "ArgConverter.h" -#include "NativeScriptException.h" -#include "NativeScriptAssert.h" -#include "NsBuiltinModules.h" -#include "Runtime.h" -#include "Util.h" +// ModuleInternalCallbacks.cpp +#include "ModuleInternalCallbacks.h" + +#include #include -#include -#include +#include + #include #include +#include +#include +#include #include -#include "HMRSupport.h" -#include "DevFlags.h" +#include +#include +#include +#include +#include +#include + +#include "ArgConverter.h" +#include "Constants.h" +#include "HttpLoader.h" #include "JEnv.h" +#include "ModuleInternal.h" +#include "NativeScriptAssert.h" +#include "NativeScriptException.h" +#include "NsBuiltinModules.h" +#include "Runtime.h" +#include "Util.h" +#include "robin_hood.h" using namespace v8; using namespace std; using namespace tns; -// External global module registry declared in ModuleInternal.cpp -extern std::unordered_map> g_moduleRegistry; +namespace tns { -// Forward declaration used by logging helper -std::string GetApplicationPath(); +// ───────────────────────────────────────────────────────────── +// Small string helpers (kept file-local — used everywhere below). +static inline bool StartsWith(const std::string& s, const char* prefix) { + size_t n = strlen(prefix); + return s.size() >= n && s.compare(0, n, prefix) == 0; +} -// Diagnostic helper: emit detailed V8 compile error info for HTTP ESM sources. -static void LogHttpCompileDiagnostics(v8::Isolate* isolate, - v8::Local context, - const std::string& url, - const std::string& code, - v8::TryCatch& tc) { - if (!IsScriptLoadingLogEnabled()) { - return; +static inline bool EndsWith(const std::string& value, const std::string& suffix) { + if (suffix.size() > value.size()) return false; + return std::equal(suffix.rbegin(), suffix.rend(), value.rbegin()); +} + +// Node.js built-in namespace check (node:url, node:module, node:path, ...). +static bool IsNodeBuiltinModule(const std::string& moduleName) { + return moduleName.rfind("node:", 0) == 0; +} + +// Filesystem: `path` names an existing regular file. +static bool IsFile(const std::string& path) { + struct stat st; + if (stat(path.c_str(), &st) != 0) { + return false; + } + return (st.st_mode & S_IFMT) == S_IFREG; +} + +// Append `ext` if `path` doesn't already carry it. +static std::string WithExtension(const std::string& path, const std::string& ext) { + if (path.size() >= ext.size() && + path.compare(path.size() - ext.size(), ext.size(), ext) == 0) { + return path; + } + return path + ext; +} + +// Application filesystem root for on-disk .mjs/.js resolution. +// Mirrors Module.java's getApplicationFilesPath + "/app". Cached after first +// JNI call — the value is process-stable, and re-entering JNI on every +// resolver hit would add avoidable overhead to hot module-graph walks. +static std::string GetApplicationPath() { + static std::string cached; + static std::once_flag flag; + std::call_once(flag, []() { + JEnv env; + jstring applicationFilesPath = (jstring)env.CallStaticObjectMethod( + ModuleInternal::MODULE_CLASS, + ModuleInternal::GET_APPLICATION_FILES_PATH_METHOD_ID); + if (applicationFilesPath != nullptr) { + cached = ArgConverter::jstringToString(applicationFilesPath) + "/app"; } - using namespace v8; - - const char* classification = "unknown"; - std::string msgStr; - std::string srcLineStr; - int lineNum = 0; - int startCol = 0; - int endCol = 0; - - Local message = tc.Message(); - if (!message.IsEmpty()) { - String::Utf8Value m8(isolate, message->Get()); - if (*m8) msgStr = *m8; - lineNum = message->GetLineNumber(context).FromMaybe(0); - startCol = message->GetStartColumn(); - endCol = message->GetEndColumn(); - MaybeLocal maybeLine = message->GetSourceLine(context); - if (!maybeLine.IsEmpty()) { - String::Utf8Value l8(isolate, maybeLine.ToLocalChecked()); - if (*l8) srcLineStr = *l8; + }); + return cached; +} + +// Collapse "." and ".." segments, preserving a leading "/". +static std::string NormalizeDotSegments(const std::string& path) { + std::vector stack; + bool absolute = !path.empty() && path[0] == '/'; + size_t i = 0; + while (i <= path.size()) { + size_t j = path.find('/', i); + std::string seg = (j == std::string::npos) ? path.substr(i) : path.substr(i, j - i); + if (seg.empty() || seg == ".") { + // skip + } else if (seg == "..") { + if (!stack.empty()) stack.pop_back(); + } else { + stack.push_back(std::move(seg)); + } + if (j == std::string::npos) break; + i = j + 1; + } + std::string norm = absolute ? "/" : std::string(); + for (size_t k = 0; k < stack.size(); k++) { + if (k > 0) norm += "/"; + norm += stack[k]; + } + return norm; +} + +// Normalize a filesystem path: collapse duplicate slashes, "./" and "../" +// segments. Same intent as iOS's `stringByStandardizingPath`, minus the +// Foundation dependency (no HOME expansion, which we never used anyway). +static std::string NormalizePath(const std::string& path) { + if (path.empty()) return path; + return NormalizeDotSegments(path); +} + +// Convert a file:// URL to a filesystem path. Handles both file:///a/b and +// file:/a/b variants. Percent-decoding is deliberately omitted — the runtime +// only emits ASCII file:// URLs internally. +static std::string FileURLToPath(const std::string& url) { + if (url.empty()) return url; + if (!StartsWith(url, "file://")) return url; + std::string tail = url.substr(7); + // Strip host component when present (file://host/path → /path). NS never + // emits a host, but be tolerant. + if (!tail.empty() && tail[0] != '/') { + size_t slash = tail.find('/'); + tail = (slash == std::string::npos) ? std::string() : tail.substr(slash); + } + // Drop query and fragment — these have no meaning for filesystem paths. + size_t cut = tail.find_first_of("?#"); + if (cut != std::string::npos) tail = tail.substr(0, cut); + return NormalizePath(tail); +} + +// Resolve a relative or root-absolute spec against an HTTP(S) referrer URL. +// Returns empty string if resolution is not applicable. +static std::string ResolveHttpRelative(const std::string& referrerUrl, + const std::string& spec) { + if (referrerUrl.empty()) return std::string(); + if (!(StartsWith(referrerUrl, "http://") || StartsWith(referrerUrl, "https://"))) { + return std::string(); + } + // Normalize referrer: drop fragment and query. + std::string base = referrerUrl; + size_t hashPos = base.find('#'); + if (hashPos != std::string::npos) base = base.substr(0, hashPos); + size_t qPos = base.find('?'); + if (qPos != std::string::npos) base = base.substr(0, qPos); + + size_t schemePos = base.find("://"); + if (schemePos == std::string::npos) return std::string(); + size_t pathStart = base.find('/', schemePos + 3); + std::string origin = (pathStart == std::string::npos) ? base : base.substr(0, pathStart); + std::string path = (pathStart == std::string::npos) ? std::string("/") + : base.substr(pathStart); + + std::string specPath = spec; + std::string specSuffix; + size_t specQ = specPath.find('?'); + size_t specH = specPath.find('#'); + size_t cut = std::string::npos; + if (specQ != std::string::npos && specH != std::string::npos) { + cut = std::min(specQ, specH); + } else if (specQ != std::string::npos) { + cut = specQ; + } else if (specH != std::string::npos) { + cut = specH; + } + if (cut != std::string::npos) { + specSuffix = specPath.substr(cut); + specPath = specPath.substr(0, cut); + } + + std::string newPath; + if (!specPath.empty() && specPath[0] == '/') { + newPath = specPath; + } else { + size_t lastSlash = path.find_last_of('/'); + std::string baseDir = (lastSlash == std::string::npos) + ? std::string("/") + : path.substr(0, lastSlash + 1); + newPath = baseDir + specPath; + } + return origin + NormalizeDotSegments(newPath) + specSuffix; +} + +// Resolve a relative "./" or "../" specifier against a file:// referrer URL. +// Returns an absolute file:// URL, or empty when not applicable. Preserved +// for parity with the earlier Android loader; the current resolver builds +// filesystem candidates directly against GetApplicationPath() so this helper +// is unused for now. +[[maybe_unused]] static std::string ResolveFileRelative( + const std::string& referrerUrl, const std::string& spec) { + const std::string filePrefix = "file://"; + if (!StartsWith(referrerUrl, filePrefix.c_str())) return std::string(); + if (spec.empty() || spec[0] != '.') return std::string(); + std::string refPath = referrerUrl.substr(filePrefix.size()); + size_t hashPos = refPath.find('#'); + if (hashPos != std::string::npos) refPath = refPath.substr(0, hashPos); + size_t qPos = refPath.find('?'); + if (qPos != std::string::npos) refPath = refPath.substr(0, qPos); + size_t lastSlash = refPath.find_last_of('/'); + std::string baseDir = (lastSlash == std::string::npos) + ? std::string("/") + : refPath.substr(0, lastSlash + 1); + return filePrefix + NormalizeDotSegments(baseDir + spec); +} + +// Forward declarations for helpers referenced before their definitions. +static bool ShouldTraceRegistryKey(const std::string& rawKey, + const std::string& registryKey); +static std::string CanonicalizeRegistryKey(const std::string& key); +static const char* ModuleStatusToString(v8::Module::Status status); +static void KillAsyncGraphLoadsForIsolate(v8::Isolate* isolate); +static bool IsCurrentIsolateWorker(v8::Isolate* isolate); +static std::string ExtractRelativePath(const std::string& path); +static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey); +static bool IsVolatileUrl(const std::string& url); + +// ───────────────────────────────────────────────────────────── +// AdoptThenable +// +// Turn any thenable value into a real v8::Promise. Promises returned by +// V8 itself (Module::Evaluate) are genuine and take the fast path; +// user-space thenables (e.g. Proxy'd Promises) fail v8::Value::IsPromise +// but adopting them via Promise::Resolver::New + Resolve preserves their +// state. +static v8::MaybeLocal AdoptThenable(v8::Isolate* isolate, + v8::Local context, + v8::Local value) { + if (value.IsEmpty()) return v8::MaybeLocal(); + if (value->IsPromise()) return value.As(); + if (!value->IsObject()) return v8::MaybeLocal(); + + v8::Local thenVal; + if (!value.As() + ->Get(context, ArgConverter::ConvertToV8String(isolate, "then")) + .ToLocal(&thenVal) || + !thenVal->IsFunction()) { + return v8::MaybeLocal(); + } + + v8::Local adopter; + if (!v8::Promise::Resolver::New(context).ToLocal(&adopter) || + adopter->Resolve(context, value).IsNothing()) { + return v8::MaybeLocal(); + } + return adopter->GetPromise(); +} + +// ───────────────────────────────────────────────────────────── +// Compile helpers + +static v8::MaybeLocal CompileModuleFromSource( + v8::Isolate* isolate, v8::Local context, + const std::string& code, const std::string& urlStr) { + v8::EscapableHandleScope hs(isolate); + // NUL-preserving conversion: module source may contain embedded NUL bytes; + // the char* path would truncate. + v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, code); + v8::Local urlV8; + if (!v8::String::NewFromUtf8(isolate, urlStr.c_str(), + v8::NewStringType::kNormal) + .ToLocal(&urlV8)) { + return v8::MaybeLocal(); + } + v8::ScriptOrigin origin(urlV8, 0, 0, false, -1, v8::Local(), + false, false, true /* is_module */); + v8::ScriptCompiler::Source src(sourceText, origin); + v8::Local mod; + if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { + return v8::MaybeLocal(); + } + if (mod->GetStatus() == v8::Module::kUninstantiated) { + if (!mod->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { + return v8::MaybeLocal(); + } + } + if (mod->GetStatus() != v8::Module::kEvaluated) { + if (mod->Evaluate(context).IsEmpty()) { + return v8::MaybeLocal(); + } + } + return hs.Escape(mod); +} + +// Compile-only variant used inside ResolveModuleCallback. Compiles a +// v8::Module and registers it under urlStr but does NOT instantiate or +// evaluate. V8 is currently instantiating the importer and will handle +// instantiation of this dependency. +static v8::MaybeLocal CompileModuleForResolveRegisterOnly( + v8::Isolate* isolate, v8::Local context, + const std::string& code, const std::string& urlStr) { + v8::EscapableHandleScope hs(isolate); + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + const std::string registryKey = CanonicalizeRegistryKey(urlStr); + if (IsScriptLoadingLogEnabled() && ShouldTraceRegistryKey(urlStr, registryKey)) { + DEBUG_WRITE("[resolver][register-resolve-only] raw=%s key=%s", + urlStr.c_str(), registryKey.c_str()); + } + + v8::Local sourceText = + ArgConverter::ConvertToV8String(isolate, code); + v8::Local urlV8; + if (!v8::String::NewFromUtf8(isolate, urlStr.c_str(), + v8::NewStringType::kNormal) + .ToLocal(&urlV8)) { + return v8::MaybeLocal(); + } + v8::ScriptOrigin origin(urlV8, 0, 0, false, -1, v8::Local(), + false, false, true /* is_module */); + v8::ScriptCompiler::Source src(sourceText, origin); + v8::Local mod; + { + v8::TryCatch tcCompile(isolate); + if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { + if (IsDebuggable() && IsScriptLoadingLogEnabled()) { + uint64_t h = 1469598103934665603ull; // FNV-1a 64-bit + for (unsigned char c : code) { + h ^= c; + h *= 1099511628211ull; + } + std::string snippet = code.substr(0, 600); + for (char& ch : snippet) { + if (ch == '\n' || ch == '\r') ch = ' '; } - // Heuristics similar to iOS for quick triage - if (msgStr.find("Unexpected identifier") != std::string::npos || - msgStr.find("Unexpected token") != std::string::npos) { + const char* classification = "unknown"; + v8::Local message = tcCompile.Message(); + std::string msgStr; + std::string srcLineStr; + int lineNum = 0; + int startCol = 0; + int endCol = 0; + if (!message.IsEmpty()) { + v8::String::Utf8Value m8(isolate, message->Get()); + if (*m8) msgStr = *m8; + lineNum = message->GetLineNumber(context).FromMaybe(0); + startCol = message->GetStartColumn(); + endCol = message->GetEndColumn(); + v8::MaybeLocal maybeLine = message->GetSourceLine(context); + if (!maybeLine.IsEmpty()) { + v8::String::Utf8Value l8(isolate, maybeLine.ToLocalChecked()); + if (*l8) srcLineStr = *l8; + } + if (msgStr.find("Unexpected identifier") != std::string::npos || + msgStr.find("Unexpected token") != std::string::npos) { if (msgStr.find("export") != std::string::npos && code.find("export default") == std::string::npos && - code.find("__sfc__") != std::string::npos) { - classification = "missing-export-default"; - } else { - classification = "syntax"; - } - } else if (msgStr.find("Cannot use import statement") != std::string::npos) { + code.find("__sfc__") != std::string::npos) + classification = "missing-export-default"; + else + classification = "syntax"; + } else if (msgStr.find("Cannot use import statement") != std::string::npos) { classification = "wrap-error"; + } } + if (classification == std::string("unknown")) { + if (code.find("export default") == std::string::npos && + code.find("__sfc__") != std::string::npos) + classification = "missing-export-default"; + else if (code.find("__sfc__") != std::string::npos && + code.find("export {") == std::string::npos && + code.find("export ") == std::string::npos) + classification = "no-exports"; + else if (code.find("import ") == std::string::npos && + code.find("export ") == std::string::npos) + classification = "not-module"; + else if (code.find("_openBlock") != std::string::npos && + code.find("openBlock") == std::string::npos) + classification = "underscore-helper-unmapped"; + } + if (srcLineStr.size() > 240) srcLineStr = srcLineStr.substr(0, 240); + DEBUG_WRITE( + "[http-esm][compile][v8-error][%s] %s line=%d col=%d..%d " + "hash=%llx bytes=%lu msg=%s srcLine=%s snippet=%s", + classification, urlStr.c_str(), lineNum, startCol, endCol, + (unsigned long long)h, (unsigned long)code.size(), + msgStr.c_str(), srcLineStr.c_str(), snippet.c_str()); + } + return v8::MaybeLocal(); } - if (strcmp(classification, "unknown") == 0) { - if (code.find("export default") == std::string::npos && code.find("__sfc__") != std::string::npos) classification = "missing-export-default"; - else if (code.find("__sfc__") != std::string::npos && code.find("export {") == std::string::npos && code.find("export ") == std::string::npos) classification = "no-exports"; - else if (code.find("import ") == std::string::npos && code.find("export ") == std::string::npos) classification = "not-module"; - else if (code.find("_openBlock") != std::string::npos && code.find("openBlock") == std::string::npos) classification = "underscore-helper-unmapped"; + } + auto itExisting = g_moduleRegistry.find(registryKey); + if (itExisting != g_moduleRegistry.end()) { + v8::Local existing = itExisting->second.Get(isolate); + if (!existing.IsEmpty()) { + return hs.Escape(existing); } + } + g_moduleRegistry[registryKey].Reset(isolate, mod); + return hs.Escape(mod); +} - // FNV-1a 64-bit hash of source for correlation - unsigned long long h = 1469598103934665603ull; // offset basis - for (unsigned char c : code) { h ^= c; h *= 1099511628211ull; } +// ───────────────────────────────────────────────────────────── +// Per-isolate module registries +// +// Why per-isolate (not process-global, not thread_local): v8::Global +// handles are bound to the isolate that created them; reading their internal +// state from a different isolate is undefined behaviour. NS Workers each run +// a separate v8::Isolate on their own thread and, under HMR, may fetch the +// same URLs the main thread already loaded — a shared map would hand the +// worker isolate a Module the main isolate compiled, and V8's linker would +// read the cross-isolate export table and emit bogus errors like: +// SyntaxError: The requested module 'X' does not provide an export named 'Y' +// Keying by v8::Isolate* stays correct even if an isolate is ever entered +// from another thread under v8::Locker. +// +// Lifetime: the per-isolate state is created lazily on first access and torn +// down by DestroyModuleStateForIsolate(), which the Runtime destructor +// should call while the isolate is still alive (before disposal) — so every +// v8::Global is Reset() at a safe time. + +namespace { +struct PerIsolateModuleState { + ModuleHandleMap registry; // canonical key -> compiled module + ModuleHandleMap fallbackRegistry; // canonical key -> last good module + ModuleHandleMap fallbackByRelative; // relative path -> last good module +}; + +std::mutex& ModuleStateTableMutex() { + static std::mutex* mutex = new std::mutex(); + return *mutex; +} - // Trim the snippet for readability - std::string snippet = code.substr(0, 600); - for (char& ch : snippet) { if (ch == '\n' || ch == '\r') ch = ' '; } - if (srcLineStr.size() > 240) srcLineStr = srcLineStr.substr(0, 240); +robin_hood::unordered_map>& +ModuleStateTable() { + static auto* table = new robin_hood::unordered_map< + v8::Isolate*, std::unique_ptr>(); + return *table; +} - DEBUG_WRITE("[http-esm][compile][v8-error][%s] %s line=%d col=%d..%d hash=%llx bytes=%lu msg=%s srcLine=%s snippet=%s", - classification, - url.c_str(), - lineNum, - startCol, - endCol, - (unsigned long long)h, - (unsigned long)code.size(), - msgStr.c_str(), - srcLineStr.c_str(), - snippet.c_str()); +PerIsolateModuleState& ModuleStateFor(v8::Isolate* isolate) { + std::lock_guard lock(ModuleStateTableMutex()); + auto& table = ModuleStateTable(); + auto it = table.find(isolate); + if (it == table.end()) { + it = table.emplace(isolate, std::make_unique()).first; + } + return *it->second; } +} // namespace -// Helper: collapse "." and ".." path segments, preserving a leading "/". -static std::string NormalizeDotSegments(const std::string& path) { - std::vector stack; - bool absolute = !path.empty() && path[0] == '/'; - size_t i = 0; - while (i <= path.size()) { - size_t j = path.find('/', i); - std::string seg = (j == std::string::npos) ? path.substr(i) : path.substr(i, j - i); - if (seg.empty() || seg == ".") { - // skip - } else if (seg == "..") { - if (!stack.empty()) stack.pop_back(); - } else { - stack.push_back(seg); - } - if (j == std::string::npos) break; - i = j + 1; - } - std::string norm = absolute ? "/" : std::string(); - for (size_t k = 0; k < stack.size(); k++) { - if (k > 0) norm += "/"; - norm += stack[k]; - } - return norm; -} - -// Helper: resolve relative or root-absolute spec against an HTTP(S) referrer URL. -// Returns empty string if resolution is not possible. -static std::string ResolveHttpRelative(const std::string& referrerUrl, const std::string& spec) { - if (referrerUrl.empty()) { - return std::string(); - } - auto startsWith = [](const std::string& s, const char* pre) -> bool { - size_t n = strlen(pre); - return s.size() >= n && s.compare(0, n, pre) == 0; - }; - if (!(startsWith(referrerUrl, "http://") || startsWith(referrerUrl, "https://"))) { - return std::string(); - } - // Normalize referrer: drop fragment and query - std::string base = referrerUrl; - size_t hashPos = base.find('#'); - if (hashPos != std::string::npos) base = base.substr(0, hashPos); - size_t qPos = base.find('?'); - if (qPos != std::string::npos) base = base.substr(0, qPos); - - // Extract origin and path - size_t schemePos = base.find("://"); - if (schemePos == std::string::npos) { - return std::string(); - } - size_t pathStart = base.find('/', schemePos + 3); - std::string origin = (pathStart == std::string::npos) ? base : base.substr(0, pathStart); - std::string path = (pathStart == std::string::npos) ? std::string("/") : base.substr(pathStart); - - // Separate query/fragment from spec - std::string specPath = spec; - std::string specSuffix; - size_t specQ = specPath.find('?'); - size_t specH = specPath.find('#'); - size_t cut = std::string::npos; - if (specQ != std::string::npos && specH != std::string::npos) { - cut = std::min(specQ, specH); - } else if (specQ != std::string::npos) { - cut = specQ; - } else if (specH != std::string::npos) { - cut = specH; - } - if (cut != std::string::npos) { - specSuffix = specPath.substr(cut); - specPath = specPath.substr(0, cut); - } - - // Build new path - std::string newPath; - if (!specPath.empty() && specPath[0] == '/') { - // Root-absolute relative to origin - newPath = specPath; +ModuleHandleMap& ModuleRegistryFor(v8::Isolate* isolate) { + return ModuleStateFor(isolate).registry; +} + +static ModuleHandleMap& ModuleFallbackRegistryFor(v8::Isolate* isolate) { + return ModuleStateFor(isolate).fallbackRegistry; +} + +static ModuleHandleMap& ModuleFallbackByRelativeFor(v8::Isolate* isolate) { + return ModuleStateFor(isolate).fallbackByRelative; +} + +void DestroyModuleStateForIsolate(v8::Isolate* isolate) { + // First: neutralize any in-flight async graph loads for this isolate. Their + // fetch completions check the dead flag before touching V8, and their + // context Globals are Reset here while the isolate is still alive. + KillAsyncGraphLoadsForIsolate(isolate); + + std::unique_ptr state; + { + std::lock_guard lock(ModuleStateTableMutex()); + auto& table = ModuleStateTable(); + auto it = table.find(isolate); + if (it == table.end()) return; + state = std::move(it->second); + table.erase(it); + } + for (auto& kv : state->registry) kv.second.Reset(); + for (auto& kv : state->fallbackRegistry) kv.second.Reset(); + for (auto& kv : state->fallbackByRelative) kv.second.Reset(); +} + +// ───────────────────────────────────────────────────────────── +// Import map: bare specifier → resolved URL (populated by ns:module +// configureLoader). Instead of rewriting import statements on the bundler +// side, the runtime resolves bare specifiers through this map to HTTP module +// URLs. Source code is served as-is. +static robin_hood::unordered_map g_importMap; + +// Volatile URL patterns: URLs matching these substrings are always re-fetched +// (cache is evicted before loading). Configured at boot by the dev client — +// the vocabulary is server/framework policy, so the runtime carries no +// framework-specific URL strings here. +static std::vector g_volatilePatterns; + +static bool ShouldTraceRegistryKey(const std::string& rawKey, + const std::string& registryKey) { + if (rawKey != registryKey) return true; + return StartsWith(registryKey, "optional:") || + StartsWith(registryKey, "node:") || + StartsWith(registryKey, "blob:"); +} + +static std::string CanonicalizeRegistryKey(const std::string& key) { + if (key.empty()) return key; + + std::string registryKey; + const char* classification = "path"; + bool traceEvenWithoutChange = false; + + if (StartsWith(key, "http://") || StartsWith(key, "https://") || + StartsWith(key, "file://http://") || StartsWith(key, "file://https://")) { + registryKey = CanonicalizeHttpUrlKey(key); + classification = "http"; + } else if (StartsWith(key, "file://")) { + registryKey = NormalizePath(FileURLToPath(key)); + classification = "file-url"; + } else if (StartsWith(key, "blob:")) { + registryKey = key; + classification = "blob"; + traceEvenWithoutChange = true; + } else { + // Preserve non-filesystem module namespaces such as optional: and node: + // so synthetic/in-memory modules keep their exact registry identity. + size_t schemePos = key.find(':'); + size_t slashPos = key.find('/'); + if (schemePos != std::string::npos && + (slashPos == std::string::npos || schemePos < slashPos)) { + registryKey = key; + classification = "custom-scheme"; + traceEvenWithoutChange = true; } else { - // Relative to directory of referrer path - size_t lastSlash = path.find_last_of('/'); - std::string baseDir = (lastSlash == std::string::npos) ? std::string("/") : path.substr(0, lastSlash + 1); - newPath = baseDir + specPath; - } - - // Normalize "." and ".." segments - std::string normPath = NormalizeDotSegments(newPath); - return origin + normPath + specSuffix; -} - -// Helper: resolve a relative "./" or "../" specifier against a file:// referrer -// URL, returning an absolute file:// URL. Returns empty if not applicable. -static std::string ResolveFileRelative(const std::string& referrerUrl, const std::string& spec) { - const std::string filePrefix = "file://"; - if (referrerUrl.rfind(filePrefix, 0) != 0) { - return std::string(); - } - if (spec.empty() || spec[0] != '.') { - return std::string(); - } - // Referrer path: strip scheme, drop query and fragment - std::string refPath = referrerUrl.substr(filePrefix.size()); - size_t hashPos = refPath.find('#'); - if (hashPos != std::string::npos) refPath = refPath.substr(0, hashPos); - size_t qPos = refPath.find('?'); - if (qPos != std::string::npos) refPath = refPath.substr(0, qPos); - - size_t lastSlash = refPath.find_last_of('/'); - std::string baseDir = (lastSlash == std::string::npos) ? std::string("/") : refPath.substr(0, lastSlash + 1); - return filePrefix + NormalizeDotSegments(baseDir + spec); -} - -// Import meta callback to support import.meta.url and import.meta.dirname -void InitializeImportMetaObject(Local context, Local module, Local meta) { - Isolate* isolate = v8::Isolate::GetCurrent(); - - // Look up the module path in the global module registry (with safety checks) - std::string modulePath; - - try { - for (auto& kv : g_moduleRegistry) { - // Check if Global handle is empty before accessing - if (kv.second.IsEmpty()) { - continue; - } - - Local registered = kv.second.Get(isolate); - if (!registered.IsEmpty() && registered == module) { - modulePath = kv.first; - break; - } - } - } catch (...) { - DEBUG_WRITE("InitializeImportMetaObject: Exception during module registry lookup, using fallback"); - modulePath = ""; // Will use fallback path + registryKey = NormalizePath(key); + } + } + + if (IsScriptLoadingLogEnabled() && + (traceEvenWithoutChange || registryKey != key)) { + DEBUG_WRITE("[resolver][registry-key][%s] raw=%s key=%s", classification, + key.c_str(), registryKey.c_str()); + } + return registryKey; +} + +v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, + v8::Local context, + const std::string& requestedUrl) { + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + const std::string registryKey = CanonicalizeHttpUrlKey(requestedUrl); + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][load][begin] request=%s key=%s", + requestedUrl.c_str(), registryKey.c_str()); + } + + auto itExisting = g_moduleRegistry.find(registryKey); + if (itExisting != g_moduleRegistry.end()) { + v8::Local existing = itExisting->second.Get(isolate); + if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][load][cache-hit] key=%s", registryKey.c_str()); + } + return v8::MaybeLocal(existing); + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][load][drop-errored] key=%s", registryKey.c_str()); } - + RemoveModuleFromRegistry(registryKey); + } + + std::string body; + std::string contentType; + int status = 0; + if (!HttpFetchText(requestedUrl, body, contentType, status) || body.empty()) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("InitializeImportMetaObject: Module lookup: found path = %s", - modulePath.empty() ? "(empty)" : modulePath.c_str()); - DEBUG_WRITE("InitializeImportMetaObject: Registry size: %zu", g_moduleRegistry.size()); - } - - // Convert to URL for import.meta.url; keep http(s) untouched, file paths with file:// - std::string moduleUrl; - if (!modulePath.empty()) { - if (modulePath.rfind("http://", 0) == 0 || modulePath.rfind("https://", 0) == 0) { - moduleUrl = modulePath; + DEBUG_WRITE("[http-esm][load][fetch-fail] request=%s key=%s status=%d", + requestedUrl.c_str(), registryKey.c_str(), status); + } + if (IsDebuggable()) { + std::string msg = "HTTP import failed: " + requestedUrl + + " (status=" + std::to_string(status) + ")"; + isolate->ThrowException(v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))); + } + return v8::MaybeLocal(); + } + + v8::MaybeLocal loaded = + CompileModuleForResolveRegisterOnly(isolate, context, body, registryKey); + if (loaded.IsEmpty()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][load][compile-fail] request=%s key=%s bytes=%zu", + requestedUrl.c_str(), registryKey.c_str(), body.size()); + } + if (IsDebuggable()) { + std::string msg = "HTTP import compile failed: " + requestedUrl; + isolate->ThrowException(v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))); + } + return v8::MaybeLocal(); + } + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[http-esm][load][ok] request=%s key=%s type=%s bytes=%zu", + requestedUrl.c_str(), registryKey.c_str(), + contentType.c_str(), body.size()); + } + return loaded; +} + +// ───────────────────────────────────────────────────────────── +// Import map helpers + +// Small hand-rolled JSON scanner for a flat {"imports": {"key": "value", ...}} +// shape. Only strings are accepted; anything malformed is silently skipped — +// same behaviour as the iOS Foundation-based parser for non-object roots. +namespace { +struct JsonScanner { + const std::string& s; + size_t i = 0; + + explicit JsonScanner(const std::string& src) : s(src) {} + + void SkipWs() { + while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || + s[i] == '\r')) { + ++i; + } + } + + bool Peek(char c) { + SkipWs(); + return i < s.size() && s[i] == c; + } + + bool Consume(char c) { + if (Peek(c)) { + ++i; + return true; + } + return false; + } + + // Parses a JSON string into `out`. Handles standard escape sequences + // (\", \\, \/, \b, \f, \n, \r, \t) and \uXXXX (BMP only; surrogate pairs + // are decoded to their two escapes as-is when not paired — good enough + // for the small import-map vocabulary the dev server emits). + bool ReadString(std::string& out) { + SkipWs(); + if (i >= s.size() || s[i] != '"') return false; + ++i; + out.clear(); + while (i < s.size()) { + char c = s[i++]; + if (c == '"') return true; + if (c != '\\') { + out.push_back(c); + continue; + } + if (i >= s.size()) return false; + char e = s[i++]; + switch (e) { + case '"': + case '\\': + case '/': + out.push_back(e); + break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + case 'u': { + if (i + 4 > s.size()) return false; + unsigned int cp = 0; + for (int k = 0; k < 4; ++k) { + char h = s[i++]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= (unsigned)(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= (unsigned)(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= (unsigned)(h - 'A' + 10); + else return false; + } + if (cp < 0x80) { + out.push_back((char)cp); + } else if (cp < 0x800) { + out.push_back((char)(0xC0 | (cp >> 6))); + out.push_back((char)(0x80 | (cp & 0x3F))); + } else { + out.push_back((char)(0xE0 | (cp >> 12))); + out.push_back((char)(0x80 | ((cp >> 6) & 0x3F))); + out.push_back((char)(0x80 | (cp & 0x3F))); + } + break; + } + default: + return false; + } + } + return false; + } + + // Skip an arbitrary JSON value (object/array/string/number/keyword) — + // used to step over "imports" siblings we don't care about. + bool SkipValue() { + SkipWs(); + if (i >= s.size()) return false; + char c = s[i]; + if (c == '"') { + std::string tmp; + return ReadString(tmp); + } + if (c == '{' || c == '[') { + char open = c, close = (c == '{') ? '}' : ']'; + int depth = 0; + bool inString = false; + while (i < s.size()) { + char ch = s[i++]; + if (inString) { + if (ch == '\\' && i < s.size()) ++i; + else if (ch == '"') inString = false; } else { - moduleUrl = "file://" + modulePath; + if (ch == '"') inString = true; + else if (ch == open) ++depth; + else if (ch == close) { + --depth; + if (depth == 0) return true; + } } - } else { - // Fallback URL if module not found in registry - moduleUrl = "file:///android_asset/app/"; + } + return false; + } + // Number / true / false / null — read until the next value terminator. + while (i < s.size()) { + char ch = s[i]; + if (ch == ',' || ch == '}' || ch == ']' || ch == ' ' || ch == '\t' || + ch == '\n' || ch == '\r') { + return true; + } + ++i; } - + return true; + } +}; +} // namespace + +void SetImportMap(const std::string& json) { + g_importMap.clear(); + if (json.empty()) return; + + JsonScanner sc(json); + if (!sc.Consume('{')) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("InitializeImportMetaObject: Final URL: %s", moduleUrl.c_str()); - } - - Local url = ArgConverter::ConvertToV8String(isolate, moduleUrl); - - // Set import.meta.url property - meta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "url"), url).Check(); - - // Add import.meta.dirname support (extract directory) - std::string dirname; - if (!modulePath.empty()) { - if (modulePath.rfind("http://", 0) == 0 || modulePath.rfind("https://", 0) == 0) { - // For URLs, compute dirname by trimming after last '/' - size_t q = modulePath.find('?'); - std::string noQuery = (q == std::string::npos) ? modulePath : modulePath.substr(0, q); - size_t lastSlash = noQuery.find_last_of('/'); - dirname = (lastSlash == std::string::npos) ? modulePath : noQuery.substr(0, lastSlash); + DEBUG_WRITE("[import-map] parse failed: not an object"); + } + return; + } + + // Find and enter the "imports" object; skip any siblings. + bool foundImports = false; + while (!sc.Peek('}')) { + std::string key; + if (!sc.ReadString(key)) break; + if (!sc.Consume(':')) break; + if (key == "imports") { + if (!sc.Consume('{')) break; + foundImports = true; + // Parse the flat {"k":"v", ...} body. + while (!sc.Peek('}')) { + std::string k, v; + if (!sc.ReadString(k)) break; + if (!sc.Consume(':')) break; + if (sc.Peek('"')) { + if (!sc.ReadString(v)) break; + g_importMap[k] = v; } else { - size_t lastSlash = modulePath.find_last_of("/\\"); - if (lastSlash != std::string::npos) { - dirname = modulePath.substr(0, lastSlash); - } else { - dirname = "/android_asset/app"; // fallback - } + // Skip non-string values (arrays, objects, etc.) — mirrors iOS. + if (!sc.SkipValue()) break; } + if (!sc.Consume(',')) break; + } + sc.Consume('}'); } else { - dirname = "/android_asset/app"; // fallback + if (!sc.SkipValue()) break; } - - Local dirnameStr = ArgConverter::ConvertToV8String(isolate, dirname); - - // Set import.meta.dirname property - meta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "dirname"), dirnameStr).Check(); + if (!sc.Consume(',')) break; + } + + if (IsScriptLoadingLogEnabled()) { + if (!foundImports) { + DEBUG_WRITE("[import-map] no 'imports' object found"); + } + DEBUG_WRITE("[import-map] loaded %lu entries", + (unsigned long)g_importMap.size()); + } +} - // Attach import.meta.hot for HMR - tns::InitializeImportMetaHot(isolate, context, meta, modulePath); +void SetVolatilePatterns(const std::vector& patterns) { + g_volatilePatterns = patterns; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map] volatile patterns: %lu", + (unsigned long)g_volatilePatterns.size()); + } } -// Helper function to check if a file exists and is a regular file -bool IsFile(const std::string& path) { - struct stat st; - if (stat(path.c_str(), &st) != 0) { - return false; +static bool IsVolatileUrl(const std::string& url) { + for (const auto& pat : g_volatilePatterns) { + if (url.find(pat) != std::string::npos) return true; + } + return false; +} + +// Normalize a Vite-rewritten specifier into the canonical import-map key. +// Handles two common patterns: +// 1. Prebundled deps: "/node_modules/.vite/deps/solid-js.js?v=abc" → "solid-js" +// "/node_modules/.vite/deps/@tanstack_solid-router.js" → +// "@tanstack/solid-router" +// 2. Explicit node_modules paths: +// "/node_modules/@angular/core/fesm2022/core.mjs" → "@angular/core/fesm2022/core.mjs" +// "/node_modules/tslib/tslib.es6.mjs" → "tslib" +static std::string NormalizeViteSpecifier(const std::string& specifier) { + // Pattern 1: Vite prebundled deps. + { + const std::string viteDepsPrefix = "/node_modules/.vite/deps/"; + const std::string viteDepsPrefix2 = "node_modules/.vite/deps/"; + std::string prefix; + if (specifier.compare(0, viteDepsPrefix.size(), viteDepsPrefix) == 0) + prefix = viteDepsPrefix; + else if (specifier.compare(0, viteDepsPrefix2.size(), viteDepsPrefix2) == 0) + prefix = viteDepsPrefix2; + + if (!prefix.empty()) { + std::string id = specifier.substr(prefix.size()); + auto qpos = id.find('?'); + if (qpos != std::string::npos) id = id.substr(0, qpos); + auto dotpos = id.rfind('.'); + if (dotpos != std::string::npos) id = id.substr(0, dotpos); + if (!id.empty() && id[0] == '@') { + auto upos = id.find('_'); + if (upos != std::string::npos) { + id = id.substr(0, upos) + "/" + id.substr(upos + 1); + auto upos2 = id.find('_', upos + 1); + if (upos2 != std::string::npos) { + id = id.substr(0, upos2); + } + } + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map][normalize] vite-deps: %s -> %s", + specifier.c_str(), id.c_str()); + } + return id; + } + } + + // Pattern 2: Resolved node_modules path — /node_modules//... + { + const std::string nmPrefix = "/node_modules/"; + const std::string nmPrefix2 = "node_modules/"; + std::string sub; + if (specifier.compare(0, nmPrefix.size(), nmPrefix) == 0) + sub = specifier.substr(nmPrefix.size()); + else if (specifier.compare(0, nmPrefix2.size(), nmPrefix2) == 0) + sub = specifier.substr(nmPrefix2.size()); + + if (!sub.empty() && sub[0] != '.') { + if (sub.compare(0, 6, ".vite/") == 0) return ""; + + std::string subNoQuery = sub; + std::string querySuffix; + auto subQueryPos = sub.find('?'); + if (subQueryPos != std::string::npos) { + subNoQuery = sub.substr(0, subQueryPos); + querySuffix = sub.substr(subQueryPos); + } + + std::string pkgName; + if (subNoQuery[0] == '@') { + auto slash1 = subNoQuery.find('/'); + if (slash1 != std::string::npos) { + auto slash2 = subNoQuery.find('/', slash1 + 1); + pkgName = (slash2 != std::string::npos) ? subNoQuery.substr(0, slash2) + : subNoQuery; + } + } else { + auto slash = subNoQuery.find('/'); + pkgName = (slash != std::string::npos) ? subNoQuery.substr(0, slash) + : subNoQuery; + } + if (!pkgName.empty()) { + std::string normalized = pkgName; + std::string remainder; + if (subNoQuery.size() > pkgName.size()) { + remainder = subNoQuery.substr(pkgName.size()); + if (!remainder.empty() && remainder[0] == '/') { + remainder.erase(0, 1); + } + } + + if (!remainder.empty()) { + bool preserveSubpath = remainder.find('/') != std::string::npos; + + if (!preserveSubpath) { + const std::string pkgBaseName = + pkgName.substr(pkgName.find_last_of('/') + 1); + std::string withoutExt = remainder; + auto dot = withoutExt.rfind('.'); + if (dot != std::string::npos) { + withoutExt = withoutExt.substr(0, dot); + } + std::string withoutPlatform = withoutExt; + for (const auto& suffix : {std::string(".ios"), std::string(".android"), + std::string(".visionos")}) { + if (EndsWith(withoutPlatform, suffix)) { + withoutPlatform = + withoutPlatform.substr(0, withoutPlatform.size() - suffix.size()); + break; + } + } + const bool isRootLevelMainEntry = + withoutPlatform == "index" || + withoutPlatform == pkgBaseName || + withoutPlatform.rfind(pkgBaseName + ".", 0) == 0; + preserveSubpath = !isRootLevelMainEntry; + } + + if (preserveSubpath) { + normalized = pkgName + "/" + remainder + querySuffix; + } + } + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map][normalize] node_modules: %s -> %s", + specifier.c_str(), normalized.c_str()); + } + return normalized; + } } - return (st.st_mode & S_IFMT) == S_IFREG; + } + return ""; } -// Helper function to add extension if missing -std::string WithExtension(const std::string& path, const std::string& ext) { - if (path.size() >= ext.size() && path.compare(path.size() - ext.size(), ext.size(), ext) == 0) { - return path; +// Look up a specifier in the import map. Supports exact and prefix matches +// (trailing-slash entries like "solid-js/" that map subpaths). +static std::string LookupImportMap(const std::string& specifier) { + auto it = g_importMap.find(specifier); + if (it != g_importMap.end()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map] exact: %s -> %s", specifier.c_str(), + it->second.c_str()); } - return path + ext; + return it->second; + } + std::string bestKey; + std::string bestValue; + for (const auto& kv : g_importMap) { + const std::string& key = kv.first; + if (key.back() != '/') continue; + if (specifier.size() > key.size() && + specifier.compare(0, key.size(), key) == 0) { + if (key.size() > bestKey.size()) { + bestKey = key; + bestValue = kv.second; + } + } + } + if (!bestKey.empty()) { + std::string remainder = specifier.substr(bestKey.size()); + std::string resolved = bestValue + remainder; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[import-map] prefix: %s -> %s (via %s)", specifier.c_str(), + resolved.c_str(), bestKey.c_str()); + } + return resolved; + } + return ""; } -// Helper function to check if a module is a Node.js built-in (e.g., node:url) -bool IsNodeBuiltinModule(const std::string& spec) { - return spec.size() > 5 && spec.substr(0, 5) == "node:"; +void CleanupImportMapGlobals() { + // Process-global import-map state (not isolate-bound). The per-isolate + // module handle maps (registry / fallback / fallbackByRelative) are torn + // down separately by DestroyModuleStateForIsolate(), which the Runtime + // destructor invokes for every isolate before disposal. + g_importMap.clear(); + g_volatilePatterns.clear(); } -// Helper function to get application path (for Android, we'll use a simple approach) -std::string GetApplicationPath() { - // For Android, use the actual file system path instead of asset path - // This should match the ApplicationFilesPath + "/app" from Module.java - JEnv env; - jstring applicationFilesPath = (jstring) env.CallStaticObjectMethod(ModuleInternal::MODULE_CLASS, ModuleInternal::GET_APPLICATION_FILES_PATH_METHOD_ID); - std::string path = ArgConverter::jstringToString(applicationFilesPath); - return path + "/app"; +// ───────────────────────────────────────────────────────────── +// Worker isolate detection: iOS keys off Caches::Get(isolate)->isWorker. +// Android encodes the same signal by installing a WORKER_WRAPPER pointer in +// the isolate's data slot on worker isolates only (see Runtime.h). +static bool IsCurrentIsolateWorker(v8::Isolate* isolate) { + if (isolate == nullptr) return false; + return isolate->GetData((uint32_t)Runtime::IsolateData::WORKER_WRAPPER) != + nullptr; +} + +// Monotonic microseconds since some fixed epoch — matches iOS's +// CFAbsoluteTimeGetCurrent() semantic (used for internal timing only, never +// exposed to JS). +static uint64_t MonotonicUs() { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000ull + (uint64_t)(ts.tv_nsec / 1000); +} + +// ───────────────────────────────────────────────────────────── +// Async HTTP module-graph pipeline +// +// See the contract comment in ModuleInternalCallbacks.h. Mechanically: +// +// EnqueueUrl(root) +// → FetchModuleBodyAsync (background thread — see HttpLoader.cpp) +// → hop to the isolate's JS thread via LooperTasks::Post +// → CompileModuleForResolveRegisterOnly (registers under the canonical +// URL key — the exact entry ResolveModuleCallback will look up) +// → GetModuleRequests() → ResolveModuleRequestForWalk → EnqueueUrl(…) +// → when pendingFetches drains, onComplete fires on the JS thread. +// +// Thread discipline: `visited`, `pendingFetches`, `failed`, `completed` are +// touched ONLY on the isolate's JS thread (every fetch completion hops there +// first). Only raw I/O runs off-thread. The one crossing signal is `dead`, +// an atomic set by isolate teardown so in-flight completions become no-ops +// instead of touching a disposed isolate. + +namespace { +struct AsyncGraphLoad { + v8::Isolate* isolate = nullptr; + v8::Global context; + std::shared_ptr jsTasks; // isolate's JS thread queue + std::string rootKey; // canonical registry key of the root URL + robin_hood::unordered_set visited; // canonical keys (JS thread only) + int pendingFetches = 0; // JS thread only + bool failed = false; // JS thread only (root failure) + bool completed = false; // JS thread only + std::string failureMessage; + size_t fetchedCount = 0; + size_t compiledCount = 0; + uint64_t startUs = 0; + std::atomic dead{false}; // set by isolate teardown (any thread) + std::function context)> + onComplete; + + ~AsyncGraphLoad() { + g_asyncGraphLoadsInFlightCounter().fetch_sub(1, std::memory_order_acq_rel); + } + + static std::atomic& g_asyncGraphLoadsInFlightCounter() { + static std::atomic counter{0}; + return counter; + } +}; + +std::mutex& AsyncGraphLoadsMutex() { + static std::mutex* mutex = new std::mutex(); + return *mutex; +} + +robin_hood::unordered_map>>& +AsyncGraphLoadsByIsolate() { + static auto* table = new robin_hood::unordered_map< + v8::Isolate*, std::vector>>(); + return *table; +} + +void RegisterAsyncGraphLoad(v8::Isolate* isolate, + const std::shared_ptr& load) { + std::lock_guard lock(AsyncGraphLoadsMutex()); + auto& loads = AsyncGraphLoadsByIsolate()[isolate]; + loads.erase(std::remove_if(loads.begin(), loads.end(), + [](const std::weak_ptr& w) { + return w.expired(); + }), + loads.end()); + loads.push_back(load); } +} // namespace -// ResolveModuleCallback - Main callback invoked by V8 to resolve import statements -v8::MaybeLocal ResolveModuleCallback(v8::Local context, - v8::Local specifier, - v8::Local import_assertions, - v8::Local referrer) { - v8::Isolate* isolate = v8::Isolate::GetCurrent(); +bool HasPendingAsyncModuleGraphWork() { + return AsyncGraphLoad::g_asyncGraphLoadsInFlightCounter().load( + std::memory_order_acquire) > 0; +} - // 1) Convert specifier to std::string - v8::String::Utf8Value specUtf8(isolate, specifier); - std::string spec = *specUtf8 ? *specUtf8 : ""; - if (spec.empty()) { - return v8::MaybeLocal(); +// Isolate-teardown hook: mark every in-flight load owned by `isolate` dead +// (pending fetch completions become no-ops) and Reset their context Globals +// NOW, while the isolate is still alive. +static void KillAsyncGraphLoadsForIsolate(v8::Isolate* isolate) { + std::vector> doomed; + { + std::lock_guard lock(AsyncGraphLoadsMutex()); + auto& table = AsyncGraphLoadsByIsolate(); + auto it = table.find(isolate); + if (it == table.end()) return; + for (auto& weak : it->second) { + if (auto load = weak.lock()) { + doomed.push_back(std::move(load)); + } } + table.erase(it); + } + for (auto& load : doomed) { + load->dead.store(true, std::memory_order_release); + load->context.Reset(); + } +} - // Debug logging - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Resolving '%s'", spec.c_str()); +// Resolve one static module request to an absolute HTTP(S) URL using the +// SAME logic ResolveModuleCallback applies, in the same order: malformed +// scheme repair → import map (direct, then Vite-normalized) → absolute +// HTTP passthrough → relative/root-absolute resolution against an HTTP +// referrer. Returns empty for everything the walk should NOT touch. +static std::string ResolveModuleRequestForWalk(const std::string& rawSpec, + const std::string& referrerUrl) { + if (rawSpec.empty() || rawSpec == "@") return ""; + std::string spec = rawSpec; + if (spec.rfind("http:/", 0) == 0 && spec.rfind("http://", 0) != 0) { + spec.insert(5, "/"); + } else if (spec.rfind("https:/", 0) == 0 && spec.rfind("https://", 0) != 0) { + spec.insert(6, "/"); + } + + if (!g_importMap.empty()) { + std::string mapped = LookupImportMap(spec); + if (mapped.empty()) { + std::string normalized = NormalizeViteSpecifier(spec); + if (!normalized.empty()) { + mapped = LookupImportMap(normalized); + } + } + if (!mapped.empty()) spec = mapped; + } + + if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) { + return spec; + } + + const bool specIsRelative = !spec.empty() && spec[0] == '.'; + const bool specIsRootAbs = !spec.empty() && spec[0] == '/'; + const bool referrerIsHttp = StartsWith(referrerUrl, "http://") || + StartsWith(referrerUrl, "https://"); + if ((specIsRelative || specIsRootAbs) && referrerIsHttp) { + std::string resolved = ResolveHttpRelative(referrerUrl, spec); + if (StartsWith(resolved, "http://") || StartsWith(resolved, "https://")) { + return resolved; } + } + return ""; +} - // Builtin modules resolve before any path handling. Unshimmed "node:" - // names fall through to the legacy polyfills below. - if (NsBuiltinModules::IsRegistered(spec) || NsBuiltinModules::IsNsScheme(spec)) { - v8::Local builtin; - if (NsBuiltinModules::GetModule(context, spec).ToLocal(&builtin)) { - return v8::MaybeLocal(builtin); - } - if (!NsBuiltinModules::IsRegistered(spec)) { - isolate->ThrowException(v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, NsBuiltinModules::NotFoundMessage(spec)))); +static void AsyncGraphEnqueueUrl(const std::shared_ptr& load, + const std::string& url); + +// Walk `mod`'s static module requests and enqueue every HTTP-resolvable +// dependency. JS thread only; `moduleUrl` is the canonical URL the module +// was registered under (the referrer for relative resolution). +static void AsyncGraphWalkModuleRequests( + const std::shared_ptr& load, + v8::Local /*context*/, v8::Local mod, + const std::string& moduleUrl) { + v8::Isolate* isolate = load->isolate; + v8::Local requests = mod->GetModuleRequests(); + const int length = requests->Length(); + for (int i = 0; i < length; i++) { + v8::Local request = + requests->Get(i).As(); + if (request.IsEmpty()) continue; + v8::Local specV8 = request->GetSpecifier(); + v8::String::Utf8Value specUtf8(isolate, specV8); + if (!*specUtf8) continue; + std::string resolved = ResolveModuleRequestForWalk(*specUtf8, moduleUrl); + if (resolved.empty()) continue; + AsyncGraphEnqueueUrl(load, resolved); + } +} + +// Fire onComplete exactly once, when the frontier has drained. JS thread only. +static void AsyncGraphMaybeComplete(const std::shared_ptr& load, + v8::Local context) { + if (load->completed || load->pendingFetches > 0) return; + load->completed = true; + if (IsScriptLoadingLogEnabled()) { + const uint64_t endUs = MonotonicUs(); + const uint64_t ms = endUs > load->startUs ? (endUs - load->startUs) / 1000ull : 0ull; + DEBUG_WRITE( + "[async-graph][done] root=%s urls=%lu fetched=%lu compiled=%lu ms=%llu ok=%d", + load->rootKey.c_str(), (unsigned long)load->visited.size(), + (unsigned long)load->fetchedCount, (unsigned long)load->compiledCount, + (unsigned long long)ms, load->failed ? 0 : 1); + } + auto onComplete = std::move(load->onComplete); + load->onComplete = nullptr; + if (onComplete) { + v8::TryCatch tc(load->isolate); + onComplete(!load->failed, load->failureMessage, context); + (void)tc; // swallow any pending exception; failures already surface as rejections + } +} + +// A fetched body arrived on the isolate's JS thread: compile + register it, +// then walk its requests. Runs outside any V8 scope, so it enters the isolate +// the same way other cross-thread callbacks do. +static void AsyncGraphOnFetchCompleted( + const std::shared_ptr& load, const std::string& url, + bool ok, int status, const std::shared_ptr& body) { + if (load->dead.load(std::memory_order_acquire)) return; + v8::Isolate* isolate = load->isolate; + if (Runtime::GetRuntime(isolate) == nullptr) return; + + v8::Locker locker(isolate); + v8::Isolate::Scope isolate_scope(isolate); + v8::HandleScope handle_scope(isolate); + v8::Local context = load->context.Get(isolate); + if (context.IsEmpty()) return; + v8::Context::Scope context_scope(context); + + load->pendingFetches--; + + const std::string key = CanonicalizeHttpUrlKey(url); + const bool isRoot = (key == load->rootKey); + + if (!load->failed) { + if (!ok) { + if (isRoot) { + load->failed = true; + load->failureMessage = "HTTP import failed: " + url + + " (status=" + std::to_string(status) + ")"; + } else if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[async-graph][dep-fetch-fail] %s status=%d (left to sync resolver)", + url.c_str(), status); + } + } else { + load->fetchedCount++; + v8::MaybeLocal maybeMod = + CompileModuleForResolveRegisterOnly(isolate, context, *body, key); + v8::Local mod; + if (!maybeMod.ToLocal(&mod)) { + if (isRoot) { + load->failed = true; + load->failureMessage = "HTTP import compile failed: " + url; + } else if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[async-graph][dep-compile-fail] %s (left to sync resolver)", + url.c_str()); } - return v8::MaybeLocal(); + } else { + load->compiledCount++; + AsyncGraphWalkModuleRequests(load, context, mod, key); + } } + } - // Normalize malformed http:/ and https:/ prefixes - if (spec.rfind("http:/", 0) == 0 && spec.rfind("http://", 0) != 0) { - spec.insert(5, "/"); - } else if (spec.rfind("https:/", 0) == 0 && spec.rfind("https://", 0) != 0) { - spec.insert(6, "/"); - } + AsyncGraphMaybeComplete(load, context); + isolate->PerformMicrotaskCheckpoint(); +} - // Attempt to resolve relative or root-absolute specifiers against an HTTP referrer URL - std::string referrerPath; - for (auto& kv : g_moduleRegistry) { - v8::Local registered = kv.second.Get(isolate); - if (!registered.IsEmpty() && registered == referrer) { - referrerPath = kv.first; - break; +// Enqueue one URL into the walk frontier. JS thread only. +static void AsyncGraphEnqueueUrl(const std::shared_ptr& load, + const std::string& url) { + const std::string key = CanonicalizeHttpUrlKey(url); + if (!load->visited.insert(key).second) return; + + v8::Isolate* isolate = load->isolate; + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto it = g_moduleRegistry.find(key); + if (it != g_moduleRegistry.end()) { + v8::Local existing = it->second.Get(isolate); + if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { + if (existing->GetStatus() == v8::Module::kUninstantiated) { + v8::Local context = load->context.Get(isolate); + if (!context.IsEmpty()) { + AsyncGraphWalkModuleRequests(load, context, existing, key); } + } + return; } - bool specIsRelative = !spec.empty() && spec[0] == '.'; - bool specIsRootAbs = !spec.empty() && spec[0] == '/'; - auto startsWithHttp = [](const std::string& s) -> bool { - return s.rfind("http://", 0) == 0 || s.rfind("https://", 0) == 0; - }; - if (!startsWithHttp(spec) && (specIsRelative || specIsRootAbs)) { - if (!referrerPath.empty() && startsWithHttp(referrerPath)) { - std::string resolved = ResolveHttpRelative(referrerPath, spec); - if (!resolved.empty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: HTTP-relative resolved '%s' + '%s' -> '%s'", - referrerPath.c_str(), spec.c_str(), resolved.c_str()); - } - spec = resolved; - } - } else if (specIsRootAbs) { - // Fallback: use global __NS_HTTP_ORIGIN__ if present to anchor root-absolute specs - v8::Local key = ArgConverter::ConvertToV8String(isolate, "__NS_HTTP_ORIGIN__"); - v8::Local global = context->Global(); - v8::MaybeLocal maybeOriginVal = global->Get(context, key); - v8::Local originVal; - if (!maybeOriginVal.IsEmpty() && maybeOriginVal.ToLocal(&originVal) && originVal->IsString()) { - v8::String::Utf8Value o8(isolate, originVal); - std::string origin = *o8 ? *o8 : ""; - if (!origin.empty() && (origin.rfind("http://", 0) == 0 || origin.rfind("https://", 0) == 0)) { - std::string refBase = origin; - if (refBase.back() != '/') refBase += '/'; - std::string resolved = ResolveHttpRelative(refBase, spec); - if (!resolved.empty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][http-origin][fallback] origin=%s spec=%s -> %s", refBase.c_str(), spec.c_str(), resolved.c_str()); - } - spec = resolved; - } - } - } - } + RemoveModuleFromRegistry(key); + } + + load->pendingFetches++; + std::shared_ptr jsTasks = load->jsTasks; + std::shared_ptr loadRef = load; + FetchModuleBodyAsync(url, [loadRef, url, jsTasks](bool ok, int status, + std::string body) { + // Arbitrary thread. Hop to the isolate's JS thread before touching any + // walk state or V8. If the isolate died in between, drop everything — + // the context Global was already Reset by the teardown hook. + if (loadRef->dead.load(std::memory_order_acquire) || jsTasks == nullptr) { + return; } + auto bodyPtr = std::make_shared(std::move(body)); + jsTasks->Post([loadRef, url, ok, status, bodyPtr]() { + AsyncGraphOnFetchCompleted(loadRef, url, ok, status, bodyPtr); + }); + }); +} - // HTTP(S) ESM support: resolve, fetch and compile from dev server - // Security: HttpFetchText gates remote module access centrally. - if (spec.rfind("http://", 0) == 0 || spec.rfind("https://", 0) == 0) { - std::string canonical = tns::CanonicalizeHttpUrlKey(spec); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][resolve] spec=%s canonical=%s", spec.c_str(), canonical.c_str()); - } - auto it = g_moduleRegistry.find(canonical); - if (it != g_moduleRegistry.end()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][cache] hit %s", canonical.c_str()); - } - return v8::MaybeLocal(it->second.Get(isolate)); - } +void StartAsyncHttpModuleGraphLoad( + v8::Isolate* isolate, v8::Local context, + const std::string& rootUrl, + std::function context)> + onComplete) { + auto load = std::make_shared(); + load->isolate = isolate; + load->context.Reset(isolate, context); + load->rootKey = CanonicalizeHttpUrlKey(rootUrl); + load->startUs = MonotonicUs(); + load->onComplete = std::move(onComplete); + + Runtime* runtime = Runtime::GetRuntime(isolate); + load->jsTasks = runtime != nullptr ? runtime->GetLooperTasks() : nullptr; + + AsyncGraphLoad::g_asyncGraphLoadsInFlightCounter().fetch_add( + 1, std::memory_order_acq_rel); + RegisterAsyncGraphLoad(isolate, load); + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[async-graph][start] root=%s key=%s", rootUrl.c_str(), + load->rootKey.c_str()); + } + + AsyncGraphEnqueueUrl(load, rootUrl); + // Root already registered (or nothing fetchable): complete inline. + AsyncGraphMaybeComplete(load, context); +} - std::string body, ct; - int status = 0; - if (!tns::HttpFetchText(spec, body, ct, status)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][fetch][fail] url=%s status=%d", spec.c_str(), status); - } - std::string msg = std::string("Failed to fetch ") + spec + ", status=" + std::to_string(status); - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); - } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][fetch][ok] url=%s status=%d bytes=%lu ct=%s", spec.c_str(), status, (unsigned long)body.size(), ct.c_str()); - } +bool RunAsyncHttpModuleGraphLoadPumped(v8::Isolate* isolate, + v8::Local context, + const std::string& rootUrl, + double timeoutSeconds) { + if (timeoutSeconds <= 0.0) timeoutSeconds = 60.0; + auto done = std::make_shared(false); + StartAsyncHttpModuleGraphLoad( + isolate, context, rootUrl, + [done](bool /*ok*/, const std::string& /*errorMessage*/, + v8::Local) { *done = true; }); + + // Manual looper pump ("until either all is settled or the app takes + // over"): the walk's completion tasks are posted to this thread's + // LooperTasks queue and dispatched via ALooper — polling the looper here + // services them. ALooper_pollOnce with a small timeout keeps the pump + // responsive without spinning. + const auto deadline = + std::chrono::steady_clock::now() + + std::chrono::milliseconds(static_cast(timeoutSeconds * 1000.0)); + while (!*done && std::chrono::steady_clock::now() < deadline) { + ALooper_pollOnce(10 /* ms */, nullptr, nullptr, nullptr); + } + if (!*done && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[async-graph][pumped][timeout] root=%s after %.1fs (sync loader takes over)", + rootUrl.c_str(), timeoutSeconds); + } + return *done; +} - v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, body); - v8::Local urlString = ArgConverter::ConvertToV8String(isolate, canonical); - v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), false, false, true); - v8::ScriptCompiler::Source src(sourceText, origin); - v8::Local mod; - { - v8::TryCatch tc(isolate); - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { - LogHttpCompileDiagnostics(isolate, context, canonical, body, tc); - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "HTTP module compile failed"))); - return v8::MaybeLocal(); - } - } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][compile][ok] %s bytes=%lu", canonical.c_str(), (unsigned long)body.size()); - } - // Register before instantiation to allow cyclic imports to resolve to same instance - g_moduleRegistry[canonical].Reset(isolate, mod); - // Do not evaluate here; allow V8 to handle instantiation/evaluation in importer context. - // Instantiate proactively if desired (safe), but not required. - // if (mod->GetStatus() == v8::Module::kUninstantiated) { - // if (!mod->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - // g_moduleRegistry.erase(canonical); - // return v8::MaybeLocal(); - // } - // } - // Let V8 evaluate during importer evaluation. Returning compiled module is fine. - return v8::MaybeLocal(mod); - } - - // 2) Find which filepath the referrer was compiled under (local filesystem case) - // referrerPath may already be set above; leave as-is if found. - if (referrerPath.empty()) { - for (auto& kv : g_moduleRegistry) { - v8::Local registered = kv.second.Get(isolate); - if (registered == referrer) { - referrerPath = kv.first; - break; - } - } +// ───────────────────────────────────────────────────────────── +// Registry mutation + diagnostics + +// Compute a relative path key for fallback lookup (mirrors iOS's helper). +// On Android there is no separate Documents directory — everything lives +// under the application path. +static std::string ExtractRelativePath(const std::string& path) { + std::string appPrefix = NormalizePath(GetApplicationPath()); + if (!appPrefix.empty()) { + std::string directPrefix = appPrefix + "/"; + if (path.rfind(directPrefix, 0) == 0) { + return path.substr(directPrefix.size()); + } + // Some code paths carry "…/app/…" twice (bundled app folder). + std::string appFolderPrefix = appPrefix + "/app/"; + if (path.rfind(appFolderPrefix, 0) == 0) { + return path.substr(appFolderPrefix.size()); } + } + return ""; +} - // If we couldn't identify the referrer and the specifier is relative, - // assume the base directory is the application root - bool specIsRelativeFs = !spec.empty() && spec[0] == '.'; - if (referrerPath.empty() && specIsRelativeFs) { - referrerPath = GetApplicationPath() + "/index.mjs"; // Default referrer +static const char* ModuleStatusToString(v8::Module::Status status) { + switch (status) { + case v8::Module::kUninstantiated: + return "Uninstantiated"; + case v8::Module::kInstantiating: + return "Instantiating"; + case v8::Module::kInstantiated: + return "Instantiated"; + case v8::Module::kEvaluating: + return "Evaluating"; + case v8::Module::kEvaluated: + return "Evaluated"; + case v8::Module::kErrored: + return "Errored"; + } + return "Unknown"; +} + +void RemoveModuleFromRegistry(const std::string& canonicalPath) { + // Only ever called on an isolate's own JS thread during module + // resolution/loading, so the entered isolate owns the maps to mutate. + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + if (isolate == nullptr) return; + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate); + auto& g_moduleFallbackByRelative = ModuleFallbackByRelativeFor(isolate); + const std::string registryKey = CanonicalizeRegistryKey(canonicalPath); + + // Defensive: never operate on an anomalous/sentinel key. + auto isSentinel = [](const std::string& s) -> bool { + if (s == "@") return true; + return s.find("__invalid_at__.mjs") != std::string::npos; + }; + if (isSentinel(registryKey)) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][guard-v3] ignore remove for sentinel %s", + registryKey.c_str()); + } + return; + } + + auto classify = [](const std::string& s) -> const char* { + if (s == "@") return "sentinel:@"; + if (s.find("__invalid_at__.mjs") != std::string::npos) + return "sentinel:invalid_at"; + bool http = StartsWith(s, "http://") || StartsWith(s, "https://"); + if (http) { + if (IsVolatileUrl(s)) return "http:volatile"; + if (s.find("/@ns/sfc/") != std::string::npos) return "http:sfc"; + if (s.find("/@ns/m/") != std::string::npos) return "http:m"; + return "http:other"; } + if (StartsWith(s, "file://")) return "file-url"; + return "path"; + }; + + if (IsScriptLoadingLogEnabled()) { + if (registryKey != canonicalPath) { + DEBUG_WRITE("[resolver][remove:pre] raw=%s key=%s class=%s", + canonicalPath.c_str(), registryKey.c_str(), + classify(registryKey)); + } else { + DEBUG_WRITE("[resolver][remove:pre] key=%s class=%s", registryKey.c_str(), + classify(registryKey)); + } + } + + size_t regPre = g_moduleRegistry.size(); + size_t fbPre = g_moduleFallbackRegistry.size(); + size_t relPre = g_moduleFallbackByRelative.size(); + + auto it = g_moduleRegistry.find(registryKey); + if (it != g_moduleRegistry.end()) { + bool isHttpKey = + StartsWith(registryKey, "http://") || StartsWith(registryKey, "https://"); + if (IsScriptLoadingLogEnabled() && !isHttpKey) { + DEBUG_WRITE("[resolver] removing stale module %s", registryKey.c_str()); + } + it->second.Reset(); + g_moduleRegistry.erase(it); + } else if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[resolver][remove:miss] key not found, proceed to clear fallbacks (%s)", + registryKey.c_str()); + } + auto fb = g_moduleFallbackRegistry.find(registryKey); + if (fb != g_moduleFallbackRegistry.end()) { + fb->second.Reset(); + g_moduleFallbackRegistry.erase(fb); + } + std::string rel = ExtractRelativePath(registryKey); + if (!rel.empty()) { + auto fbr = g_moduleFallbackByRelative.find(rel); + if (fbr != g_moduleFallbackByRelative.end()) { + fbr->second.Reset(); + g_moduleFallbackByRelative.erase(fbr); + } + } + + if (IsScriptLoadingLogEnabled()) { + size_t regPost = g_moduleRegistry.size(); + size_t fbPost = g_moduleFallbackRegistry.size(); + size_t relPost = g_moduleFallbackByRelative.size(); + DEBUG_WRITE( + "[resolver][remove:post] reg %lu->%lu fb %lu->%lu rel %lu->%lu", + (unsigned long)regPre, (unsigned long)regPost, (unsigned long)fbPre, + (unsigned long)fbPost, (unsigned long)relPre, (unsigned long)relPost); + } +} - // 3) Compute base directory from referrer path - size_t slash = referrerPath.find_last_of("/\\"); - std::string baseDir = slash == std::string::npos ? "" : referrerPath.substr(0, slash + 1); +std::vector GetLoadedModuleUrls() { + std::vector urls; + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + if (isolate == nullptr) return urls; + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + urls.reserve(g_moduleRegistry.size()); + + for (const auto& entry : g_moduleRegistry) { + const std::string& key = entry.first; + if (key.empty()) continue; + if (StartsWith(key, "blob:") || key.find("://") != std::string::npos) { + urls.push_back(key); + } + } + std::sort(urls.begin(), urls.end()); + urls.erase(std::unique(urls.begin(), urls.end()), urls.end()); + return urls; +} - // 4) Build candidate paths for resolution - std::vector candidateBases; - std::string appPath = GetApplicationPath(); +void InvalidateModules(v8::Isolate* isolate, v8::Local context, + const std::vector& urls) { + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + if (urls.empty()) return; + + robin_hood::unordered_set seen; + std::vector uniqueUrls; + uniqueUrls.reserve(urls.size()); + + for (const auto& url : urls) { + if (url.empty()) continue; + std::string registryKey = CanonicalizeRegistryKey(url); + if (registryKey.empty()) continue; + if (!seen.insert(registryKey).second) continue; + uniqueUrls.push_back(registryKey); + } + + const bool logScriptLoading = IsScriptLoadingLogEnabled(); + size_t hits = 0, misses = 0; + for (const auto& url : uniqueUrls) { + bool present = g_moduleRegistry.find(url) != g_moduleRegistry.end(); + if (present) hits++; + else misses++; + if (logScriptLoading) { + DEBUG_WRITE("[ns-hmr][android-invalidate] %s key=%s", + present ? "HIT " : "MISS", url.c_str()); + } + RejectAndClearInvalidatedModuleState(isolate, context, url); + RemoveModuleFromRegistry(url); + } + + // Second layer: the OS HTTP cache is outside our control and may serve + // a previous save's body even with no-store headers. Mark every + // invalidated key so the NEXT network fetch carries a unique + // `__ns_dev_nonce` query param — the network sees a URL it has never + // cached and must go to origin. The nonce is transport-only; module + // identity stays the canonical URL. + MarkUrlsForCacheBust(uniqueUrls); + + if (logScriptLoading) { + DEBUG_WRITE( + "[ns-hmr][android-invalidate] summary unique=%lu hits=%lu misses=%lu " + "(registry now=%lu)", + (unsigned long)uniqueUrls.size(), (unsigned long)hits, + (unsigned long)misses, (unsigned long)g_moduleRegistry.size()); + } +} - if (!spec.empty() && spec[0] == '.') { - // Relative import (./ or ../) - std::string cleanSpec = spec.substr(0, 2) == "./" ? spec.substr(2) : spec; - std::string candidate = baseDir + cleanSpec; - candidateBases.push_back(candidate); +void UpdateModuleFallback(v8::Isolate* isolate, + const std::string& canonicalPath, + v8::Local module) { + auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate); + auto& g_moduleFallbackByRelative = ModuleFallbackByRelativeFor(isolate); + auto fallbackIt = g_moduleFallbackRegistry.find(canonicalPath); + if (fallbackIt != g_moduleFallbackRegistry.end()) { + fallbackIt->second.Reset(); + } + if (!module.IsEmpty()) { + g_moduleFallbackRegistry[canonicalPath].Reset(isolate, module); if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Relative import: '%s' + '%s' -> '%s'", - baseDir.c_str(), cleanSpec.c_str(), candidate.c_str()); - } - } else if (spec.size() > 7 && spec.substr(0, 7) == "file://") { - // Absolute file URL - std::string tail = spec.substr(7); // strip file:// - if (tail.empty() || tail[0] != '/') { - tail = "/" + tail; - } - - // Map common virtual roots to the real appPath - const std::string appVirtualRoot = "/app/"; // e.g. file:///app/foo.mjs - const std::string androidAssetAppRoot = "/android_asset/app/"; // e.g. file:///android_asset/app/foo.mjs + DEBUG_WRITE("[resolver] fallback updated for %s from evaluated module", + canonicalPath.c_str()); + } + std::string relative = ExtractRelativePath(canonicalPath); + if (!relative.empty()) { + auto relativeIt = g_moduleFallbackByRelative.find(relative); + if (relativeIt != g_moduleFallbackByRelative.end()) { + relativeIt->second.Reset(); + } + g_moduleFallbackByRelative[relative].Reset(isolate, module); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver] fallback relative updated for %s", + relative.c_str()); + } + } + } +} - std::string candidate; - if (tail.rfind(appVirtualRoot, 0) == 0) { - // Drop the leading "/app/" and prepend real appPath - candidate = appPath + "/" + tail.substr(appVirtualRoot.size()); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: file:// to appPath mapping: '%s' -> '%s'", tail.c_str(), candidate.c_str()); - } - } else if (tail.rfind(androidAssetAppRoot, 0) == 0) { - // Replace "/android_asset/app/" with the real appPath - candidate = appPath + "/" + tail.substr(androidAssetAppRoot.size()); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: file:// android_asset mapping: '%s' -> '%s'", tail.c_str(), candidate.c_str()); - } - } else if (tail.rfind(appPath, 0) == 0) { - // Already an absolute on-disk path to the app folder - candidate = tail; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: file:// absolute path preserved: '%s'", candidate.c_str()); - } - } else { - // Fallback: treat as absolute on-disk path - candidate = tail; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: file:// generic absolute: '%s'", candidate.c_str()); - } - } +// ───────────────────────────────────────────────────────────── +// Thread-local resolver state +// +// Recursion detection + module in-flight/waiter tracking. Everything here is +// touched only from the isolate's own JS thread, so thread_local is safe. +static thread_local std::vector g_moduleResolutionStack; +static thread_local robin_hood::unordered_map g_moduleReentryCounts; +static thread_local robin_hood::unordered_map> + g_moduleReentryParents; +static thread_local robin_hood::unordered_map g_modulePrimaryImporters; +static thread_local robin_hood::unordered_set g_modulesInFlight; +static thread_local robin_hood::unordered_set g_modulesPendingReset; +static constexpr size_t kMaxModuleReentryCount = 256; +// Waiters: module registry key -> list of Promise resolvers waiting for +// completion (instantiated/evaluated or errored). +static robin_hood::unordered_map>> + g_moduleWaiters; +// Dynamic HTTP import waiters: resolve to module namespace when available. +static thread_local robin_hood::unordered_map< + std::string, std::vector>> + g_httpDynamicWaiters; + +static bool IsModuleEvaluationInProgress(v8::Module::Status status) { + return status == v8::Module::kInstantiating || + status == v8::Module::kEvaluating; +} - candidateBases.push_back(candidate); - } else if (!spec.empty() && spec[0] == '~') { - // Alias to application root using ~/path - std::string tail = spec.size() >= 2 && spec[1] == '/' ? spec.substr(2) : spec.substr(1); - std::string candidate = appPath + "/" + tail; - candidateBases.push_back(candidate); - } else if (!spec.empty() && spec[0] == '/') { - // Absolute path within the bundle - candidateBases.push_back(appPath + spec); - } else { - // Bare specifier – resolve relative to the application root - std::string candidate = appPath + "/" + spec; - candidateBases.push_back(candidate); - - // Try converting underscores to slashes (bundler heuristic) - std::string withSlashes = spec; - std::replace(withSlashes.begin(), withSlashes.end(), '_', '/'); - std::string candidateSlashes = appPath + "/" + withSlashes; - if (candidateSlashes != candidate) { - candidateBases.push_back(candidateSlashes); - } +static void ResolveResolversWithModuleNamespace( + v8::Isolate* isolate, v8::Local context, + std::vector>& resolvers, + v8::Local module, const std::string& registryKey) { + if (resolvers.empty()) return; + if (module.IsEmpty() || module->GetStatus() != v8::Module::kEvaluated) { + std::string msg = "Module did not finish evaluation: " + registryKey; + v8::Local errObj = + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg)); + for (auto& resGlobal : resolvers) { + v8::Local resolver = resGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Reject(context, errObj).FromMaybe(false); + } + resGlobal.Reset(); } + return; + } + v8::Local moduleNamespace = module->GetModuleNamespace(); + for (auto& resGlobal : resolvers) { + v8::Local resolver = resGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Resolve(context, moduleNamespace).FromMaybe(false); + } + resGlobal.Reset(); + } +} - // 5) Attempt to resolve to an actual file - std::string absPath; - bool found = false; +static void RejectResolversWithReason( + v8::Isolate* isolate, v8::Local context, + std::vector>& resolvers, + v8::Local reason) { + if (resolvers.empty()) return; + for (auto& resGlobal : resolvers) { + v8::Local resolver = resGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Reject(context, reason).FromMaybe(false); + } + resGlobal.Reset(); + } +} - for (const std::string& baseCandidate : candidateBases) { - absPath = baseCandidate; +static bool QueueModuleWaiterIfInFlight(v8::Isolate* isolate, + const std::string& registryKey, + v8::Local module, + v8::Local resolver) { + if (registryKey.empty() || module.IsEmpty() || + !IsModuleEvaluationInProgress(module->GetStatus()) || + g_modulesInFlight.find(registryKey) == g_modulesInFlight.end()) { + return false; + } + g_moduleWaiters[registryKey].emplace_back(isolate, resolver); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][await] queued module waiter for %s status=%s", + registryKey.c_str(), + ModuleStatusToString(module->GetStatus())); + } + return true; +} - // Check if file exists as-is - if (IsFile(absPath)) { - found = true; - break; - } +static bool QueueHttpDynamicWaiterIfInFlight( + v8::Isolate* isolate, const std::string& registryKey, + v8::Local module, v8::Local resolver) { + if (registryKey.empty() || module.IsEmpty() || + !IsModuleEvaluationInProgress(module->GetStatus()) || + g_modulesInFlight.find(registryKey) == g_modulesInFlight.end()) { + return false; + } + g_httpDynamicWaiters[registryKey].emplace_back(isolate, resolver); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][http-await] queued waiter for %s status=%s", + registryKey.c_str(), + ModuleStatusToString(module->GetStatus())); + } + return true; +} - // Try adding extensions - const char* exts[] = {".mjs", ".js"}; - for (const char* ext : exts) { - std::string candidate = WithExtension(absPath, ext); - if (IsFile(candidate)) { - absPath = candidate; - found = true; - break; - } - } - if (found) break; - - // Try index files if path is a directory - const char* indexExts[] = {"/index.mjs", "/index.js"}; - for (const char* idx : indexExts) { - std::string candidate = absPath + idx; - if (IsFile(candidate)) { - absPath = candidate; - found = true; - break; - } - } - if (found) break; - } - - // Canonicalize "." / ".." segments so a file reached through different - // spellings (e.g. "./x" from /a/b and "../x" from /a/b/c both name /a/b/x) - // maps to one registry key and is compiled once. The HTTP branch - // canonicalizes via CanonicalizeHttpUrlKey. - if (found) { - absPath = NormalizeDotSegments(absPath); - } - - // 6) Handle special cases if file not found - if (!found) { - // Check for Node.js built-in modules - if (IsNodeBuiltinModule(spec)) { - std::string builtinName = spec.substr(5); // Remove "node:" prefix - - // Create polyfill content for Node.js built-in modules - std::string polyfillContent; - - if (builtinName == "url") { - // Create a polyfill for node:url with fileURLToPath - polyfillContent = "// Polyfill for node:url\n" - "export function fileURLToPath(url) {\n" - " if (typeof url === 'string') {\n" - " if (url.startsWith('file://')) {\n" - " return decodeURIComponent(url.slice(7));\n" - " }\n" - " return url;\n" - " }\n" - " if (url && typeof url.href === 'string') {\n" - " return fileURLToPath(url.href);\n" - " }\n" - " throw new Error('Invalid URL');\n" - "}\n" - "\n" - "export function pathToFileURL(path) {\n" - " const encoded = encodeURIComponent(path).replace(/%2F/g, '/');\n" - " return new URL('file://' + encoded);\n" - "}\n"; - } else if (builtinName == "module") { - // Create a polyfill for node:module with createRequire - polyfillContent = "// Polyfill for node:module\n" - "export function createRequire(filename) {\n" - " // Return the global require function\n" - " // In NativeScript, require is globally available\n" - " if (typeof require === 'function') {\n" - " return require;\n" - " }\n" - " \n" - " // Fallback: create a basic require function\n" - " return function(id) {\n" - " throw new Error('Module ' + id + ' not found. NativeScript require() not available.');\n" - " };\n" - "}\n" - "\n" - "// Export as default as well for compatibility\n" - "export default { createRequire };\n"; - } else if (builtinName == "path") { - // Create a polyfill for node:path - polyfillContent = "// Polyfill for node:path\n" - "export const sep = '/';\n" - "export const delimiter = ':';\n" - "\n" - "export function basename(path, ext) {\n" - " const name = path.split('/').pop() || '';\n" - " return ext && name.endsWith(ext) ? name.slice(0, -ext.length) : name;\n" - "}\n" - "\n" - "export function dirname(path) {\n" - " const parts = path.split('/');\n" - " return parts.slice(0, -1).join('/') || '/';\n" - "}\n" - "\n" - "export function extname(path) {\n" - " const name = basename(path);\n" - " const dot = name.lastIndexOf('.');\n" - " return dot > 0 ? name.slice(dot) : '';\n" - "}\n" - "\n" - "export function join(...paths) {\n" - " return paths.filter(Boolean).join('/').replace(/\\/+/g, '/');\n" - "}\n" - "\n" - "export function resolve(...paths) {\n" - " let resolved = '';\n" - " for (let path of paths) {\n" - " if (path.startsWith('/')) {\n" - " resolved = path;\n" - " } else {\n" - " resolved = join(resolved, path);\n" - " }\n" - " }\n" - " return resolved || '/';\n" - "}\n" - "\n" - "export function isAbsolute(path) {\n" - " return path.startsWith('/');\n" - "}\n" - "\n" - "export default { basename, dirname, extname, join, resolve, isAbsolute, sep, delimiter };\n"; - } else { - isolate->ThrowException(v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, NsBuiltinModules::NotFoundMessage(spec)))); - return v8::MaybeLocal(); - } - - // Create module source and compile it in-memory - v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, polyfillContent); - - // Build URL for stack traces - std::string moduleUrl = "node:" + builtinName; - v8::Local urlString = ArgConverter::ConvertToV8String(isolate, moduleUrl); - - v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), false, false, true /* is_module */); - v8::ScriptCompiler::Source src(sourceText, origin); - - v8::Local polyfillModule; - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&polyfillModule)) { - std::string msg = "Failed to compile polyfill for: " + spec; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); - } - - // Store in registry before instantiation - g_moduleRegistry[spec].Reset(isolate, polyfillModule); - - // Instantiate the module - if (!polyfillModule->InstantiateModule(context, ResolveModuleCallback).FromMaybe(false)) { - g_moduleRegistry.erase(spec); - std::string msg = "Failed to instantiate polyfill for: " + spec; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); - } - - // Evaluate the module - v8::MaybeLocal evalResult = polyfillModule->Evaluate(context); - if (evalResult.IsEmpty()) { - g_moduleRegistry.erase(spec); - std::string msg = "Failed to evaluate polyfill for: " + spec; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); - } - - return v8::MaybeLocal(polyfillModule); - - } else if (tns::ModuleInternal::IsLikelyOptionalModule(spec)) { - // For optional modules, create a placeholder - std::string msg = "Optional module not found: " + spec; - DEBUG_WRITE("ResolveModuleCallback: %s", msg.c_str()); - // Return empty to indicate module not found gracefully - return v8::MaybeLocal(); - } else { - // Regular module not found - std::string msg = "Cannot find module " + spec + " (tried " + absPath + ")"; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); +// Build a rejection reason that PRESERVES the underlying V8 exception text. +static v8::Local BuildModuleFailureReason(v8::Isolate* isolate, + v8::TryCatch& tc, + const char* stage, + const std::string& urlOrKey) { + std::string message = std::string(stage) + ": " + urlOrKey; + if (tc.HasCaught()) { + v8::Local excMessage = tc.Message(); + if (!excMessage.IsEmpty()) { + v8::String::Utf8Value text(isolate, excMessage->Get()); + if (*text != nullptr && strlen(*text) > 0) { + message += std::string(" — ") + *text; + } + } else { + v8::Local exception = tc.Exception(); + if (!exception.IsEmpty()) { + v8::String::Utf8Value text(isolate, exception); + if (*text != nullptr && strlen(*text) > 0) { + message += std::string(" — ") + *text; } + } } + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][failure] %s", message.c_str()); + } + return v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, message)); +} - // 7) Handle JSON modules - if (absPath.size() >= 5 && absPath.compare(absPath.size() - 5, 5, ".json") == 0) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Handling JSON module '%s'", absPath.c_str()); - } +static void ResolveModuleWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local module) { + auto waitIt = g_moduleWaiters.find(registryKey); + if (waitIt == g_moduleWaiters.end()) return; + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_moduleWaiters.erase(waitIt); + ResolveResolversWithModuleNamespace(isolate, context, resolvers, module, + registryKey); +} - // Read JSON file content - std::string jsonText = Runtime::GetRuntime(isolate)->ReadFileText(absPath); +static void RejectModuleWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local reason) { + auto waitIt = g_moduleWaiters.find(registryKey); + if (waitIt == g_moduleWaiters.end()) return; + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_moduleWaiters.erase(waitIt); + RejectResolversWithReason(isolate, context, resolvers, reason); +} - // Create ES module that exports the JSON as default - std::string moduleSource = "export default " + jsonText + ";"; +static void ResolveHttpDynamicWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local module) { + auto waitIt = g_httpDynamicWaiters.find(registryKey); + if (waitIt != g_httpDynamicWaiters.end()) { + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_httpDynamicWaiters.erase(waitIt); + ResolveResolversWithModuleNamespace(isolate, context, resolvers, module, + registryKey); + } + g_modulesInFlight.erase(registryKey); +} - v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, moduleSource); - std::string url = "file://" + absPath; +static void RejectHttpDynamicWaiters(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey, + v8::Local reason) { + auto waitIt = g_httpDynamicWaiters.find(registryKey); + if (waitIt != g_httpDynamicWaiters.end()) { + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_httpDynamicWaiters.erase(waitIt); + RejectResolversWithReason(isolate, context, resolvers, reason); + } + g_modulesInFlight.erase(registryKey); +} - v8::Local urlString; - if (!v8::String::NewFromUtf8(isolate, url.c_str(), v8::NewStringType::kNormal).ToLocal(&urlString)) { - isolate->ThrowException(v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, "Failed to create URL string for JSON module"))); - return v8::MaybeLocal(); - } +static void RejectResolversForInvalidation( + v8::Isolate* isolate, v8::Local context, + std::vector>& resolvers, + const std::string& registryKey) { + if (resolvers.empty()) return; + std::string message = "Module invalidated during dev reload: " + registryKey; + v8::Local error = + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, message)); + for (auto& resolverGlobal : resolvers) { + v8::Local resolver = resolverGlobal.Get(isolate); + if (!resolver.IsEmpty()) { + resolver->Reject(context, error).FromMaybe(false); + } + resolverGlobal.Reset(); + } +} - v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), false, - false, true /* is_module */); +static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, + v8::Local context, + const std::string& registryKey) { + g_moduleReentryCounts.erase(registryKey); + g_moduleReentryParents.erase(registryKey); + g_modulePrimaryImporters.erase(registryKey); + g_modulesInFlight.erase(registryKey); + g_modulesPendingReset.erase(registryKey); + + auto waitIt = g_moduleWaiters.find(registryKey); + if (waitIt != g_moduleWaiters.end()) { + std::vector> resolvers; + resolvers.swap(waitIt->second); + g_moduleWaiters.erase(waitIt); + RejectResolversForInvalidation(isolate, context, resolvers, registryKey); + } + + auto dynamicWaitIt = g_httpDynamicWaiters.find(registryKey); + if (dynamicWaitIt != g_httpDynamicWaiters.end()) { + std::vector> resolvers; + resolvers.swap(dynamicWaitIt->second); + g_httpDynamicWaiters.erase(dynamicWaitIt); + RejectResolversForInvalidation(isolate, context, resolvers, registryKey); + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][invalidate-state] cleared in-flight state for %s", + registryKey.c_str()); + } +} - v8::ScriptCompiler::Source src(sourceText, origin); +namespace { +struct ResolutionStackGuard { + ResolutionStackGuard(v8::Isolate* isolate, std::vector& stack, + const std::string& entry) + : isolate_(isolate), stack_(stack), entry_(entry), active_(true) { + stack_.push_back(entry_); + g_moduleReentryCounts[entry_] = 0; + g_moduleReentryParents.erase(entry_); + if (stack_.size() > 1) { + g_modulePrimaryImporters[entry_] = stack_[stack_.size() - 2]; + } else { + g_modulePrimaryImporters.erase(entry_); + } + g_modulesInFlight.insert(entry_); + g_modulesPendingReset.erase(entry_); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][stack] push (%lu) %s", + static_cast(stack_.size()), entry_.c_str()); + } + } - v8::Local jsonModule; - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&jsonModule)) { - isolate->ThrowException(v8::Exception::SyntaxError( - ArgConverter::ConvertToV8String(isolate, "Failed to compile JSON module"))); - return v8::MaybeLocal(); + ~ResolutionStackGuard() { + if (!active_ || stack_.empty()) return; + auto& g_moduleRegistry = ModuleRegistryFor(isolate_); + auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate_); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][stack] pop (%lu) %s", + static_cast(stack_.size()), entry_.c_str()); + } + g_moduleReentryCounts.erase(entry_); + g_moduleReentryParents.erase(entry_); + g_modulePrimaryImporters.erase(entry_); + g_modulesInFlight.erase(entry_); + + v8::Module::Status finalStatus = v8::Module::kErrored; + auto regIt = g_moduleRegistry.find(entry_); + if (regIt != g_moduleRegistry.end()) { + v8::Local m = regIt->second.Get(isolate_); + if (!m.IsEmpty()) finalStatus = m->GetStatus(); + } + bool isError = finalStatus == v8::Module::kErrored; + auto waitIt = g_moduleWaiters.find(entry_); + if (waitIt != g_moduleWaiters.end()) { + v8::Local currentContext = isolate_->GetCurrentContext(); + if (isError || regIt == g_moduleRegistry.end()) { + std::string msg = "Module evaluation failed: " + entry_; + RejectModuleWaiters( + isolate_, currentContext, entry_, + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate_, msg))); + } else { + v8::Local resolvedModule = regIt->second.Get(isolate_); + ResolveModuleWaiters(isolate_, currentContext, entry_, resolvedModule); + } + } + stack_.pop_back(); + auto pendingIt = g_modulesPendingReset.find(entry_); + if (pendingIt != g_modulesPendingReset.end()) { + auto it = g_moduleRegistry.find(entry_); + if (it != g_moduleRegistry.end()) { + v8::Local module = it->second.Get(isolate_); + v8::Module::Status status = + module.IsEmpty() ? v8::Module::kErrored : module->GetStatus(); + if (status != v8::Module::kEvaluated && status != v8::Module::kErrored) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[resolver] dropping incomplete module after unwind %s (status=%s)", + entry_.c_str(), ModuleStatusToString(status)); + } + RemoveModuleFromRegistry(entry_); } + } + g_modulesPendingReset.erase(pendingIt); + } - // Instantiate and evaluate the JSON module - if (!jsonModule->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - return v8::MaybeLocal(); + auto activeIt = g_moduleRegistry.find(entry_); + if (activeIt != g_moduleRegistry.end()) { + v8::Local activeModule = activeIt->second.Get(isolate_); + if (!activeModule.IsEmpty() && + activeModule->GetStatus() == v8::Module::kEvaluated) { + g_moduleFallbackRegistry[entry_].Reset(isolate_, activeModule); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[resolver] updated fallback module for %s after successful evaluation", + entry_.c_str()); } + } + } + } + + void Release() { active_ = false; } + + private: + v8::Isolate* isolate_; + std::vector& stack_; + std::string entry_; + bool active_; +}; +} // namespace + +// ───────────────────────────────────────────────────────────── +// JSON module → synthetic ES module + +// Compile a `.json` file as an ES module whose default export is the parsed +// JSON value. Handles registry insertion and eager evaluation. +static v8::MaybeLocal CompileJsonAsEsModule( + v8::Isolate* isolate, v8::Local context, + const std::string& absPath, const std::string& registryAbsPath, + bool isWorker) { + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + if (isWorker && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver] Worker handling JSON module '%s'", absPath.c_str()); + } + + std::string jsonText = Runtime::GetRuntime(isolate)->ReadFileText(absPath); + std::string moduleSource = "export default " + jsonText + ";"; + v8::Local sourceText = + ArgConverter::ConvertToV8String(isolate, moduleSource); + std::string url = "file://" + absPath; + + v8::Local urlString; + if (!v8::String::NewFromUtf8(isolate, url.c_str(), + v8::NewStringType::kNormal) + .ToLocal(&urlString)) { + isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Failed to create URL string for JSON module"))); + return v8::MaybeLocal(); + } + + v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), + false, false, true /* is_module */); + v8::ScriptCompiler::Source src(sourceText, origin); + + v8::Local jsonModule; + if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&jsonModule)) { + isolate->ThrowException(v8::Exception::SyntaxError( + ArgConverter::ConvertToV8String(isolate, "Failed to compile JSON module"))); + return v8::MaybeLocal(); + } + + if (!jsonModule->InstantiateModule(context, &ResolveModuleCallback) + .FromMaybe(false)) { + return v8::MaybeLocal(); + } + v8::MaybeLocal evalResult = jsonModule->Evaluate(context); + if (evalResult.IsEmpty()) return v8::MaybeLocal(); + + auto it = g_moduleRegistry.find(registryAbsPath); + if (it != g_moduleRegistry.end()) it->second.Reset(); + g_moduleRegistry[registryAbsPath].Reset(isolate, jsonModule); + return v8::MaybeLocal(jsonModule); +} - v8::MaybeLocal evalResult = jsonModule->Evaluate(context); - if (evalResult.IsEmpty()) { - return v8::MaybeLocal(); - } +// ───────────────────────────────────────────────────────────── +// node: builtin polyfills (Android). iOS ships node:url only; Android has +// carried node:url / node:module / node:path shims for longer. Kept here to +// avoid a behavior regression relative to current Android main. +static const char* NodeUrlPolyfill() { + return "// In-memory polyfill for node:url\n" + "export function fileURLToPath(url) {\n" + " if (typeof url === 'string') {\n" + " if (url.startsWith('file://')) {\n" + " return decodeURIComponent(url.slice(7));\n" + " }\n" + " return url;\n" + " }\n" + " if (url && typeof url.href === 'string') {\n" + " return fileURLToPath(url.href);\n" + " }\n" + " throw new Error('Invalid URL');\n" + "}\n" + "\n" + "export function pathToFileURL(path) {\n" + " const encoded = encodeURIComponent(path).replace(/%2F/g, '/');\n" + " return new URL('file://' + encoded);\n" + "}\n"; +} + +static const char* NodeModulePolyfill() { + return "// In-memory polyfill for node:module\n" + "export function createRequire(filename) {\n" + " if (typeof require === 'function') {\n" + " return require;\n" + " }\n" + " return function(id) {\n" + " throw new Error('Module ' + id + ' not found. NativeScript require() not available.');\n" + " };\n" + "}\n" + "export default { createRequire };\n"; +} + +static const char* NodePathPolyfill() { + return "// In-memory polyfill for node:path\n" + "export const sep = '/';\n" + "export const delimiter = ':';\n" + "\n" + "export function basename(path, ext) {\n" + " const name = path.split('/').pop() || '';\n" + " return ext && name.endsWith(ext) ? name.slice(0, -ext.length) : name;\n" + "}\n" + "\n" + "export function dirname(path) {\n" + " const parts = path.split('/');\n" + " return parts.slice(0, -1).join('/') || '/';\n" + "}\n" + "\n" + "export function extname(path) {\n" + " const name = basename(path);\n" + " const dot = name.lastIndexOf('.');\n" + " return dot > 0 ? name.slice(dot) : '';\n" + "}\n" + "\n" + "export function join(...paths) {\n" + " return paths.filter(Boolean).join('/').replace(/\\/+/g, '/');\n" + "}\n" + "\n" + "export function resolve(...paths) {\n" + " let resolved = '';\n" + " for (let path of paths) {\n" + " if (path.startsWith('/')) {\n" + " resolved = path;\n" + " } else {\n" + " resolved = join(resolved, path);\n" + " }\n" + " }\n" + " return resolved || '/';\n" + "}\n" + "\n" + "export function isAbsolute(path) {\n" + " return path.startsWith('/');\n" + "}\n" + "\n" + "export default { basename, dirname, extname, join, resolve, isAbsolute, sep, delimiter };\n"; +} + +// Compile + register a node: builtin polyfill under `key`. Returns the +// compiled (but not instantiated) module on success. +static v8::MaybeLocal CompileNodeBuiltinPolyfill( + v8::Isolate* isolate, v8::Local context, + const std::string& spec, const std::string& key) { + const std::string builtinName = spec.substr(5); // drop "node:" + const char* polyfill = nullptr; + if (builtinName == "url") polyfill = NodeUrlPolyfill(); + else if (builtinName == "module") polyfill = NodeModulePolyfill(); + else if (builtinName == "path") polyfill = NodePathPolyfill(); + else { + isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, NsBuiltinModules::NotFoundMessage(spec)))); + return v8::MaybeLocal(); + } + return CompileModuleForResolveRegisterOnly(isolate, context, polyfill, key); +} - // Store in registry with safe handle management - auto it = g_moduleRegistry.find(absPath); - if (it != g_moduleRegistry.end()) { - it->second.Reset(); +// ───────────────────────────────────────────────────────────── +// ResolveModuleCallback — invoked by V8 to resolve `import X from ''`. +// +// Structure mirrors iOS: import-map first, then HTTP fast path, then +// filesystem resolution against the application root using the Android +// virtual-root mappings (file:///app/ and file:///android_asset/app/). + +v8::MaybeLocal ResolveModuleCallback( + v8::Local context, v8::Local specifier, + v8::Local /*import_assertions*/, + v8::Local referrer) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate); + + v8::String::Utf8Value specUtf8(isolate, specifier); + const std::string rawSpec = *specUtf8 ? *specUtf8 : ""; + if (rawSpec.empty()) return v8::MaybeLocal(); + + // Builtins resolve before any path handling. + if (NsBuiltinModules::IsRegistered(rawSpec) || + NsBuiltinModules::IsNsScheme(rawSpec)) { + v8::Local builtin; + if (NsBuiltinModules::GetModule(context, rawSpec).ToLocal(&builtin)) { + return v8::MaybeLocal(builtin); + } + if (!NsBuiltinModules::IsRegistered(rawSpec)) { + isolate->ThrowException( + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, NsBuiltinModules::NotFoundMessage(rawSpec)))); + } + return v8::MaybeLocal(); + } + + std::string normalizedSpec = rawSpec; + // Repair malformed http:/ or https:/ prefixes so the HTTP fast path fires. + if (normalizedSpec.rfind("http:/", 0) == 0 && + normalizedSpec.rfind("http://", 0) != 0) { + normalizedSpec.insert(5, "/"); + } else if (normalizedSpec.rfind("https:/", 0) == 0 && + normalizedSpec.rfind("https://", 0) != 0) { + normalizedSpec.insert(6, "/"); + } + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][spec] %s", normalizedSpec.c_str()); + } + + // Guard against a bare '@' spec — invalid; refuse to poison the registry. + if (normalizedSpec == "@") { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][normalize] ignoring invalid '@' static spec"); + } + return v8::MaybeLocal(); + } + + // Import map resolution (bare specifiers → resolved URLs). + if (!g_importMap.empty()) { + std::string mapped = LookupImportMap(normalizedSpec); + if (mapped.empty()) { + std::string normalized = NormalizeViteSpecifier(normalizedSpec); + if (!normalized.empty()) { + mapped = LookupImportMap(normalized); + if (!mapped.empty() && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][import-map] normalized: %s -> %s -> %s", + normalizedSpec.c_str(), normalized.c_str(), + mapped.c_str()); + } + } + } + if (!mapped.empty()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][import-map] rewrite: %s -> %s", + normalizedSpec.c_str(), mapped.c_str()); + } + normalizedSpec = mapped; + } else { + bool looksBare = !normalizedSpec.empty() && normalizedSpec[0] != '/' && + normalizedSpec[0] != '.' && + normalizedSpec.find("://") == std::string::npos && + normalizedSpec.find('\\') == std::string::npos; + if (looksBare && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[resolver][import-map][miss] bare='%s' importMap.size=%lu", + normalizedSpec.c_str(), (unsigned long)g_importMap.size()); + } + } + } + + const std::string& spec = normalizedSpec; + + // Early absolute-HTTP fast path. + if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) { + return LoadHttpModuleForUrl(isolate, context, spec); + } + + const bool isWorker = IsCurrentIsolateWorker(isolate); + if (isWorker && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver] Worker trying to resolve '%s'", spec.c_str()); + } + + // Find the referrer's registered path so we can resolve relative specs + // against its directory. + std::string referrerPath; + for (auto& kv : g_moduleRegistry) { + v8::Local registered = kv.second.Get(isolate); + if (!registered.IsEmpty() && registered == referrer) { + referrerPath = kv.first; + break; + } + } + bool specIsRelative = !spec.empty() && spec[0] == '.'; + if (referrerPath.empty() && specIsRelative) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver] No referrer for relative '%s' - assuming app root", + spec.c_str()); + } + referrerPath = GetApplicationPath() + "/index.mjs"; + } + + size_t slash = referrerPath.find_last_of("/\\"); + std::string baseDir = + slash == std::string::npos ? "" : referrerPath.substr(0, slash + 1); + + // Relative or root-absolute against an HTTP referrer resolves via HTTP. + bool referrerIsHttp = !referrerPath.empty() && + (StartsWith(referrerPath, "http://") || + StartsWith(referrerPath, "https://")); + bool specIsRootAbs = !spec.empty() && spec[0] == '/'; + if (referrerIsHttp && (specIsRelative || specIsRootAbs)) { + std::string resolvedHttp = ResolveHttpRelative(referrerPath, spec); + if (!resolvedHttp.empty() && + (StartsWith(resolvedHttp, "http://") || + StartsWith(resolvedHttp, "https://"))) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][http-rel] base=%s spec=%s -> %s", + referrerPath.c_str(), spec.c_str(), resolvedHttp.c_str()); + } + return LoadHttpModuleForUrl(isolate, context, resolvedHttp); + } + } else if (!referrerIsHttp && specIsRootAbs) { + // Fallback: use __NS_HTTP_ORIGIN__ if present to anchor bare root-absolute + // specs (matches historical Android behavior). + v8::Local key = + ArgConverter::ConvertToV8String(isolate, "__NS_HTTP_ORIGIN__"); + v8::Local global = context->Global(); + v8::MaybeLocal maybeOriginVal = global->Get(context, key); + v8::Local originVal; + if (!maybeOriginVal.IsEmpty() && maybeOriginVal.ToLocal(&originVal) && + originVal->IsString()) { + v8::String::Utf8Value o8(isolate, originVal); + std::string origin = *o8 ? *o8 : ""; + if (!origin.empty() && (StartsWith(origin, "http://") || + StartsWith(origin, "https://"))) { + std::string refBase = origin; + if (refBase.back() != '/') refBase += '/'; + std::string resolved = ResolveHttpRelative(refBase, spec); + if (StartsWith(resolved, "http://") || + StartsWith(resolved, "https://")) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][http-origin][fallback] origin=%s spec=%s -> %s", + refBase.c_str(), spec.c_str(), resolved.c_str()); + } + return LoadHttpModuleForUrl(isolate, context, resolved); } - g_moduleRegistry[absPath].Reset(isolate, jsonModule); - return v8::MaybeLocal(jsonModule); + } } + } + + // ── Build filesystem candidate paths ── + const std::string appPath = GetApplicationPath(); + std::vector candidateBases; - // 8) Check if we've already compiled this module - auto it = g_moduleRegistry.find(absPath); - if (it != g_moduleRegistry.end()) { + if (!spec.empty() && spec[0] == '.') { + std::string cleanSpec = spec.rfind("./", 0) == 0 ? spec.substr(2) : spec; + std::string candidate = NormalizePath(baseDir + cleanSpec); + candidateBases.push_back(candidate); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][normalize-rel] %s + %s -> %s", baseDir.c_str(), + cleanSpec.c_str(), candidate.c_str()); + } + } else if (StartsWith(spec, "file://")) { + // Absolute file URL. Handle the two virtual roots the runtime emits. + std::string tail = spec.substr(7); + if (tail.empty() || tail[0] != '/') tail = "/" + tail; + + const std::string appVirtualRoot = "/app/"; + const std::string androidAssetAppRoot = "/android_asset/app/"; + std::string candidate; + if (tail.rfind(appVirtualRoot, 0) == 0) { + candidate = appPath + "/" + tail.substr(appVirtualRoot.size()); + } else if (tail.rfind(androidAssetAppRoot, 0) == 0) { + candidate = appPath + "/" + tail.substr(androidAssetAppRoot.size()); + } else if (tail.rfind(appPath, 0) == 0) { + candidate = tail; + } else { + candidate = tail; + } + candidateBases.push_back(NormalizePath(candidate)); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][file-url] tail=%s -> %s", tail.c_str(), + candidateBases.back().c_str()); + } + } else if (!spec.empty() && spec[0] == '~') { + std::string tail = spec.size() >= 2 && spec[1] == '/' ? spec.substr(2) + : spec.substr(1); + std::string base = NormalizePath(appPath + "/" + tail); + candidateBases.push_back(base); + // Also try appPath/app for projects that bundle JS under an app folder. + std::string baseApp = NormalizePath(appPath + "/app/" + tail); + if (baseApp != base) candidateBases.push_back(baseApp); if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Found cached module '%s'", absPath.c_str()); + DEBUG_WRITE("[resolver][tilde] spec=%s base=%s appBase=%s", spec.c_str(), + base.c_str(), baseApp.c_str()); + } + } else if (!spec.empty() && spec[0] == '/') { + // Absolute path. Dynamic import may already have resolved a relative + // specifier to a real filesystem path under the application root; use + // that as-is so we don't prefix ApplicationPath twice. Bundle-relative + // paths like /app/... or /src/... still resolve against appPath. + if (!appPath.empty() && spec.rfind(appPath, 0) == 0) { + candidateBases.push_back(NormalizePath(spec)); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][abs-fs] spec=%s", spec.c_str()); + } + } else { + std::string base = NormalizePath(appPath + spec); + candidateBases.push_back(base); + const std::string appPrefix = "/app/"; + if (spec.rfind(appPrefix, 0) == 0) { + std::string tailNoApp = spec.substr(appPrefix.size() - 1); + std::string baseNoApp = NormalizePath(appPath + tailNoApp); + if (baseNoApp != base) candidateBases.push_back(baseNoApp); + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][abs] spec=%s base=%s", spec.c_str(), + base.c_str()); + } + } + } else { + // Bare specifier — resolve relative to the application root. + std::string base = NormalizePath(appPath + "/" + spec); + candidateBases.push_back(base); + // Underscore-separated bundler chunk heuristic. + std::string withSlashes = spec; + std::replace(withSlashes.begin(), withSlashes.end(), '_', '/'); + std::string baseSlashes = NormalizePath(appPath + "/" + withSlashes); + if (baseSlashes != base) candidateBases.push_back(baseSlashes); + } + + // Reroute a candidate that accidentally embeds a collapsed HTTP URL. + auto rerouteHttpIfEmbedded = [&](const std::string& p, + v8::MaybeLocal* moduleOut) -> bool { + size_t pos1 = p.find("/http:/"); + size_t pos2 = p.find("/https:/"); + size_t pos = std::min(pos1 == std::string::npos ? SIZE_MAX : pos1, + pos2 == std::string::npos ? SIZE_MAX : pos2); + if (pos == SIZE_MAX) return false; + std::string tail = p.substr(pos + 1); + if (StartsWith(tail, "http:/") && !StartsWith(tail, "http://")) { + tail.insert(5, "/"); + } else if (StartsWith(tail, "https:/") && !StartsWith(tail, "https://")) { + tail.insert(6, "/"); + } + if (!(StartsWith(tail, "http://") || StartsWith(tail, "https://"))) + return false; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver][http-embedded] %s -> %s", p.c_str(), tail.c_str()); + } + if (moduleOut != nullptr) { + *moduleOut = LoadHttpModuleForUrl(isolate, context, tail); + } + return true; + }; + + // ── Resolve on disk ── + std::string absPath; + bool found = false; + + for (const std::string& baseCandidate : candidateBases) { + absPath = baseCandidate; + + v8::MaybeLocal embeddedHttpModule; + if (rerouteHttpIfEmbedded(absPath, &embeddedHttpModule)) { + return embeddedHttpModule; + } + + if (IsFile(absPath)) { + found = true; + break; + } + const char* exts[] = {".mjs", ".js"}; + for (const char* e : exts) { + std::string cand = NormalizePath(WithExtension(absPath, e)); + if (IsFile(cand)) { + absPath = cand; + found = true; + break; + } + } + if (found) break; + const char* idxExts[] = {"/index.mjs", "/index.js"}; + for (const char* idx : idxExts) { + std::string cand = NormalizePath(absPath + idx); + if (IsFile(cand)) { + absPath = cand; + found = true; + break; + } + } + if (found) break; + } + + if (found) absPath = NormalizePath(absPath); + const std::string registryAbsPath = CanonicalizeRegistryKey(absPath); + + if (!found) { + // node: builtins that don't exist on disk get an in-memory polyfill + // module. Anything else throws Cannot find module (matches iOS HEAD; + // no optional-module empty-return placeholder). + if (IsNodeBuiltinModule(spec)) { + std::string key = spec; // e.g. "node:url" + auto itExisting = g_moduleRegistry.find(key); + if (itExisting != g_moduleRegistry.end()) { + v8::Local existing = itExisting->second.Get(isolate); + if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { + return v8::MaybeLocal(existing); } - return v8::MaybeLocal(it->second.Get(isolate)); + RemoveModuleFromRegistry(key); + } + v8::MaybeLocal m = + CompileNodeBuiltinPolyfill(isolate, context, spec, key); + v8::Local mod; + if (m.ToLocal(&mod)) return m; + // CompileNodeBuiltinPolyfill already threw (unknown builtin, or + // compile failure). Do not overwrite that exception. + return v8::MaybeLocal(); + } + std::string msg = "Cannot find module '" + spec + "' (tried " + absPath + ")"; + isolate->ThrowException( + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); + return v8::MaybeLocal(); + } + + // JSON module: compile a synthetic ESM. + if (EndsWith(absPath, ".json")) { + return CompileJsonAsEsModule(isolate, context, absPath, registryAbsPath, + isWorker); + } + + // Cache lookup. + auto it = g_moduleRegistry.find(registryAbsPath); + if (it != g_moduleRegistry.end()) { + v8::Local existing = it->second.Get(isolate); + v8::Module::Status status = + existing.IsEmpty() ? v8::Module::kErrored : existing->GetStatus(); + bool inCurrentStack = + std::find(g_moduleResolutionStack.begin(), + g_moduleResolutionStack.end(), + registryAbsPath) != g_moduleResolutionStack.end(); + bool shouldReuse = !existing.IsEmpty() && status != v8::Module::kErrored; + if (shouldReuse && + (status == v8::Module::kUninstantiated || + status == v8::Module::kInstantiating || + status == v8::Module::kEvaluating)) { + if (!inCurrentStack) shouldReuse = false; + } + if (shouldReuse) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver] cache hit %s (status=%s)", absPath.c_str(), + ModuleStatusToString(status)); + } + return v8::MaybeLocal(existing); } + if (!existing.IsEmpty() && status == v8::Module::kEvaluated) { + auto fallbackIt = g_moduleFallbackRegistry.find(registryAbsPath); + if (fallbackIt != g_moduleFallbackRegistry.end()) { + fallbackIt->second.Reset(); + } + g_moduleFallbackRegistry[registryAbsPath].Reset(isolate, existing); + } + RemoveModuleFromRegistry(absPath); + } - // 9) Compile and register the new module + // Detect recursive load prior to LoadESModule. + auto cycleIt = std::find(g_moduleResolutionStack.begin(), + g_moduleResolutionStack.end(), registryAbsPath); + if (cycleIt != g_moduleResolutionStack.end()) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ResolveModuleCallback: Compiling new module '%s'", absPath.c_str()); + DEBUG_WRITE( + "[resolver] Detected recursive load for %s (stack len %lu)", + absPath.c_str(), (unsigned long)g_moduleResolutionStack.size()); + } + auto existing = g_moduleRegistry.find(registryAbsPath); + if (existing != g_moduleRegistry.end()) { + return v8::MaybeLocal(existing->second.Get(isolate)); + } + if (IsDebuggable()) { + DEBUG_WRITE("[resolver] Debug mode - empty return for recursive load: %s", + absPath.c_str()); + return v8::MaybeLocal(); } - try { - // Use our existing LoadESModule function to compile the module - tns::ModuleInternal::LoadESModule(isolate, absPath); - } catch (NativeScriptException& ex) { - DEBUG_WRITE("ResolveModuleCallback: Failed to compile module '%s'", absPath.c_str()); - ex.ReThrowToV8(); - return v8::MaybeLocal(); + std::string msg = "Recursive module resolution detected for " + absPath; + isolate->ThrowException( + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); + return v8::MaybeLocal(); + } + + ResolutionStackGuard stackGuard(isolate, g_moduleResolutionStack, + registryAbsPath); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[resolver] -> LoadESModule %s", absPath.c_str()); + } + try { + tns::ModuleInternal::LoadESModule(isolate, absPath); + } catch (NativeScriptException& ex) { + if (isWorker) { + DEBUG_WRITE("[resolver] Worker failed to compile '%s' -> '%s'", + spec.c_str(), absPath.c_str()); } + ex.ReThrowToV8(); + return v8::MaybeLocal(); + } + auto it2 = g_moduleRegistry.find(registryAbsPath); + if (it2 == g_moduleRegistry.end()) { + return v8::MaybeLocal(); + } + return v8::MaybeLocal(it2->second.Get(isolate)); +} - // LoadESModule should have added it to g_moduleRegistry - auto it2 = g_moduleRegistry.find(absPath); - if (it2 == g_moduleRegistry.end()) { - // Something went wrong - std::string msg = "Failed to register compiled module: " + absPath; - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); +// ───────────────────────────────────────────────────────────── +// FinishHttpDynamicImport +// +// Called on the JS thread once the async graph walk has fetched (and +// registered as uninstantiated) the transitive closure for an HTTP dynamic +// import. Instantiates + evaluates the root and settles all queued +// dynamic-import waiters. Top-level await is fanned out to a Then handler so +// waiters only settle after the returned promise settles. +static void FinishHttpDynamicImport(v8::Isolate* isolate, + v8::Local context, + const std::string& key, + const std::string& requestUrl) { + if (IsScriptLoadingLogEnabled()) { + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + if (g_moduleRegistry.find(key) == g_moduleRegistry.end()) { + DEBUG_WRITE("[async-graph][fallback-sync-load] root missed walk: %s", + key.c_str()); } + } + v8::MaybeLocal modMaybe = + LoadHttpModuleForUrl(isolate, context, requestUrl); + if (!modMaybe.IsEmpty()) { + v8::Local mod; + if (modMaybe.ToLocal(&mod)) { + if (mod->GetStatus() == v8::Module::kUninstantiated) { + v8::TryCatch tcInstantiate(isolate); + if (!mod->InstantiateModule(context, &ResolveModuleCallback) + .FromMaybe(false)) { + RemoveModuleFromRegistry(key); + RejectHttpDynamicWaiters( + isolate, context, key, + BuildModuleFailureReason(isolate, tcInstantiate, + "Instantiation failed (http-loader)", + requestUrl)); + return; + } + } - return v8::MaybeLocal(it2->second.Get(isolate)); + if (IsModuleEvaluationInProgress(mod->GetStatus())) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[dyn-import][http-loader] waiting on existing evaluation for %s status=%s", + key.c_str(), ModuleStatusToString(mod->GetStatus())); + } + return; + } + + if (mod->GetStatus() != v8::Module::kEvaluated) { + v8::Local evalResult; + { + v8::TryCatch tcEvaluate(isolate); + if (!mod->Evaluate(context).ToLocal(&evalResult)) { + RemoveModuleFromRegistry(key); + RejectHttpDynamicWaiters( + isolate, context, key, + BuildModuleFailureReason(isolate, tcEvaluate, + "Evaluation failed (http-loader)", + requestUrl)); + return; + } + } + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + v8::Local p = evalResult.As(); + struct EvalWaitData2 { + std::string key; + v8::Global ctx; + v8::Global mod; + }; + auto* data2 = new EvalWaitData2{ + key, v8::Global(isolate, context), + v8::Global(isolate, mod)}; + auto onFulfilled2 = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + std::string keyLocal = d->key; + v8::Local modLocal = d->mod.Get(iso); + ResolveHttpDynamicWaiters(iso, ctx, keyLocal, modLocal); + delete d; + }; + auto onRejected2 = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + std::string keyLocal = d->key; + v8::Local reason = + (info.Length() > 0) + ? info[0] + : v8::Exception::Error(ArgConverter::ConvertToV8String( + iso, "Evaluation failed (http-loader TLA)")); + if (IsScriptLoadingLogEnabled()) { + v8::String::Utf8Value r(iso, reason); + if (*r) { + DEBUG_WRITE("[dyn-import][http-loader][tla] rejected: %s", *r); + } + } + RejectHttpDynamicWaiters(iso, ctx, keyLocal, reason); + delete d; + }; + v8::Local thenFulfillTpl2 = + v8::FunctionTemplate::New( + isolate, onFulfilled2, + v8::External::New(isolate, data2, + v8::kExternalPointerTypeTagDefault)); + v8::Local thenFulfill2 = + thenFulfillTpl2->GetFunction(context).ToLocalChecked(); + v8::Local thenRejectTpl2 = + v8::FunctionTemplate::New( + isolate, onRejected2, + v8::External::New(isolate, data2, + v8::kExternalPointerTypeTagDefault)); + v8::Local thenReject2 = + thenRejectTpl2->GetFunction(context).ToLocalChecked(); + p->Then(context, thenFulfill2, thenReject2).ToLocalChecked(); + return; + } + } + ResolveHttpDynamicWaiters(isolate, context, key, mod); + return; + } + } + RejectHttpDynamicWaiters( + isolate, context, key, + v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, "HTTP fetch/compile failed"))); } -// Dynamic import() host callback +// ───────────────────────────────────────────────────────────── +// ImportModuleDynamicallyCallback — host callback for `import()` expressions. +// +// Structure mirrors iOS: builtins → import-map → invalid-'@' guard → blob URL +// path → HTTP fast path (with coalescing + cache) → filesystem resolution via +// ResolveModuleCallback → instantiate/evaluate/TLA settle. v8::MaybeLocal ImportModuleDynamicallyCallback( - v8::Local context, v8::Local host_defined_options, + v8::Local context, v8::Local /*host_defined_options*/, v8::Local resource_name, v8::Local specifier, v8::Local import_assertions) { - v8::Isolate* isolate = v8::Isolate::GetCurrent(); - - // Convert specifier to std::string for logging - v8::String::Utf8Value specUtf8(isolate, specifier); - std::string spec = *specUtf8 ? *specUtf8 : ""; - + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + + v8::String::Utf8Value specUtf8(isolate, specifier); + const char* cSpec = (*specUtf8) ? *specUtf8 : ""; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import] -> %s", cSpec); + v8::Local resName = resource_name; + if (!resName.IsEmpty() && resName->IsString()) { + v8::String::Utf8Value rn(isolate, resName); + if (*rn) { + DEBUG_WRITE("[dyn-import][referrer] %s", *rn); + } + } + } + + std::string rawSpec = cSpec ? std::string(cSpec) : std::string(); + + // Builtin modules never touch the loader below; the namespace comes straight + // from the realm's synthetic module. + if (NsBuiltinModules::IsRegistered(rawSpec) || + NsBuiltinModules::IsNsScheme(rawSpec)) { + v8::EscapableHandleScope builtinScope(isolate); + v8::Local builtinResolver; + if (!v8::Promise::Resolver::New(context).ToLocal(&builtinResolver)) { + return v8::MaybeLocal(); + } + v8::TryCatch tc(isolate); + v8::Local builtin; + if (NsBuiltinModules::GetModule(context, rawSpec).ToLocal(&builtin)) { + builtinResolver->Resolve(context, builtin->GetModuleNamespace()) + .FromMaybe(false); + } else { + v8::Local error = + tc.HasCaught() + ? tc.Exception() + : v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, NsBuiltinModules::NotFoundMessage(rawSpec))); + // Reject must not run with a pending exception on the isolate. + tc.Reset(); + builtinResolver->Reject(context, error).FromMaybe(false); + } + return builtinScope.Escape(builtinResolver->GetPromise()); + } + + std::string normalizedSpec = rawSpec; + // remove query/hash ONLY for non-HTTP specs + bool isHttpLike = + (!normalizedSpec.empty() && (StartsWith(normalizedSpec, "http://") || + StartsWith(normalizedSpec, "https://"))); + if (!isHttpLike) { + size_t qpos = normalizedSpec.find_first_of("?#"); + if (qpos != std::string::npos) { + normalizedSpec = normalizedSpec.substr(0, qpos); + } + } + if (normalizedSpec != rawSpec) { + specifier = ArgConverter::ConvertToV8String(isolate, normalizedSpec); if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Dynamic import for '%s'", spec.c_str()); + DEBUG_WRITE("[dyn-import][normalize] %s -> %s", rawSpec.c_str(), + normalizedSpec.c_str()); } - - v8::EscapableHandleScope scope(isolate); - - // Create a Promise resolver we'll resolve/reject synchronously for now. - v8::Local resolver; - if (!v8::Promise::Resolver::New(context).ToLocal(&resolver)) { - // Failed to create resolver, return empty promise - return v8::MaybeLocal(); + } + + v8::EscapableHandleScope scope(isolate); + + v8::Local resolver; + if (!v8::Promise::Resolver::New(context).ToLocal(&resolver)) { + return v8::MaybeLocal(); + } + + // ── Import map resolution for dynamic import() ── + if (!g_importMap.empty() && !normalizedSpec.empty() && normalizedSpec != "@") { + std::string mapped = LookupImportMap(normalizedSpec); + if (mapped.empty()) { + std::string normalized = NormalizeViteSpecifier(normalizedSpec); + if (!normalized.empty()) { + mapped = LookupImportMap(normalized); + if (!mapped.empty() && IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][import-map] normalized: %s -> %s -> %s", + normalizedSpec.c_str(), normalized.c_str(), + mapped.c_str()); + } + } } - - // Builtin modules never reach the loader below; the namespace comes - // straight from the realm's synthetic module. - if (NsBuiltinModules::IsRegistered(spec) || NsBuiltinModules::IsNsScheme(spec)) { - v8::TryCatch tc(isolate); - v8::Local builtin; - if (NsBuiltinModules::GetModule(context, spec).ToLocal(&builtin)) { - resolver->Resolve(context, builtin->GetModuleNamespace()).FromMaybe(false); - } else { - v8::Local error = - tc.HasCaught() ? tc.Exception() - : v8::Exception::Error(ArgConverter::ConvertToV8String( - isolate, NsBuiltinModules::NotFoundMessage(spec))); - // Reject must not run with the exception still pending on the isolate. - tc.Reset(); - resolver->Reject(context, error).FromMaybe(false); + if (!mapped.empty()) { + normalizedSpec = mapped; + specifier = ArgConverter::ConvertToV8String(isolate, normalizedSpec); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][import-map] rewrite: %s -> %s", + rawSpec.c_str(), normalizedSpec.c_str()); + } + } + } + + try { + // Defensive guard: some dev-time toolchains emit a stray import('@') during + // bootstrap. Treat it as a no-op module to avoid a hard failure. + if (!normalizedSpec.empty() && normalizedSpec == "@") { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[dyn-import] ignoring invalid '@' spec (returning empty module)"); + } + const char* kEmptySrc = "export {}\n"; + std::string url = "file:///app/__invalid_at__.mjs"; + v8::MaybeLocal modMaybe = + CompileModuleFromSource(isolate, context, kEmptySrc, url); + v8::Local mod; + if (modMaybe.ToLocal(&mod)) { + g_moduleRegistry[CanonicalizeRegistryKey(url)].Reset(isolate, mod); + if (mod->GetStatus() != v8::Module::kEvaluated) { + if (mod->Evaluate(context).IsEmpty()) { + resolver + ->Reject(context, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Evaluation failed for empty module"))) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } } + resolver->Resolve(context, mod->GetModuleNamespace()).FromMaybe(false); return scope.Escape(resolver->GetPromise()); + } } - // Resolve relative or root-absolute dynamic imports against the referrer's URL when provided - auto isHttpLike = [](const std::string& s) -> bool { - return s.rfind("http://", 0) == 0 || s.rfind("https://", 0) == 0; - }; - bool specIsRelative = !spec.empty() && spec[0] == '.'; - bool specIsRootAbs = !spec.empty() && spec[0] == '/'; - std::string referrerUrl; - if (!resource_name.IsEmpty() && resource_name->IsString()) { - v8::String::Utf8Value r8(isolate, resource_name); - referrerUrl = *r8 ? *r8 : ""; - } - if ((specIsRelative || specIsRootAbs) && isHttpLike(referrerUrl)) { - std::string resolved = ResolveHttpRelative(referrerUrl, spec); - if (!resolved.empty()) { + // ── Blob URL support (e.g. blob:nativescript/) ── + // Retrieve the blob content from the global BLOB_STORE via + // URL.InternalAccessor.getData() (installed by Android's blob-url.js) and + // compile it as an ES module. + if (!normalizedSpec.empty() && + StartsWith(normalizedSpec, "blob:nativescript/")) { + const std::string blobRegistryKey = CanonicalizeRegistryKey(normalizedSpec); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] trying blob URL %s key=%s", + normalizedSpec.c_str(), blobRegistryKey.c_str()); + } + + auto existingIt = g_moduleRegistry.find(blobRegistryKey); + if (existingIt != g_moduleRegistry.end()) { + v8::Local existing = existingIt->second.Get(isolate); + if (!existing.IsEmpty()) { + v8::Module::Status existingStatus = existing->GetStatus(); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob-cache] hit %s status=%s", + blobRegistryKey.c_str(), + ModuleStatusToString(existingStatus)); + } + if (existingStatus == v8::Module::kErrored) { + RemoveModuleFromRegistry(blobRegistryKey); + } else if (IsModuleEvaluationInProgress(existingStatus)) { + g_modulesInFlight.insert(blobRegistryKey); + g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][http-rel] base=%s spec=%s -> %s", referrerUrl.c_str(), spec.c_str(), resolved.c_str()); + DEBUG_WRITE( + "[dyn-import][blob-await] queued waiter for %s status=%s", + blobRegistryKey.c_str(), ModuleStatusToString(existingStatus)); } - spec = resolved; - } else if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][http-rel][skip] base=%s spec=%s", referrerUrl.c_str(), spec.c_str()); + return scope.Escape(resolver->GetPromise()); + } else { + resolver->Resolve(context, existing->GetModuleNamespace()) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } + } else { + RemoveModuleFromRegistry(blobRegistryKey); + } + } + + if (g_modulesInFlight.find(blobRegistryKey) != g_modulesInFlight.end()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] coalesce in-flight %s", + blobRegistryKey.c_str()); } + g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); + return scope.Escape(resolver->GetPromise()); + } + + g_modulesInFlight.insert(blobRegistryKey); + g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); + + v8::TryCatch tc(isolate); + v8::Local globalObj = context->Global(); + + v8::Local urlCtorVal; + if (!globalObj + ->Get(context, ArgConverter::ConvertToV8String(isolate, "URL")) + .ToLocal(&urlCtorVal) || + !urlCtorVal->IsFunction()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] URL constructor not found"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "URL constructor not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local urlCtor = urlCtorVal.As(); + + v8::Local internalAccessorVal; + if (!urlCtor + ->Get(context, ArgConverter::ConvertToV8String(isolate, + "InternalAccessor")) + .ToLocal(&internalAccessorVal) || + !internalAccessorVal->IsObject()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] URL.InternalAccessor not found"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "URL.InternalAccessor not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local internalAccessor = + internalAccessorVal.As(); + + v8::Local getDataVal; + if (!internalAccessor + ->Get(context, + ArgConverter::ConvertToV8String(isolate, "getData")) + .ToLocal(&getDataVal) || + !getDataVal->IsFunction()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] URL.InternalAccessor.getData not found"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "URL.InternalAccessor.getData not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local getDataFn = getDataVal.As(); + + v8::Local urlArg = + ArgConverter::ConvertToV8String(isolate, normalizedSpec); + v8::Local blobDataVal; + if (!getDataFn->Call(context, internalAccessor, 1, &urlArg) + .ToLocal(&blobDataVal) || + blobDataVal->IsNullOrUndefined()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] blob not found in BLOB_STORE: %s", + normalizedSpec.c_str()); + } + std::string msg = "Blob not found: " + normalizedSpec; + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); + return scope.Escape(resolver->GetPromise()); + } + + if (!blobDataVal->IsObject()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] blob data is not an object"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, "Invalid blob data"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local blobData = blobDataVal.As(); + + v8::Local blobVal; + if (!blobData + ->Get(context, ArgConverter::ConvertToV8String(isolate, "blob")) + .ToLocal(&blobVal) || + !blobVal->IsObject()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] blob property not found"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Blob object not found"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local blobObj = blobVal.As(); + + v8::Local textFnVal; + if (!blobObj + ->Get(context, ArgConverter::ConvertToV8String(isolate, "text")) + .ToLocal(&textFnVal) || + !textFnVal->IsFunction()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] Blob.text() not available"); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Blob.text() not available"))); + return scope.Escape(resolver->GetPromise()); + } + v8::Local textFn = textFnVal.As(); + + // Keep the two failure modes distinct — a throw out of text() and a + // non-thenable return — and carry the thrown value's text into the + // rejection to preserve diagnostics. + v8::Local textResultVal; + std::string textFailure; + { + v8::TryCatch textTc(isolate); + if (!textFn->Call(context, blobObj, 0, nullptr) + .ToLocal(&textResultVal)) { + textFailure = "Blob.text() threw"; + if (textTc.HasCaught()) { + v8::String::Utf8Value thrown(isolate, textTc.Exception()); + if (*thrown) { + textFailure += std::string(": ") + *thrown; + } + } + } + } + + v8::Local textPromise; + if (textFailure.empty() && + !AdoptThenable(isolate, context, textResultVal).ToLocal(&textPromise)) { + textFailure = "Blob.text() did not return a thenable"; + } + if (!textFailure.empty()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] %s", textFailure.c_str()); + } + RejectHttpDynamicWaiters( + isolate, context, blobRegistryKey, + v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, textFailure))); + return scope.Escape(resolver->GetPromise()); + } + + struct BlobImportData { + v8::Global ctx; + std::string blobUrl; + std::string registryKey; + }; + auto* data = new BlobImportData{v8::Global(isolate, context), + normalizedSpec, blobRegistryKey}; + + auto onFulfilled = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + + if (info.Length() < 1 || !info[0]->IsString()) { + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, + v8::Exception::Error( + ArgConverter::ConvertToV8String( + iso, "Blob text is not a string"))); + delete d; + return; + } + + v8::String::Utf8Value codeUtf8(iso, info[0]); + std::string code = *codeUtf8 ? *codeUtf8 : ""; + + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][blob] compiling blob module, code length=%zu", + code.size()); + } + + v8::MaybeLocal modMaybe = + CompileModuleForResolveRegisterOnly(iso, ctx, code, d->blobUrl); + v8::Local mod; + if (!modMaybe.ToLocal(&mod)) { + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, + v8::Exception::Error( + ArgConverter::ConvertToV8String( + iso, "Failed to compile blob module"))); + delete d; + return; + } + + if (mod->GetStatus() == v8::Module::kUninstantiated && + !mod->InstantiateModule(ctx, &ResolveModuleCallback) + .FromMaybe(false)) { + RemoveModuleFromRegistry(d->registryKey); + RejectHttpDynamicWaiters( + iso, ctx, d->registryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + iso, "Failed to instantiate blob module"))); + delete d; + return; + } + + if (IsModuleEvaluationInProgress(mod->GetStatus())) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[dyn-import][blob] waiting on existing evaluation for %s status=%s", + d->registryKey.c_str(), ModuleStatusToString(mod->GetStatus())); + } + delete d; + return; + } + + if (mod->GetStatus() != v8::Module::kEvaluated) { + v8::Local evalResult; + if (!mod->Evaluate(ctx).ToLocal(&evalResult)) { + RemoveModuleFromRegistry(d->registryKey); + RejectHttpDynamicWaiters( + iso, ctx, d->registryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String( + iso, "Failed to evaluate blob module"))); + delete d; + return; + } + + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + struct BlobEvalData { + std::string registryKey; + v8::Global ctx; + v8::Global mod; + }; + auto* evalData = new BlobEvalData{ + d->registryKey, v8::Global(iso, ctx), + v8::Global(iso, mod)}; + + auto onEvalFulfilled = + [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local mod = d->mod.Get(iso); + ResolveHttpDynamicWaiters(iso, ctx, d->registryKey, mod); + delete d; + }; + + auto onEvalRejected = + [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local reason = + info.Length() > 0 + ? info[0] + : v8::Exception::Error( + ArgConverter::ConvertToV8String( + iso, "Blob module evaluation failed")); + RemoveModuleFromRegistry(d->registryKey); + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, reason); + delete d; + }; + + v8::Local evalPromise = evalResult.As(); + v8::Local onEvalFulfilledFn = + v8::Function::New( + ctx, onEvalFulfilled, + v8::External::New(iso, evalData, + v8::kExternalPointerTypeTagDefault)) + .ToLocalChecked(); + v8::Local onEvalRejectedFn = + v8::Function::New( + ctx, onEvalRejected, + v8::External::New(iso, evalData, + v8::kExternalPointerTypeTagDefault)) + .ToLocalChecked(); + evalPromise->Then(ctx, onEvalFulfilledFn, onEvalRejectedFn) + .FromMaybe(v8::Local()); + delete d; + return; + } + } + + ResolveHttpDynamicWaiters(iso, ctx, d->registryKey, mod); + delete d; + }; + + auto onRejected = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local reason = + info.Length() > 0 + ? info[0] + : v8::Exception::Error(ArgConverter::ConvertToV8String( + iso, "Blob text() failed")); + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, reason); + delete d; + }; + + v8::Local onFulfilledFn = + v8::Function::New( + context, onFulfilled, + v8::External::New(isolate, data, + v8::kExternalPointerTypeTagDefault)) + .ToLocalChecked(); + v8::Local onRejectedFn = + v8::Function::New( + context, onRejected, + v8::External::New(isolate, data, + v8::kExternalPointerTypeTagDefault)) + .ToLocalChecked(); + + textPromise->Then(context, onFulfilledFn, onRejectedFn) + .FromMaybe(v8::Local()); + + return scope.Escape(resolver->GetPromise()); } - // Handle HTTP(S) dynamic import directly + // ── HTTP(S) fast path ── // Security: HttpFetchText gates remote module access centrally. - if (!spec.empty() && isHttpLike(spec)) { - std::string canonical = tns::CanonicalizeHttpUrlKey(spec); + if (!normalizedSpec.empty() && + (StartsWith(normalizedSpec, "http://") || + StartsWith(normalizedSpec, "https://"))) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][http-loader] trying URL %s", + normalizedSpec.c_str()); + } + std::string key = CanonicalizeHttpUrlKey(normalizedSpec); + + // Volatile-pattern eviction: if the URL matches any configured volatile + // pattern, evict the cached module so we always re-fetch. Policy is + // supplied exclusively by JS via ns:module `configureLoader({ + // volatilePatterns })` — the runtime carries no framework or server URL + // vocabulary of its own. + bool isVolatile = IsVolatileUrl(normalizedSpec); + if (isVolatile) { + auto ex = g_moduleRegistry.find(key); + if (ex != g_moduleRegistry.end()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][http-cache] drop volatile %s", key.c_str()); + } + RemoveModuleFromRegistry(key); + } + } + // Coalesce concurrent dynamic imports for the same HTTP key. + auto inflight = g_modulesInFlight.find(key) != g_modulesInFlight.end(); + if (inflight) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][resolve] spec=%s canonical=%s", spec.c_str(), canonical.c_str()); + DEBUG_WRITE("[dyn-import][http] coalesce in-flight %s", key.c_str()); } - v8::Local mod; - auto it = g_moduleRegistry.find(canonical); - if (it != g_moduleRegistry.end()) { - mod = it->second.Get(isolate); + g_httpDynamicWaiters[key].emplace_back(isolate, resolver); + return scope.Escape(resolver->GetPromise()); + } + // If module was already compiled, resolve immediately. + auto itExisting = g_moduleRegistry.find(key); + if (itExisting != g_moduleRegistry.end()) { + v8::Local existing = itExisting->second.Get(isolate); + if (!existing.IsEmpty()) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][http-cache] hit %s status=%s", key.c_str(), + ModuleStatusToString(existing->GetStatus())); + } + v8::Module::Status st = existing->GetStatus(); + if (st == v8::Module::kErrored) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][cache] hit %s", canonical.c_str()); + DEBUG_WRITE("[dyn-import][http-cache] dropping errored module for %s", + key.c_str()); } - } else { - std::string body, ct; int status = 0; - if (!tns::HttpFetchText(spec, body, ct, status)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][fetch][fail] url=%s status=%d", spec.c_str(), status); - } - resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, std::string("Failed to fetch ")+spec))).Check(); - return scope.Escape(resolver->GetPromise()); + RemoveModuleFromRegistry(key); + } else if (IsModuleEvaluationInProgress(st)) { + if (QueueHttpDynamicWaiterIfInFlight(isolate, key, existing, + resolver)) { + return scope.Escape(resolver->GetPromise()); } if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][fetch][ok] url=%s status=%d bytes=%lu ct=%s", spec.c_str(), status, (unsigned long)body.size(), ct.c_str()); + DEBUG_WRITE( + "[dyn-import][http-cache] avoiding re-entrant Evaluate for %s status=%s", + key.c_str(), ModuleStatusToString(st)); } - v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, body); - v8::Local urlString = ArgConverter::ConvertToV8String(isolate, canonical); - v8::ScriptOrigin origin(urlString, 0, 0, false, -1, v8::Local(), false, false, true); - v8::ScriptCompiler::Source src(sourceText, origin); - { - v8::TryCatch tc(isolate); - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { - LogHttpCompileDiagnostics(isolate, context, canonical, body, tc); - resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "HTTP module compile failed"))).Check(); - return scope.Escape(resolver->GetPromise()); + resolver->Resolve(context, existing->GetModuleNamespace()) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } else { + if (st != v8::Module::kEvaluated) { + g_modulesInFlight.insert(key); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][http-cache] awaiting evaluation %s", + key.c_str()); + } + g_httpDynamicWaiters[key].emplace_back(isolate, resolver); + if (st == v8::Module::kUninstantiated) { + v8::TryCatch tcInstantiate(isolate); + if (!existing->InstantiateModule(context, &ResolveModuleCallback) + .FromMaybe(false)) { + RemoveModuleFromRegistry(key); + RejectHttpDynamicWaiters( + isolate, context, key, + BuildModuleFailureReason( + isolate, tcInstantiate, + "Instantiation failed (http-cache hit)", key)); + return scope.Escape(resolver->GetPromise()); } - } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][dyn][compile][ok] %s bytes=%lu", canonical.c_str(), (unsigned long)body.size()); - } - g_moduleRegistry[canonical].Reset(isolate, mod); - } - if (mod->GetStatus() == v8::Module::kUninstantiated) { - if (!mod->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Instantiate failed"))).Check(); + } + + if (IsModuleEvaluationInProgress(existing->GetStatus())) { return scope.Escape(resolver->GetPromise()); - } - } - if (mod->GetStatus() != v8::Module::kEvaluated) { - if (mod->Evaluate(context).IsEmpty()) { - resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Evaluation failed"))).Check(); + } + + v8::Local evalResult; + { + v8::TryCatch tcEvaluate(isolate); + if (!existing->Evaluate(context).ToLocal(&evalResult)) { + RemoveModuleFromRegistry(key); + RejectHttpDynamicWaiters( + isolate, context, key, + BuildModuleFailureReason( + isolate, tcEvaluate, + "Evaluation failed (http-cache hit)", key)); + return scope.Escape(resolver->GetPromise()); + } + } + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + v8::Local p = evalResult.As(); + struct EvalWaitData { + std::string key; + v8::Global ctx; + v8::Global mod; + }; + auto* data = new EvalWaitData{ + key, v8::Global(isolate, context), + v8::Global(isolate, existing)}; + auto onFulfilled = + [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + std::string keyLocal = d->key; + v8::Local modLocal = d->mod.Get(iso); + ResolveHttpDynamicWaiters(iso, ctx, keyLocal, modLocal); + delete d; + }; + auto onRejected = + [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + std::string keyLocal = d->key; + v8::Local reason = + (info.Length() > 0) + ? info[0] + : v8::Exception::Error( + ArgConverter::ConvertToV8String( + iso, "Evaluation failed (http-cache TLA)")); + if (IsScriptLoadingLogEnabled()) { + v8::String::Utf8Value r(iso, reason); + if (*r) { + DEBUG_WRITE("[dyn-import][http-cache][tla] rejected: %s", + *r); + } + } + RejectHttpDynamicWaiters(iso, ctx, keyLocal, reason); + delete d; + }; + v8::Local thenFulfillTpl = + v8::FunctionTemplate::New( + isolate, onFulfilled, + v8::External::New(isolate, data, + v8::kExternalPointerTypeTagDefault)); + v8::Local thenFulfill = + thenFulfillTpl->GetFunction(context).ToLocalChecked(); + v8::Local thenRejectTpl = + v8::FunctionTemplate::New( + isolate, onRejected, + v8::External::New(isolate, data, + v8::kExternalPointerTypeTagDefault)); + v8::Local thenReject = + thenRejectTpl->GetFunction(context).ToLocalChecked(); + p->Then(context, thenFulfill, thenReject).ToLocalChecked(); return scope.Escape(resolver->GetPromise()); + } + ResolveHttpDynamicWaiters(isolate, context, key, existing); + return scope.Escape(resolver->GetPromise()); } + resolver->Resolve(context, existing->GetModuleNamespace()) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } } - resolver->Resolve(context, mod->GetModuleNamespace()).Check(); - return scope.Escape(resolver->GetPromise()); + } + // Mark in-flight and start the async graph load. + g_modulesInFlight.insert(key); + g_httpDynamicWaiters[key].emplace_back(isolate, resolver); + const std::string requestUrl = normalizedSpec; + StartAsyncHttpModuleGraphLoad( + isolate, context, requestUrl, + [key, requestUrl, isolate](bool ok, const std::string& errorMessage, + v8::Local completionContext) { + v8::Isolate* iso = isolate; + if (!ok) { + RejectHttpDynamicWaiters( + iso, completionContext, key, + v8::Exception::Error( + ArgConverter::ConvertToV8String(iso, errorMessage))); + return; + } + FinishHttpDynamicImport(iso, completionContext, key, requestUrl); + }); + return scope.Escape(resolver->GetPromise()); } - // Re-use the static resolver to locate / compile the module for non-HTTP cases. - try { - // V8 exposes only the referrer's URL here (resource_name), not its Module, - // so anchor a relative specifier at the referrer's directory and hand the - // resolver an absolute file:// URL. Other specifiers pass through unchanged - // (the resolver applies its own ~/, bare and absolute heuristics). - v8::Local resolvedSpecifier = specifier; - if (specIsRelative) { - std::string fileResolved = ResolveFileRelative(referrerUrl, spec); - if (!fileResolved.empty()) { - resolvedSpecifier = ArgConverter::ConvertToV8String(isolate, fileResolved); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[esm][dyn][file-rel] base=%s spec=%s -> %s", - referrerUrl.c_str(), spec.c_str(), fileResolved.c_str()); - } + // ── Filesystem path ── + // For relative specs, adjust against the referrer's resource URL so + // ../-segments collapse and the resolver can find the target on disk. + v8::Local refMod; + v8::Local adjustedSpecifier = specifier; + if (!normalizedSpec.empty() && + (normalizedSpec.rfind("./", 0) == 0 || + normalizedSpec.rfind("../", 0) == 0)) { + v8::Local resName = resource_name; + if (!resName.IsEmpty() && resName->IsString()) { + v8::String::Utf8Value rn(isolate, resName); + std::string refUrl = *rn ? *rn : std::string(); + if (!refUrl.empty()) { + std::string refPath = FileURLToPath(refUrl); + size_t slash = refPath.find_last_of("/\\"); + std::string baseDir = slash == std::string::npos + ? std::string() + : refPath.substr(0, slash + 1); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][ref] url=%s base=%s spec=%s", refUrl.c_str(), + baseDir.c_str(), normalizedSpec.c_str()); + } + std::string fsPath = NormalizePath(baseDir + normalizedSpec); + if (!fsPath.empty()) { + adjustedSpecifier = + ArgConverter::ConvertToV8String(isolate, fsPath); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][normalize-rel] %s + %s -> %s", + baseDir.c_str(), normalizedSpec.c_str(), + fsPath.c_str()); } + } } + } else if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[dyn-import][ref] missing resource name; cannot normalize relative " + "spec against referrer"); + } + } - // Pass empty referrer: this V8 version does not expose GetModule() on - // ScriptOrModule, and the specifier above is already absolute when needed. - v8::Local refMod; + v8::TryCatch resolveTc(isolate); + v8::MaybeLocal maybeModule = ResolveModuleCallback( + context, adjustedSpecifier, import_assertions, refMod); + if (IsScriptLoadingLogEnabled()) { + v8::String::Utf8Value adj(isolate, adjustedSpecifier); + const char* cAdj = (*adj) ? *adj : ""; + DEBUG_WRITE("[dyn-import][resolver-call] raw=%s normalized=%s adjusted=%s", + rawSpec.c_str(), normalizedSpec.c_str(), cAdj); + } + v8::String::Utf8Value adjustedSpecUtf8(isolate, adjustedSpecifier); + std::string adjustedRegistryKey = + *adjustedSpecUtf8 ? CanonicalizeRegistryKey(*adjustedSpecUtf8) + : std::string(); + if (maybeModule.IsEmpty()) { + if (resolveTc.HasCaught()) { + resolver->Reject(context, resolveTc.Exception()).FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } else { + std::string msg = "Module resolution failed for dynamic import: "; + msg += normalizedSpec.empty() ? "" : normalizedSpec; + resolver + ->Reject(context, v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))) + .FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } + } - v8::Local module; - { - v8::TryCatch resolveTc(isolate); - v8::MaybeLocal maybeModule = - ResolveModuleCallback(context, resolvedSpecifier, import_assertions, refMod); - - if (!maybeModule.ToLocal(&module)) { - // Resolution failed; reject to avoid leaving a pending Promise (white screen) - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Resolution failed for '%s'", spec.c_str()); - } - // The resolver's own error carries the reason (a missing - // builtin names the exact contract message); only invent one - // when resolution failed without throwing. - v8::Local ex = - resolveTc.HasCaught() - ? resolveTc.Exception() - : v8::Exception::Error(ArgConverter::ConvertToV8String( - isolate, std::string("Failed to resolve module: ") + spec)); - resolveTc.Reset(); - resolver->Reject(context, ex).Check(); - return scope.Escape(resolver->GetPromise()); - } - } + v8::Local module = maybeModule.ToLocalChecked(); - // If not yet instantiated/evaluated, do it now - if (module->GetStatus() == v8::Module::kUninstantiated) { - if (!module->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Instantiate failed for '%s'", spec.c_str()); - } - resolver - ->Reject(context, - v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Failed to instantiate module"))) - .Check(); - return scope.Escape(resolver->GetPromise()); - } + if (module->GetStatus() == v8::Module::kUninstantiated) { + v8::TryCatch ictc(isolate); + if (!module->InstantiateModule(context, &ResolveModuleCallback) + .FromMaybe(false)) { + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import] instantiate failed %s", + normalizedSpec.c_str()); } - - if (module->GetStatus() != v8::Module::kEvaluated) { - if (module->Evaluate(context).IsEmpty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Evaluation failed for '%s'", spec.c_str()); - } - v8::Local ex = - v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, "Evaluation failed")); - resolver->Reject(context, ex).Check(); - return scope.Escape(resolver->GetPromise()); - } + std::string msg = + std::string("Failed to instantiate module: ") + normalizedSpec; + if (ictc.HasCaught()) { + std::string exStr = ArgConverter::ToString(isolate, ictc.Exception()); + if (!exStr.empty()) { + msg.append(" - "); + msg.append(exStr); + } } + resolver + ->Reject(context, v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))) + .Check(); + return scope.Escape(resolver->GetPromise()); + } + } - resolver->Resolve(context, module->GetModuleNamespace()).Check(); + if (IsModuleEvaluationInProgress(module->GetStatus())) { + if (QueueModuleWaiterIfInFlight(isolate, adjustedRegistryKey, module, + resolver)) { + return scope.Escape(resolver->GetPromise()); + } + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[dyn-import] avoiding re-entrant Evaluate for %s status=%s", + adjustedRegistryKey.empty() ? rawSpec.c_str() + : adjustedRegistryKey.c_str(), + ModuleStatusToString(module->GetStatus())); + } + resolver->Resolve(context, module->GetModuleNamespace()).Check(); + return scope.Escape(resolver->GetPromise()); + } + + if (module->GetStatus() != v8::Module::kEvaluated) { + v8::Local evalResult; + if (!module->Evaluate(context).ToLocal(&evalResult)) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Successfully resolved '%s'", spec.c_str()); + DEBUG_WRITE("[dyn-import] evaluation failed %s", + normalizedSpec.c_str()); } - } catch (NativeScriptException& ex) { - ex.ReThrowToV8(); + std::string msg = + std::string("Evaluation failed for module: ") + normalizedSpec; + v8::Local ex = v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg)); + resolver->Reject(context, ex).Check(); + return scope.Escape(resolver->GetPromise()); + } + if (!evalResult.IsEmpty() && evalResult->IsPromise()) { + v8::Local p = evalResult.As(); + struct DynEvalData { + v8::Global ctx; + v8::Global mod; + v8::Global res; + }; + auto* d = new DynEvalData{ + v8::Global(isolate, context), + v8::Global(isolate, module), + v8::Global(isolate, resolver)}; + auto onFulfilled = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local modLocal = d->mod.Get(iso); + v8::Local res = d->res.Get(iso); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import][tla] fulfilled, resolving namespace"); + } + if (!res.IsEmpty()) + res->Resolve(ctx, modLocal->GetModuleNamespace()).FromMaybe(false); + delete d; + }; + auto onRejected = [](const v8::FunctionCallbackInfo& info) { + v8::Isolate* iso = info.GetIsolate(); + v8::HandleScope hs(iso); + if (!info.Data()->IsExternal()) return; + auto* d = static_cast( + info.Data().As()->Value( + v8::kExternalPointerTypeTagDefault)); + v8::Local ctx = d->ctx.Get(iso); + v8::Local res = d->res.Get(iso); + v8::Local reason = + (info.Length() > 0) + ? info[0] + : v8::Exception::Error(ArgConverter::ConvertToV8String( + iso, "Evaluation failed (TLA)")); + if (IsScriptLoadingLogEnabled()) { + v8::String::Utf8Value r(iso, reason); + if (*r) { + DEBUG_WRITE("[dyn-import][tla] rejected: %s", *r); + } + } + if (!res.IsEmpty()) res->Reject(ctx, reason).FromMaybe(false); + delete d; + }; + v8::Local fulfillTpl = v8::FunctionTemplate::New( + isolate, onFulfilled, + v8::External::New(isolate, d, v8::kExternalPointerTypeTagDefault)); + v8::Local fulfill = + fulfillTpl->GetFunction(context).ToLocalChecked(); + v8::Local rejectTpl = v8::FunctionTemplate::New( + isolate, onRejected, + v8::External::New(isolate, d, v8::kExternalPointerTypeTagDefault)); + v8::Local reject = + rejectTpl->GetFunction(context).ToLocalChecked(); + p->Then(context, fulfill, reject).ToLocalChecked(); + return scope.Escape(resolver->GetPromise()); + } + } + + // Final verify before resolving for non-HTTP paths. + v8::Local nsFinal = module->GetModuleNamespace(); + if (nsFinal->IsObject()) { + v8::Local o = nsFinal.As(); + v8::TryCatch tc3(isolate); + v8::Local defVal; + if (!o->Get(context, ArgConverter::ConvertToV8String(isolate, "default")) + .ToLocal(&defVal)) { if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("ImportModuleDynamicallyCallback: Native exception for '%s'", spec.c_str()); + DEBUG_WRITE( + "[dyn-import][verify] ns.default threw after eval (generic) %s", + normalizedSpec.c_str()); } resolver - ->Reject(context, v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, "Native error during dynamic import"))) + ->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "TDZ on default after eval (generic)"))) .Check(); + return scope.Escape(resolver->GetPromise()); + } + } + resolver->Resolve(context, module->GetModuleNamespace()).Check(); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import] resolved %s", normalizedSpec.c_str()); } + } catch (NativeScriptException& ex) { + ex.ReThrowToV8(); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE("[dyn-import] native failed %s", normalizedSpec.c_str()); + } + resolver + ->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Native error during dynamic import"))) + .Check(); + } + + return scope.Escape(resolver->GetPromise()); +} - return scope.Escape(resolver->GetPromise()); +// ───────────────────────────────────────────────────────────── +// InitializeImportMetaObject — populates `import.meta.url` and +// `import.meta.dirname`. `import.meta.hot` is JS policy and is deliberately +// NOT set here (matches the port spec). +void InitializeImportMetaObject(v8::Local context, + v8::Local module, + v8::Local meta) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto& g_moduleRegistry = ModuleRegistryFor(isolate); + + std::string modulePath; + for (auto& kv : g_moduleRegistry) { + v8::Local registered = kv.second.Get(isolate); + if (!registered.IsEmpty() && registered == module) { + modulePath = kv.first; + break; + } + } + if (modulePath.empty()) return; + + std::string moduleUrl; + std::string moduleDirname; + if (StartsWith(modulePath, "http://") || StartsWith(modulePath, "https://")) { + moduleUrl = modulePath; + size_t slash = modulePath.find_last_of('/'); + moduleDirname = slash == std::string::npos ? modulePath + : modulePath.substr(0, slash); + } else if (StartsWith(modulePath, "blob:")) { + moduleUrl = modulePath; + moduleDirname = modulePath; + } else { + moduleUrl = StartsWith(modulePath, "file://") ? modulePath + : ("file://" + modulePath); + std::string filesystemPath = FileURLToPath(moduleUrl); + size_t slash = filesystemPath.find_last_of("/\\"); + moduleDirname = slash == std::string::npos ? filesystemPath + : filesystemPath.substr(0, slash); + } + + meta->CreateDataProperty( + context, ArgConverter::ConvertToV8String(isolate, "url"), + ArgConverter::ConvertToV8String(isolate, moduleUrl)) + .FromMaybe(false); + meta->CreateDataProperty( + context, ArgConverter::ConvertToV8String(isolate, "dirname"), + ArgConverter::ConvertToV8String(isolate, moduleDirname)) + .FromMaybe(false); } + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index 908c30ba7..6e447f9bc 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -1,29 +1,130 @@ -#ifndef MODULE_INTERNAL_CALLBACKS_H -#define MODULE_INTERNAL_CALLBACKS_H +// ModuleInternalCallbacks.h +#pragma once +#include -#include "v8.h" +#include +#include +#include -// Module resolution callback for ES modules -v8::MaybeLocal ResolveModuleCallback(v8::Local context, - v8::Local specifier, - v8::Local import_assertions, - v8::Local referrer); +#include "robin_hood.h" -// InitializeImportMetaObject - Callback invoked by V8 to initialize import.meta object -void InitializeImportMetaObject(v8::Local context, - v8::Local module, - v8::Local meta); +namespace tns { + +// Canonical module key → compiled-module handle map used by the per-isolate +// registries below. +using ModuleHandleMap = + robin_hood::unordered_map>; + +// Per-isolate module registry accessor: map canonical keys → compiled +// v8::Module handles for `isolate`. Keyed by v8::Isolate* (not thread) because +// v8::Global handles are isolate-bound; see the long-form comment +// above the definition in ModuleInternalCallbacks.cpp for the +// cross-isolate-handle bug this prevents. Callers bind a local alias, e.g. +// `auto& g_moduleRegistry = tns::ModuleRegistryFor(isolate);`. +ModuleHandleMap& ModuleRegistryFor(v8::Isolate* isolate); + +// Reset + drop every module handle owned by `isolate`. Must be called while +// the isolate is still alive (the Runtime destructor should call this before +// disposal). +void DestroyModuleStateForIsolate(v8::Isolate* isolate); + +// Utility to drop modules from the registry when compilation/instantiation +// fails. Operates on the *current* isolate's maps (resolved internally); only +// ever called on the isolate's own JS thread during module resolution/loading. +void RemoveModuleFromRegistry(const std::string& canonicalPath); + +// Authoritative HTTP URL loader for dev-served ESM. This compiles and +// registers the module under its canonical URL key without evaluating it. +v8::MaybeLocal LoadHttpModuleForUrl( + v8::Isolate* isolate, v8::Local context, + const std::string& requestedUrl); + +// ── Async HTTP module-graph pipeline ───────────── +// +// Standard three-phase module-map pipeline (the Node/Blink shape) under V8's +// synchronous ResolveModuleCallback: the sync constraint applies to +// *resolution*, not *fetching*. Starting from `rootUrl`, the walk fetches +// bodies concurrently off-thread (FetchModuleBodyAsync), compiles each on the +// isolate's JS thread (ScriptCompiler::CompileModule parses without +// resolving), resolves every static module request with the same import-map + +// relative-URL logic ResolveModuleCallback uses, and recurses until the +// transitive closure is compiled + registered. By InstantiateModule time the +// resolver is a pure registry lookup for the walked graph; anything the walk +// missed falls back to the legacy synchronous fetch inside the resolver. +// +// `onComplete(ok, errorMessage, context)` runs exactly once on the isolate's +// JS thread with the isolate entered and `context` (the context captured at +// start) already scoped. `ok` is false only when the ROOT fetch/compile +// failed — dependency failures are logged and left to surface through the +// resolver during instantiation, so the walk itself introduces no new +// failure modes. +void StartAsyncHttpModuleGraphLoad( + v8::Isolate* isolate, v8::Local context, + const std::string& rootUrl, + std::function context)> + onComplete); + +// Synchronous wrapper for callers that need the graph ready before +// continuing (static HTTP entry loads): starts the walk, then pumps the +// current thread's Android Looper until it settles or `timeoutSeconds` +// elapses. Returns true when the walk completed (regardless of root success +// — the caller's own load path reports root failures). This is the "manual +// run loop until settled" boot handoff. +bool RunAsyncHttpModuleGraphLoadPumped(v8::Isolate* isolate, + v8::Local context, + const std::string& rootUrl, + double timeoutSeconds); + +// True while any async graph load (any isolate) has fetches or compiles +// outstanding. +bool HasPendingAsyncModuleGraphWork(); -// Dynamic import() host callback +// Keep a fallback copy of the last evaluated module so it could be served +// while reloading if needed. +void UpdateModuleFallback(v8::Isolate* isolate, + const std::string& canonicalPath, + v8::Local module); + +// Drop exact URL-keyed modules from the registry and clear any in-flight +// invalidation bookkeeping tied to those canonical keys. +void InvalidateModules(v8::Isolate* isolate, v8::Local context, + const std::vector& urls); + +// Diagnostics helper: returns URL-like keys currently loaded in the module +// registry. +std::vector GetLoadedModuleUrls(); + +// Resolve callback signature (with import‑assertions slot) +v8::MaybeLocal ResolveModuleCallback( + v8::Local context, v8::Local specifier, + v8::Local import_assertions, + v8::Local referrer); + +// Host callback for dynamic import() expressions v8::MaybeLocal ImportModuleDynamicallyCallback( v8::Local context, v8::Local host_defined_options, v8::Local resource_name, v8::Local specifier, v8::Local import_assertions); -// Helper functions -bool IsFile(const std::string& path); -std::string WithExtension(const std::string& path, const std::string& ext); -bool IsNodeBuiltinModule(const std::string& spec); -std::string GetApplicationPath(); +// Host callback for import.meta initialization — Android-specific. Populates +// `import.meta.url` and `import.meta.dirname`. Kept here (not on iOS) because +// Runtime.cpp installs it via SetHostInitializeImportMetaObjectCallback. No +// `import.meta.hot` — that surface is JS policy, not native. +void InitializeImportMetaObject(v8::Local context, + v8::Local module, + v8::Local meta); + +// Import map support. +// Parse and store an import map from JSON. Expected shape: +// {"imports": {"key": "value", ...}} +void SetImportMap(const std::string& json); + +// Set URL patterns that should bypass module cache (e.g. "/@ns/sfc/", "?v="). +void SetVolatilePatterns(const std::vector& patterns); + +// Clear import map state and vendor module cache. Must be called before +// isolate disposal. +void CleanupImportMapGlobals(); -#endif // MODULE_INTERNAL_CALLBACKS_H +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 2057695cd..38e570433 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -19,6 +19,7 @@ #include "Events.h" #include "File.h" #include "FrameCallbacks.h" +#include "HttpLoader.h" #include "Interop.h" #include "IsolateTracked.h" #include "JType.h" @@ -350,17 +351,34 @@ void Runtime::Unlock() { #endif } +static void PumpPendingHttpModuleGraph(v8::Isolate* isolate) { + if (!tns::HasPendingAsyncModuleGraphWork()) { + return; + } + const auto start = std::chrono::steady_clock::now(); + while (tns::HasPendingAsyncModuleGraphWork()) { + isolate->PerformMicrotaskCheckpoint(); + ALooper_pollOnce(10, nullptr, nullptr, nullptr); + isolate->PerformMicrotaskCheckpoint(); + if (std::chrono::duration(std::chrono::steady_clock::now() - start).count() > 60.0) { + break; + } + } +} + void Runtime::RunModule(JNIEnv* _env, jobject obj, jstring scriptFile) { JEnv env(_env); string filePath = ArgConverter::jstringToString(scriptFile); auto context = this->GetContext(); m_module.Load(context, filePath); + PumpPendingHttpModuleGraph(m_isolate); } void Runtime::RunModule(const char* moduleName) { auto context = this->GetContext(); m_module.Load(context, moduleName); + PumpPendingHttpModuleGraph(m_isolate); } void Runtime::RunWorker(const std::string& filePath) { @@ -1042,7 +1060,6 @@ void Runtime::DestroyRuntime() { m_dispatchUnhandledRejectionFunc.Reset(); m_dispatchRejectionHandledFunc.Reset(); m_dispatchNativeUncaughtErrorFunc.Reset(); - // Both hold v8::Global handles to JS callbacks, so their entries must be // dropped here rather than in ~Runtime, which runs after Isolate::Dispose -- // resetting a Global then writes into a freed handle table. Doing it here @@ -1051,6 +1068,17 @@ void Runtime::DestroyRuntime() { CallbackHandlers::RemoveIsolateEntries(m_isolate); FrameCallbacks::RemoveIsolateEntries(m_isolate); + // Drop this isolate's module registry (compiled modules, fallbacks, + // in-flight async graph loads) while the isolate is still alive. + tns::DestroyModuleStateForIsolate(m_isolate); + // Process-wide HTTP-loader / import-map state is shared across isolates; + // only the main isolate may clear it (worker teardown must not wipe the + // main isolate's session). + if (m_isMainThread) { + tns::CleanupHttpLoaderGlobals(); + tns::CleanupImportMapGlobals(); + } + // V8 does not run weak callbacks when an isolate is disposed, so anything // still bound to one has to be deleted explicitly, here, while the isolate // is alive and its destructors can still touch v8::Global handles. diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index 1a139bd17..1c8d18ef8 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -117,6 +117,10 @@ class Runtime { return m_state.get(); } + bool IsMainThread() const { + return m_isMainThread; + } + jobject GetJavaRuntime() const; ObjectManager* GetObjectManager() const; diff --git a/test-app/runtime/src/main/java/com/tns/DexFactory.java b/test-app/runtime/src/main/java/com/tns/DexFactory.java index 56b37462e..29f302e50 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -194,7 +194,7 @@ && injectDexIntoClassLoader((BaseDexClassLoader) classLoader, jarFilePath)) { } public Class findClass(String className) throws ClassNotFoundException { - String canonicalName = className.replace('/', '.'); + String canonicalName = className.replace('/', '.').replace('$', '_'); if (logger.isEnabled()) { logger.write(canonicalName); } From 5b080c428104753ffb4cc51ea8828c4c5a66178d Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 12 Aug 2026 17:26:44 -0700 Subject: [PATCH 02/36] feat(runtime): HMR dev-sessions and a hardened HTTP session loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev sessions serve the app's module graph over HTTP during development, with a mechanism-only dev-loader contract: policy stays in JS tooling, the runtime supplies fetch/registry/invalidations. The loader is deny-by-default — remote allowlist entries only authorize URLs on a URL-component boundary ('/', '?', '#' or exact match), refusing lookalike-host and lookalike-port bypasses; a specific port must be listed explicitly. Hot-path hash containers use robin_hood maps. Per-fetch URL logging is opt-in via the httpFetchUrlLog config flag (volume is one line per fetch), alongside the existing logScriptLoading-gated diagnostics. The previous HMRSupport/DevFlags sources are replaced by HttpLoader (JNI HttpURLConnection). --- test-app/runtime/src/main/cpp/DevFlags.cpp | 141 ------- test-app/runtime/src/main/cpp/DevFlags.h | 24 -- test-app/runtime/src/main/cpp/HMRSupport.cpp | 353 ------------------ test-app/runtime/src/main/cpp/HMRSupport.h | 25 -- .../src/main/java/com/tns/AppConfig.java | 15 +- .../src/main/java/com/tns/Runtime.java | 48 ++- 6 files changed, 58 insertions(+), 548 deletions(-) delete mode 100644 test-app/runtime/src/main/cpp/DevFlags.cpp delete mode 100644 test-app/runtime/src/main/cpp/DevFlags.h delete mode 100644 test-app/runtime/src/main/cpp/HMRSupport.cpp delete mode 100644 test-app/runtime/src/main/cpp/HMRSupport.h diff --git a/test-app/runtime/src/main/cpp/DevFlags.cpp b/test-app/runtime/src/main/cpp/DevFlags.cpp deleted file mode 100644 index 224601b10..000000000 --- a/test-app/runtime/src/main/cpp/DevFlags.cpp +++ /dev/null @@ -1,141 +0,0 @@ -// DevFlags.cpp -#include "DevFlags.h" -#include "JEnv.h" -#include -#include -#include -#include - -namespace tns { - -bool IsScriptLoadingLogEnabled() { - static std::atomic cached{-1}; // -1 unknown, 0 false, 1 true - int v = cached.load(std::memory_order_acquire); - if (v != -1) { - return v == 1; - } - - static std::once_flag initFlag; - std::call_once(initFlag, []() { - bool enabled = false; - try { - JEnv env; - jclass runtimeClass = env.FindClass("com/tns/Runtime"); - if (runtimeClass != nullptr) { - jmethodID mid = env.GetStaticMethodID(runtimeClass, "getLogScriptLoadingEnabled", "()Z"); - if (mid != nullptr) { - jboolean res = env.CallStaticBooleanMethod(runtimeClass, mid); - enabled = (res == JNI_TRUE); - } - } - } catch (...) { - // keep default false - } - cached.store(enabled ? 1 : 0, std::memory_order_release); - }); - - return cached.load(std::memory_order_acquire) == 1; -} - -// Security config - -static std::once_flag s_securityConfigInitFlag; -static bool s_allowRemoteModules = false; -static std::vector s_remoteModuleAllowlist; -static bool s_isDebuggable = false; - -// Helper to check if a URL starts with a given prefix -static bool UrlStartsWith(const std::string& url, const std::string& prefix) { - if (prefix.size() > url.size()) return false; - return url.compare(0, prefix.size(), prefix) == 0; -} - -void InitializeSecurityConfig() { - std::call_once(s_securityConfigInitFlag, []() { - try { - JEnv env; - jclass runtimeClass = env.FindClass("com/tns/Runtime"); - if (runtimeClass == nullptr) { - return; - } - - // Check isDebuggable first - jmethodID isDebuggableMid = env.GetStaticMethodID(runtimeClass, "isDebuggable", "()Z"); - if (isDebuggableMid != nullptr) { - jboolean res = env.CallStaticBooleanMethod(runtimeClass, isDebuggableMid); - s_isDebuggable = (res == JNI_TRUE); - } - - // If debuggable, we don't need to check further - always allow - if (s_isDebuggable) { - s_allowRemoteModules = true; - return; - } - - // Check isRemoteModulesAllowed - jmethodID allowRemoteMid = env.GetStaticMethodID(runtimeClass, "isRemoteModulesAllowed", "()Z"); - if (allowRemoteMid != nullptr) { - jboolean res = env.CallStaticBooleanMethod(runtimeClass, allowRemoteMid); - s_allowRemoteModules = (res == JNI_TRUE); - } - - // Get the allowlist - jmethodID getAllowlistMid = env.GetStaticMethodID(runtimeClass, "getRemoteModuleAllowlist", "()[Ljava/lang/String;"); - if (getAllowlistMid != nullptr) { - jobjectArray allowlistArray = (jobjectArray)env.CallStaticObjectMethod(runtimeClass, getAllowlistMid); - if (allowlistArray != nullptr) { - jsize len = env.GetArrayLength(allowlistArray); - for (jsize i = 0; i < len; i++) { - jstring jstr = (jstring)env.GetObjectArrayElement(allowlistArray, i); - if (jstr != nullptr) { - const char* str = env.GetStringUTFChars(jstr, nullptr); - if (str != nullptr) { - s_remoteModuleAllowlist.push_back(std::string(str)); - env.ReleaseStringUTFChars(jstr, str); - } - env.DeleteLocalRef(jstr); - } - } - env.DeleteLocalRef(allowlistArray); - } - } - } catch (...) { - // Keep defaults (remote modules disabled) - } - }); -} - -bool IsRemoteModulesAllowed() { - InitializeSecurityConfig(); - return s_allowRemoteModules || s_isDebuggable; -} - -bool IsRemoteUrlAllowed(const std::string& url) { - InitializeSecurityConfig(); - - // Debug mode always allows all URLs - if (s_isDebuggable) { - return true; - } - - // Production: first check if remote modules are allowed at all - if (!s_allowRemoteModules) { - return false; - } - - // If no allowlist is configured, allow all URLs (user explicitly enabled remote modules) - if (s_remoteModuleAllowlist.empty()) { - return true; - } - - // Check if URL matches any allowlist prefix - for (const std::string& prefix : s_remoteModuleAllowlist) { - if (UrlStartsWith(url, prefix)) { - return true; - } - } - - return false; -} - -} // namespace tns diff --git a/test-app/runtime/src/main/cpp/DevFlags.h b/test-app/runtime/src/main/cpp/DevFlags.h deleted file mode 100644 index db571d49f..000000000 --- a/test-app/runtime/src/main/cpp/DevFlags.h +++ /dev/null @@ -1,24 +0,0 @@ -// DevFlags.h -#pragma once - -#include - -namespace tns { - -// Fast cached flag: whether to log script loading diagnostics. -// First call queries Java once; subsequent calls are atomic loads only. -bool IsScriptLoadingLogEnabled(); - -// Security config - -// "security.allowRemoteModules" from nativescript.config -bool IsRemoteModulesAllowed(); - -// "security.remoteModuleAllowlist" array from nativescript.config -// If no allowlist is configured but allowRemoteModules is true, all URLs are allowed. -bool IsRemoteUrlAllowed(const std::string& url); - -// Init security configuration -void InitializeSecurityConfig(); - -} diff --git a/test-app/runtime/src/main/cpp/HMRSupport.cpp b/test-app/runtime/src/main/cpp/HMRSupport.cpp deleted file mode 100644 index 16cac04d8..000000000 --- a/test-app/runtime/src/main/cpp/HMRSupport.cpp +++ /dev/null @@ -1,353 +0,0 @@ -// HMRSupport.cpp -#include "HMRSupport.h" -#include "ArgConverter.h" -#include "JEnv.h" -#include "DevFlags.h" -#include "NativeScriptAssert.h" -#include -#include -#include -#include -#include -#include - -namespace tns { - -static inline bool StartsWith(const std::string& s, const char* prefix) { - size_t n = strlen(prefix); - return s.size() >= n && s.compare(0, n, prefix) == 0; -} - -// Per-module hot data and callbacks. Keyed by canonical module path (file path or URL). -static std::unordered_map> g_hotData; -static std::unordered_map>> g_hotAccept; -static std::unordered_map>> g_hotDispose; - -v8::Local GetOrCreateHotData(v8::Isolate* isolate, const std::string& key) { - auto it = g_hotData.find(key); - if (it != g_hotData.end() && !it->second.IsEmpty()) { - return it->second.Get(isolate); - } - v8::Local obj = v8::Object::New(isolate); - g_hotData[key].Reset(isolate, obj); - return obj; -} - -void RegisterHotAccept(v8::Isolate* isolate, const std::string& key, v8::Local cb) { - if (cb.IsEmpty()) return; - g_hotAccept[key].emplace_back(v8::Global(isolate, cb)); -} - -void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local cb) { - if (cb.IsEmpty()) return; - g_hotDispose[key].emplace_back(v8::Global(isolate, cb)); -} - -std::vector> GetHotAcceptCallbacks(v8::Isolate* isolate, const std::string& key) { - std::vector> out; - auto it = g_hotAccept.find(key); - if (it != g_hotAccept.end()) { - for (auto& gfn : it->second) { - if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); - } - } - return out; -} - -std::vector> GetHotDisposeCallbacks(v8::Isolate* isolate, const std::string& key) { - std::vector> out; - auto it = g_hotDispose.find(key); - if (it != g_hotDispose.end()) { - for (auto& gfn : it->second) { - if (!gfn.IsEmpty()) out.push_back(gfn.Get(isolate)); - } - } - return out; -} - -void InitializeImportMetaHot(v8::Isolate* isolate, - v8::Local context, - v8::Local importMeta, - const std::string& modulePath) { - using v8::Function; - using v8::FunctionCallbackInfo; - using v8::Local; - using v8::Object; - using v8::String; - using v8::Value; - - v8::HandleScope scope(isolate); - - auto makeKeyData = [&](const std::string& key) -> Local { - return ArgConverter::ConvertToV8String(isolate, key); - }; - - auto acceptCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - Local data = info.Data(); - std::string key; - if (!data.IsEmpty()) { - v8::String::Utf8Value s(iso, data); - key = *s ? *s : ""; - } - v8::Local cb; - if (info.Length() >= 1 && info[0]->IsFunction()) { - cb = info[0].As(); - } else if (info.Length() >= 2 && info[1]->IsFunction()) { - cb = info[1].As(); - } - if (!cb.IsEmpty()) { - RegisterHotAccept(iso, key, cb); - } - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - auto disposeCb = [](const FunctionCallbackInfo& info) { - v8::Isolate* iso = info.GetIsolate(); - Local data = info.Data(); - std::string key; - if (!data.IsEmpty()) { v8::String::Utf8Value s(iso, data); key = *s ? *s : ""; } - if (info.Length() >= 1 && info[0]->IsFunction()) { - RegisterHotDispose(iso, key, info[0].As()); - } - info.GetReturnValue().Set(v8::Undefined(iso)); - }; - - auto declineCb = [](const FunctionCallbackInfo& info) { - info.GetReturnValue().Set(v8::Undefined(info.GetIsolate())); - }; - - auto invalidateCb = [](const FunctionCallbackInfo& info) { - info.GetReturnValue().Set(v8::Undefined(info.GetIsolate())); - }; - - Local hot = Object::New(isolate); - hot->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "data"), - GetOrCreateHotData(isolate, modulePath)).Check(); - hot->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "prune"), - v8::Boolean::New(isolate, false)).Check(); - hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "accept"), - v8::Function::New(context, acceptCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "dispose"), - v8::Function::New(context, disposeCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "decline"), - v8::Function::New(context, declineCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - hot->CreateDataProperty( - context, ArgConverter::ConvertToV8String(isolate, "invalidate"), - v8::Function::New(context, invalidateCb, makeKeyData(modulePath)).ToLocalChecked()).Check(); - - importMeta->CreateDataProperty(context, ArgConverter::ConvertToV8String(isolate, "hot"), hot).Check(); -} - -// Drop fragments and normalize parameters for consistent registry keys. -std::string CanonicalizeHttpUrlKey(const std::string& url) { - if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) { - return url; - } - // Remove fragment - size_t hashPos = url.find('#'); - std::string noHash = (hashPos == std::string::npos) ? url : url.substr(0, hashPos); - - // Split into origin+path and query - size_t qPos = noHash.find('?'); - std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos); - std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1); - - // Normalize bridge endpoints to keep a single realm across HMR updates: - // - /ns/rt/ -> /ns/rt - // - /ns/core/ -> /ns/core - size_t schemePos = originAndPath.find("://"); - if (schemePos != std::string::npos) { - size_t pathStart = originAndPath.find('/', schemePos + 3); - if (pathStart != std::string::npos) { - std::string pathOnly = originAndPath.substr(pathStart); - auto normalizeBridge = [&](const char* needle) { - size_t nlen = strlen(needle); - if (pathOnly.size() <= nlen) return false; - if (pathOnly.compare(0, nlen, needle) != 0) return false; - if (pathOnly.size() == nlen) return true; - if (pathOnly[nlen] != '/') return false; - size_t i = nlen + 1; - size_t j = i; - while (j < pathOnly.size() && isdigit((unsigned char)pathOnly[j])) j++; - // Only normalize exact version segment: /ns/*/ (no further segments) - if (j == i) return false; - if (j != pathOnly.size()) return false; - originAndPath = originAndPath.substr(0, pathStart) + std::string(needle); - return true; - }; - if (!normalizeBridge("/ns/rt")) { - normalizeBridge("/ns/core"); - } - } - } - - if (query.empty()) return originAndPath; - - // Strip ?import markers and sort remaining query params for stability - std::vector kept; - size_t start = 0; - while (start <= query.size()) { - size_t amp = query.find('&', start); - std::string pair = (amp == std::string::npos) ? query.substr(start) : query.substr(start, amp - start); - if (!pair.empty()) { - size_t eq = pair.find('='); - std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); - if (!(name == "import")) kept.push_back(pair); - } - if (amp == std::string::npos) break; - start = amp + 1; - } - if (kept.empty()) return originAndPath; - std::sort(kept.begin(), kept.end()); - std::string rebuilt = originAndPath + "?"; - for (size_t i = 0; i < kept.size(); i++) { - if (i > 0) rebuilt += "&"; - rebuilt += kept[i]; - } - return rebuilt; -} - -// Minimal HTTP fetch using java.net.* via JNI. Returns true on success (2xx) and non-empty body. -// Security: This is the single point of enforcement for remote module loading. -// In debug mode, all URLs are allowed. In production, checks security.allowRemoteModules -// and security.remoteModuleAllowlist from the app config. -bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status) { - out.clear(); - contentType.clear(); - status = 0; - - // Security gate: check if remote module loading is allowed before any HTTP fetch. - if (!IsRemoteUrlAllowed(url)) { - status = 403; // Forbidden - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][security][blocked] %s", url.c_str()); - } - return false; - } - - try { - JEnv env; - - // Allow network operations on the current thread (dev-only HMR path) - // Some Android environments enforce StrictMode which throws NetworkOnMainThreadException - // when performing network I/O on the main thread. Since this fetch runs on the JS/V8 thread - // during development, explicitly relax the policy here. - { - jclass clsStrict = env.FindClass("android/os/StrictMode"); - jclass clsPolicyBuilder = env.FindClass("android/os/StrictMode$ThreadPolicy$Builder"); - if (clsStrict && clsPolicyBuilder) { - jmethodID builderCtor = env.GetMethodID(clsPolicyBuilder, "", "()V"); - jobject builder = env.NewObject(clsPolicyBuilder, builderCtor); - if (builder) { - jmethodID permitAll = env.GetMethodID(clsPolicyBuilder, "permitAll", "()Landroid/os/StrictMode$ThreadPolicy$Builder;"); - jobject builder2 = permitAll ? env.CallObjectMethod(builder, permitAll) : builder; - jmethodID build = env.GetMethodID(clsPolicyBuilder, "build", "()Landroid/os/StrictMode$ThreadPolicy;"); - jobject policy = build ? env.CallObjectMethod(builder2 ? builder2 : builder, build) : nullptr; - if (policy) { - jmethodID setThreadPolicy = env.GetStaticMethodID(clsStrict, "setThreadPolicy", "(Landroid/os/StrictMode$ThreadPolicy;)V"); - if (setThreadPolicy) { - env.CallStaticVoidMethod(clsStrict, setThreadPolicy, policy); - } - } - } - } - } - - jclass clsURL = env.FindClass("java/net/URL"); - if (!clsURL) return false; - jmethodID urlCtor = env.GetMethodID(clsURL, "", "(Ljava/lang/String;)V"); - jmethodID openConnection = env.GetMethodID(clsURL, "openConnection", "()Ljava/net/URLConnection;"); - jstring jUrlStr = env.NewStringUTF(url.c_str()); - jobject urlObj = env.NewObject(clsURL, urlCtor, jUrlStr); - - jobject conn = env.CallObjectMethod(urlObj, openConnection); - if (!conn) return false; - - jclass clsConn = env.GetObjectClass(conn); - jmethodID setConnectTimeout = env.GetMethodID(clsConn, "setConnectTimeout", "(I)V"); - jmethodID setReadTimeout = env.GetMethodID(clsConn, "setReadTimeout", "(I)V"); - jmethodID setDoInput = env.GetMethodID(clsConn, "setDoInput", "(Z)V"); - jmethodID setUseCaches = env.GetMethodID(clsConn, "setUseCaches", "(Z)V"); - jmethodID setReqProp = env.GetMethodID(clsConn, "setRequestProperty", "(Ljava/lang/String;Ljava/lang/String;)V"); - env.CallVoidMethod(conn, setConnectTimeout, 15000); - env.CallVoidMethod(conn, setReadTimeout, 15000); - if (setDoInput) { env.CallVoidMethod(conn, setDoInput, JNI_TRUE); } - if (setUseCaches) { env.CallVoidMethod(conn, setUseCaches, JNI_FALSE); } - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Accept"), env.NewStringUTF("application/javascript, text/javascript, */*;q=0.1")); - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Accept-Encoding"), env.NewStringUTF("identity")); - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Cache-Control"), env.NewStringUTF("no-cache")); - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("Connection"), env.NewStringUTF("close")); - env.CallVoidMethod(conn, setReqProp, env.NewStringUTF("User-Agent"), env.NewStringUTF("NativeScript-HTTP-ESM")); - - // Try to get status via HttpURLConnection if possible - jclass clsHttp = env.FindClass("java/net/HttpURLConnection"); - bool isHttp = clsHttp && env.IsInstanceOf(conn, clsHttp); - jmethodID getResponseCode = isHttp ? env.GetMethodID(clsHttp, "getResponseCode", "()I") : nullptr; - jmethodID getErrorStream = isHttp ? env.GetMethodID(clsHttp, "getErrorStream", "()Ljava/io/InputStream;") : nullptr; - if (isHttp && getResponseCode) { - status = env.CallIntMethod(conn, getResponseCode); - } - - // Read InputStream (prefer error stream on HTTP error codes) - jmethodID getInputStream = env.GetMethodID(clsConn, "getInputStream", "()Ljava/io/InputStream;"); - jobject inStream = nullptr; - if (isHttp && status >= 400 && getErrorStream) { - inStream = env.CallObjectMethod(conn, getErrorStream); - } - if (!inStream) { - inStream = env.CallObjectMethod(conn, getInputStream); - } - if (!inStream) return false; - - jclass clsIS = env.GetObjectClass(inStream); - jmethodID readMethod = env.GetMethodID(clsIS, "read", "([B)I"); - jmethodID closeIS = env.GetMethodID(clsIS, "close", "()V"); - - jclass clsBAOS = env.FindClass("java/io/ByteArrayOutputStream"); - jmethodID baosCtor = env.GetMethodID(clsBAOS, "", "()V"); - jmethodID baosWrite = env.GetMethodID(clsBAOS, "write", "([BII)V"); - jmethodID baosToByteArray = env.GetMethodID(clsBAOS, "toByteArray", "()[B"); - jmethodID baosClose = env.GetMethodID(clsBAOS, "close", "()V"); - jobject baos = env.NewObject(clsBAOS, baosCtor); - - jbyteArray buffer = env.NewByteArray(8192); - while (true) { - jint n = env.CallIntMethod(inStream, readMethod, buffer); - if (n < 0) break; // -1 indicates EOF - if (n == 0) { - // Defensive: continue reading if zero bytes returned - continue; - } - env.CallVoidMethod(baos, baosWrite, buffer, 0, n); - } - - env.CallVoidMethod(inStream, closeIS); - jbyteArray bytes = (jbyteArray) env.CallObjectMethod(baos, baosToByteArray); - env.CallVoidMethod(baos, baosClose); - - if (!bytes) return false; - jsize len = env.GetArrayLength(bytes); - out.resize(static_cast(len)); - if (len > 0) { - env.GetByteArrayRegion(bytes, 0, len, reinterpret_cast(&out[0])); - } - - // Content-Type if available - jmethodID getContentType = env.GetMethodID(clsConn, "getContentType", "()Ljava/lang/String;"); - jstring jct = (jstring) env.CallObjectMethod(conn, getContentType); - if (jct) { - contentType = ArgConverter::jstringToString(jct); - } - - if (status == 0) status = 200; // assume OK if not HTTP - return status >= 200 && status < 300 && !out.empty(); - } catch (...) { - return false; - } -} - -} // namespace tns diff --git a/test-app/runtime/src/main/cpp/HMRSupport.h b/test-app/runtime/src/main/cpp/HMRSupport.h deleted file mode 100644 index f08e7fa09..000000000 --- a/test-app/runtime/src/main/cpp/HMRSupport.h +++ /dev/null @@ -1,25 +0,0 @@ -// HMRSupport.h -#pragma once - -#include -#include -#include - -namespace tns { - -// import.meta.hot support -v8::Local GetOrCreateHotData(v8::Isolate* isolate, const std::string& key); -void RegisterHotAccept(v8::Isolate* isolate, const std::string& key, v8::Local cb); -void RegisterHotDispose(v8::Isolate* isolate, const std::string& key, v8::Local cb); -std::vector> GetHotAcceptCallbacks(v8::Isolate* isolate, const std::string& key); -std::vector> GetHotDisposeCallbacks(v8::Isolate* isolate, const std::string& key); -void InitializeImportMetaHot(v8::Isolate* isolate, - v8::Local context, - v8::Local importMeta, - const std::string& modulePath); - -// Dev HTTP loader helpers -std::string CanonicalizeHttpUrlKey(const std::string& url); -bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status); - -} // namespace tns diff --git a/test-app/runtime/src/main/java/com/tns/AppConfig.java b/test-app/runtime/src/main/java/com/tns/AppConfig.java index d1379a440..ff0df0048 100644 --- a/test-app/runtime/src/main/java/com/tns/AppConfig.java +++ b/test-app/runtime/src/main/java/com/tns/AppConfig.java @@ -26,7 +26,8 @@ protected enum KnownKeys { EnableMultithreadedJavascript("enableMultithreadedJavascript", false), LogScriptLoading("logScriptLoading", false), // Appended last: native code reads this array by ordinal. - UncaughtErrorPolicy("uncaughtErrorPolicy", "report"); + UncaughtErrorPolicy("uncaughtErrorPolicy", "report"), + HttpFetchUrlLog("httpFetchUrlLog", false); private final String name; private final Object defaultValue; @@ -88,6 +89,9 @@ public AppConfig(File appDir) { if (rootObject.has(KnownKeys.LogScriptLoading.getName())) { values[KnownKeys.LogScriptLoading.ordinal()] = rootObject.getBoolean(KnownKeys.LogScriptLoading.getName()); } + if (rootObject.has(KnownKeys.HttpFetchUrlLog.getName())) { + values[KnownKeys.HttpFetchUrlLog.ordinal()] = rootObject.getBoolean(KnownKeys.HttpFetchUrlLog.getName()); + } if (rootObject.has(KnownKeys.DiscardUncaughtJsExceptions.getName())) { boolean discard = rootObject.getBoolean(KnownKeys.DiscardUncaughtJsExceptions.getName()); if (discard) { @@ -226,8 +230,13 @@ public boolean getEnableMultithreadedJavascript() { } public boolean getLogScriptLoading() { - Object v = values[KnownKeys.LogScriptLoading.ordinal()]; - return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; + Object v = values[KnownKeys.LogScriptLoading.ordinal()]; + return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; + } + + public boolean getHttpFetchUrlLog() { + Object v = values[KnownKeys.HttpFetchUrlLog.ordinal()]; + return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; } // Security conf diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 4a02c22c4..1fcce9083 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -304,6 +304,17 @@ public static boolean getLogScriptLoadingEnabled() { } return false; } + + public static boolean getHttpFetchUrlLogEnabled() { + Runtime runtime = com.tns.Runtime.getCurrentRuntime(); + if (runtime != null && runtime.config != null && runtime.config.appConfig != null) { + return runtime.config.appConfig.getHttpFetchUrlLog(); + } + if (staticConfiguration != null && staticConfiguration.appConfig != null) { + return staticConfiguration.appConfig.getHttpFetchUrlLog(); + } + return false; + } // Security config @@ -349,15 +360,48 @@ public static boolean isRemoteUrlAllowed(String url) { return true; } - // Check if URL matches any allowlist prefix + // Check if URL matches any allowlist prefix at a URL-component boundary + // (exact match, entry ends in '/', or next char is '/', '?', or '#'). + // This refuses lookalike-host and lookalike-port bypasses. for (String prefix : allowlist) { - if (url != null && prefix != null && url.startsWith(prefix)) { + if (url != null && prefix != null && remoteUrlMatchesAllowlistEntry(url, prefix)) { return true; } } return false; } + + private static boolean remoteUrlMatchesAllowlistEntry(String url, String entry) { + if (entry.isEmpty() || url.length() < entry.length()) { + return false; + } + if (!url.startsWith(entry)) { + return false; + } + if (url.length() == entry.length()) { + return true; + } + if (entry.charAt(entry.length() - 1) == '/') { + return true; + } + char next = url.charAt(entry.length()); + return next == '/' || next == '?' || next == '#'; + } + + /** + * Test/JNI helper: boot-time security.allowRemoteModules (debug always true). + */ + public static boolean getSecurityAllowRemoteModules() { + return isRemoteModulesAllowed(); + } + + /** + * Test/JNI helper: boot-time security.remoteModuleAllowlist. + */ + public static String[] getSecurityRemoteModuleAllowlist() { + return getRemoteModuleAllowlist(); + } /** * Returns the remote module allowlist as a String array for JNI. From d7b20a53e16830303da7eb5c4c8c431d47f53311 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 12 Aug 2026 17:27:11 -0700 Subject: [PATCH 03/36] feat(runtime): expose the dev-loader surface as the ns:module builtin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev-loader control surface (HttpLoader) is reachable from JS as the ns:module builtin module: NsBuiltinModules routes ns:module through BuildNsModuleBinding — the binding builder decides build-dependent membership — and ns-module.js (compiled in via js2c) shapes and freezes whatever arrives. docs/ns-builtin-modules.md documents the surface. --- docs/ns-builtin-modules.md | 22 ++++++++++++++++ test-app/runtime/CMakeLists.txt | 1 + .../runtime/src/main/cpp/NsBuiltinModules.cpp | 8 ++++++ test-app/runtime/src/main/cpp/js/README.md | 1 + test-app/runtime/src/main/cpp/js/ns-module.js | 25 +++++++++++++++++++ 5 files changed, 57 insertions(+) create mode 100644 test-app/runtime/src/main/cpp/js/ns-module.js diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index 589b42acc..bbecf6a72 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -52,6 +52,28 @@ Rules: versions for readability; it is intended for humans and must not be parsed programmatically. +### `ns:module` (v1) + +The module-loader control surface consumed by development tooling +(`@nativescript/vite`). Mechanism only: every policy concern (boot +orchestration, `import.meta.hot`, full reload, CSS apply, worker teardown, +WebSocket protocol) lives in the tooling. + +| export | description | +|---|---| +| `configureLoader(config)` | Install loader policy before the session imports anything: `importMap` (bare specifier → URL, consulted inside the synchronous resolver), `volatilePatterns` (URL substrings always re-fetched), `canonicalization` (`stripParams`/`forPathPrefixes`/`preserveQueryFor` vocabulary for registry keying). Each present section replaces its state wholesale. | +| `invalidateModules(urls)` | Evict the given URLs (canonicalized) from the module registry and mark them bust-next-fetch, so the next network fetch bypasses every HTTP cache layer. | +| `getLoadedModuleUrls()` | URL-like keys currently in the module registry (used to compute full-reload eviction sets). | +| `setDevBootComplete(value?)` | Flip the dev-boot-complete signal (defaults to `true`); disarms cold-boot-only behaviors. | + +Debug builds additionally carry `canonicalizeHttpUrlKey(url)`, a pure test +diagnostic; release builds omit it. Missing members are simply absent — +never present-but-throwing — so feature checks work. The module is +registered in every build; the security boundary for remote module loading +sits at the network layer (`security.allowRemoteModules` in +nativescript.config, enforced inside `HttpLoader`), not the module +registry. + ## `node:` compatibility shims The same registry serves the `node:` scheme with **compatibility shims** so diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index 8b1fcdb9c..d332a354a 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -74,6 +74,7 @@ set(RUNTIME_BUILTIN_JS ${RUNTIME_BUILTIN_JS_DIR}/inspect.js ${RUNTIME_BUILTIN_JS_DIR}/json-helper.js ${RUNTIME_BUILTIN_JS_DIR}/node-util.js + ${RUNTIME_BUILTIN_JS_DIR}/ns-module.js ${RUNTIME_BUILTIN_JS_DIR}/ns-util.js ${RUNTIME_BUILTIN_JS_DIR}/performance.js ${RUNTIME_BUILTIN_JS_DIR}/primordials.js diff --git a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp index b6a3d6f16..abdfc2ee1 100644 --- a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp +++ b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp @@ -6,6 +6,7 @@ #include "ArgConverter.h" #include "BuiltinLoader.h" +#include "HttpLoader.h" #include "RuntimeState.h" #include "console/Console.h" #include "robin_hood.h" @@ -31,6 +32,7 @@ struct Registration { * never carries compatibility code. */ constexpr Registration kRegistry[] = { + {"ns:module", BuiltinId::kNsModule}, {"ns:util", BuiltinId::kNsUtil}, {"node:util", BuiltinId::kNodeUtil}, }; @@ -88,6 +90,12 @@ MaybeLocal BuildBinding(Local context, BuiltinId builtin) { Local binding = Object::New(isolate); switch (builtin) { + case BuiltinId::kNsModule: { + if (!BuildNsModuleBinding(context, binding)) { + return MaybeLocal(); + } + break; + } case BuiltinId::kNsUtil: { // The console formatter is built once per realm; ns:util // re-exports that instance instead of creating a second one. diff --git a/test-app/runtime/src/main/cpp/js/README.md b/test-app/runtime/src/main/cpp/js/README.md index 65beb22a9..4730d4b45 100644 --- a/test-app/runtime/src/main/cpp/js/README.md +++ b/test-app/runtime/src/main/cpp/js/README.md @@ -46,6 +46,7 @@ module.exports = somethingTheCallSiteNeeds; `node:util` shim: one source file per specifier, the shim owning every bit of Node compatibility. See `docs/ns-builtin-modules.md` for the cross-runtime contract. +- `ns-module.js` is the `ns:module` loader-control surface. - Destructure `binding` and `primordials` once, at the top of the file, so the file's dependencies are visible and greppable. diff --git a/test-app/runtime/src/main/cpp/js/ns-module.js b/test-app/runtime/src/main/cpp/js/ns-module.js new file mode 100644 index 000000000..9e3b6ce7a --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/ns-module.js @@ -0,0 +1,25 @@ +"use strict"; + +// The `ns:module` builtin: the dev-loader control surface the runtime +// exposes to development tooling (docs/ns-builtin-modules.md). Every member +// is a native function handed in through `binding`; this file only shapes +// and freezes the exports. +// +// Membership varies by build: +// - `canonicalizeHttpUrlKey` exists only in debug builds (test diagnostic). +// Missing members are simply absent — never present-but-throwing — so +// feature checks work. + +const { ObjectFreeze } = primordials; + +const surface = { + configureLoader: binding.configureLoader, + invalidateModules: binding.invalidateModules, + getLoadedModuleUrls: binding.getLoadedModuleUrls, + setDevBootComplete: binding.setDevBootComplete, +}; +if (binding.canonicalizeHttpUrlKey !== undefined) { + surface.canonicalizeHttpUrlKey = binding.canonicalizeHttpUrlKey; +} + +module.exports = ObjectFreeze(surface); From b19dbe8a1fa5c6f6252c08f78fec9c9a9399e35f Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 12 Aug 2026 17:27:15 -0700 Subject: [PATCH 04/36] fix(worker): surface entry-script load errors and buffer early messages Worker entry-script load failures now reach worker.onerror instead of failing silently. Messages posted before the worker's entry script has installed onmessage are no longer dropped: ConcurrentQueue::Signal re-arms the drain source without enqueueing (a silent no-op when racing Terminate), and WorkerWrapper retries delivery through a deferred drain, with drainRetryPending_ preventing one stacked retry per attempt. --- .../runtime/src/main/cpp/ConcurrentQueue.cpp | 14 +++++++ .../runtime/src/main/cpp/ConcurrentQueue.h | 2 + .../runtime/src/main/cpp/WorkerWrapper.cpp | 41 ++++++++++++++++--- test-app/runtime/src/main/cpp/WorkerWrapper.h | 2 + 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp b/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp index cc43b238c..0a5fcd52b 100644 --- a/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp +++ b/test-app/runtime/src/main/cpp/ConcurrentQueue.cpp @@ -58,6 +58,20 @@ void ConcurrentQueue::Push(std::shared_ptr message) { } } +void ConcurrentQueue::Signal() { + std::unique_lock lock(initializationMutex_); + if (terminated_ || this->fd_ == -1) { + return; + } + uint64_t value = 1; + write(this->fd_, &value, sizeof(value)); +} + +bool ConcurrentQueue::IsEmpty() { + std::unique_lock mlock(this->mutex_); + return this->messagesQueue_.empty(); +} + std::vector> ConcurrentQueue::PopAll() { std::unique_lock mlock(this->mutex_); std::vector> messages; diff --git a/test-app/runtime/src/main/cpp/ConcurrentQueue.h b/test-app/runtime/src/main/cpp/ConcurrentQueue.h index 33526f443..bbcbbd688 100644 --- a/test-app/runtime/src/main/cpp/ConcurrentQueue.h +++ b/test-app/runtime/src/main/cpp/ConcurrentQueue.h @@ -21,6 +21,8 @@ struct ConcurrentQueue { public: void Initialize(ALooper* looper, ALooper_callbackFunc performWork, void* data); void Push(std::shared_ptr message); + void Signal(); + bool IsEmpty(); std::vector> PopAll(); void Terminate(); diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index 503da71c5..c8417fa9c 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -44,6 +44,7 @@ WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string w isClosing_(false), isTerminating_(false), isDisposed_(false), + drainRetryPending_(false), javaLooperRef_(nullptr) {} void WorkerWrapper::Start() { @@ -145,11 +146,6 @@ void WorkerWrapper::DrainPendingTasks() { return; } - auto messages = queue_.PopAll(); - if (messages.empty()) { - return; - } - v8::Locker locker(isolate); Isolate::Scope isolate_scope(isolate); HandleScope handle_scope(isolate); @@ -157,6 +153,37 @@ void WorkerWrapper::DrainPendingTasks() { Context::Scope context_scope(context); auto globalObject = context->Global(); + // WHATWG parity: buffer inbound messages until the entry script has + // installed `onmessage`. Async ESM entries (HTTP dev sessions, TLA) + // finish evaluating after the wrapper starts draining; silently dropping + // messages with no handler would leave the sender waiting forever. + if (!isTerminating_ && !isClosing_ && !queue_.IsEmpty()) { + Local onMessageValue; + bool gotHandler = + globalObject->Get(context, ArgConverter::ConvertToV8String(isolate, "onmessage")) + .ToLocal(&onMessageValue); + if (!gotHandler || !onMessageValue->IsFunction()) { + bool expected = false; + if (drainRetryPending_.compare_exchange_strong(expected, true)) { + const int workerId = workerId_; + std::thread([workerId]() { + usleep(50 * 1000); + auto wrapper = WorkerWrapper::GetById(workerId); + if (wrapper != nullptr) { + wrapper->drainRetryPending_ = false; + wrapper->SignalMessageDrain(); + } + }).detach(); + } + return; + } + } + + auto messages = queue_.PopAll(); + if (messages.empty()) { + return; + } + for (auto& message : messages) { if (isTerminating_ || isClosing_) { break; @@ -188,6 +215,10 @@ void WorkerWrapper::DrainPendingTasks() { } } +void WorkerWrapper::SignalMessageDrain() { + queue_.Signal(); +} + void WorkerWrapper::FireMessageOnParentWorkerObject(int workerId, std::shared_ptr message) { auto wrapper = WorkerWrapper::GetById(workerId); diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.h b/test-app/runtime/src/main/cpp/WorkerWrapper.h index fd12bbcb2..5006d8ace 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.h +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.h @@ -142,6 +142,7 @@ class WorkerWrapper : public std::enable_shared_from_this { private: void BackgroundLooper(std::shared_ptr self); void DrainPendingTasks(); + void SignalMessageDrain(); void QuitLooper(); static int DrainCallback(int fd, int events, void* data); static void FireMessageOnParentWorkerObject(int workerId, @@ -169,6 +170,7 @@ class WorkerWrapper : public std::enable_shared_from_this { std::atomic_bool isClosing_; std::atomic_bool isTerminating_; std::atomic_bool isDisposed_; + std::atomic_bool drainRetryPending_; ConcurrentQueue queue_; From d5d180c4fe38b97aa4af47eb8f47dde765de935d Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 12 Aug 2026 17:27:19 -0700 Subject: [PATCH 05/36] test: cover the ESM loader, remote-module security, and worker behavior The ns:module surface, remote-module allowlist boundary matching, and relative ESM dynamic-import cases exercise the async loader and the deny-by-default HTTP gate. The on-device result harvester falls back to run-as when adb root is unavailable (Play Store emulator images), and -Pabis is forwarded so a single-ABI V8 tree can build and test locally. --- build.gradle | 6 + .../src/main/assets/app/tests/testNsModule.js | 105 ++++++++++++++++++ .../app/tests/testRemoteModuleSecurity.js | 9 ++ test-app/runtests.gradle | 8 +- .../tools/try_to_find_test_result_file.js | 23 +++- 5 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/testNsModule.js diff --git a/build.gradle b/build.gradle index 221c47de6..cffa741df 100644 --- a/build.gradle +++ b/build.gradle @@ -193,6 +193,9 @@ def getAssembleReleaseBuildArguments = { -> if (onlyX86) { arguments.add("-PonlyX86") } + if (project.hasProperty("abis")) { + arguments.add("-Pabis=${project.property('abis')}") + } if (useCCache) { arguments.add("-PuseCCache") } @@ -462,6 +465,9 @@ def getRunTestsBuildArguments = { taskName -> if (onlyX86) { arguments.add("-PonlyX86") } + if (project.hasProperty("abis")) { + arguments.add("-Pabis=${project.property('abis')}") + } if (useCCache) { arguments.add("-PuseCCache") } diff --git a/test-app/app/src/main/assets/app/tests/testNsModule.js b/test-app/app/src/main/assets/app/tests/testNsModule.js new file mode 100644 index 000000000..6c33b9b2b --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testNsModule.js @@ -0,0 +1,105 @@ +describe("ns:module", function () { + it("should expose the dev-loader primitives via the ns:module builtin", function () { + var nsModule = require("ns:module"); + expect(Object.isFrozen(nsModule)).toBe(true); + expect(typeof nsModule.configureLoader).toBe("function"); + expect(typeof nsModule.invalidateModules).toBe("function"); + expect(typeof nsModule.getLoadedModuleUrls).toBe("function"); + expect(typeof nsModule.setDevBootComplete).toBe("function"); + expect(nsModule.terminateAllWorkers).toBeUndefined(); + expect(global.__NS_DEV__).toBeUndefined(); + }); + + it("exposes exactly the declared surface", function () { + var nsModule = require("ns:module"); + var expected = ["configureLoader", "getLoadedModuleUrls", "invalidateModules", "setDevBootComplete"]; + if (typeof nsModule.canonicalizeHttpUrlKey === "function") { + expected.push("canonicalizeHttpUrlKey"); + } + expect(Object.keys(nsModule).sort()).toEqual(expected.sort()); + }); + + it("resolves ns:module to the same members for require and import()", function (done) { + var nsModule = require("ns:module"); + import("ns:module").then(function (ns) { + expect(ns.default).toBe(nsModule); + expect(ns.invalidateModules).toBe(nsModule.invalidateModules); + expect(ns.configureLoader).toBe(nsModule.configureLoader); + done(); + }).catch(function (error) { + fail("import('ns:module') rejected: " + error.message); + done(); + }); + }); + + it("setDevBootComplete flips the JS-visible boot-complete global", function () { + var nsModule = require("ns:module"); + nsModule.setDevBootComplete(true); + expect(global.__NS_HMR_BOOT_COMPLETE__).toBe(true); + nsModule.setDevBootComplete(false); + expect(global.__NS_HMR_BOOT_COMPLETE__).toBe(false); + nsModule.setDevBootComplete(); + expect(global.__NS_HMR_BOOT_COMPLETE__).toBe(true); + nsModule.setDevBootComplete(false); + }); +}); + +describe("HTTP canonical key (ns:module canonicalizeHttpUrlKey)", function () { + function getCanon() { + return require("ns:module").canonicalizeHttpUrlKey; + } + + function checkKey(input, expected) { + var canon = getCanon(); + if (typeof canon !== "function") { + pending("ns:module.canonicalizeHttpUrlKey not exposed (release build)"); + return; + } + expect(canon(input)).toBe(expected); + } + + it("is exposed as a function in debug builds", function () { + var canon = getCanon(); + if (typeof canon !== "function") { + pending("ns:module.canonicalizeHttpUrlKey not exposed (release build)"); + return; + } + expect(typeof canon).toBe("function"); + }); + + it("drops dev cache-busters (t/v/import) but keeps real query params", function () { + checkKey("http://h/ns/core?p=x&t=123&v=9&import=1", "http://h/ns/core?p=x"); + }); + + it("leaves public (non-dev, non-volatile) URLs untouched", function () { + checkKey("https://cdn.example.com/lib.js?token=abc", "https://cdn.example.com/lib.js?token=abc"); + }); + + it("treats module identity as literally the URL — no path-tag collapses", function () { + checkKey("http://h/ns/m/foo.js", "http://h/ns/m/foo.js"); + checkKey("http://h/ns/rt", "http://h/ns/rt"); + checkKey("http://h/ns/core", "http://h/ns/core"); + }); + + it("ignores URL fragments for dev endpoints", function () { + checkKey("http://h/ns/m/foo.js#frag", "http://h/ns/m/foo.js"); + }); + + it("honors a client-supplied canonicalization vocabulary via configureLoader", function () { + var canon = getCanon(); + if (typeof canon !== "function") { + pending("ns:module.canonicalizeHttpUrlKey not exposed (release build)"); + return; + } + require("ns:module").configureLoader({ + canonicalization: { + stripParams: ["t", "v", "import"], + forPathPrefixes: ["/ns/", "/node_modules/.vite/", "/@id/", "/@fs/"], + preserveQueryFor: ["/@ng/component"], + }, + }); + expect(canon("http://h/ns/core?p=x&t=123&v=9&import=1")).toBe("http://h/ns/core?p=x"); + expect(canon("http://h/ns/m/comp/@ng/component?c=a&t=42")).toBe("http://h/ns/m/comp/@ng/component?c=a&t=42"); + expect(canon("https://cdn.example.com/lib.js?token=abc")).toBe("https://cdn.example.com/lib.js?token=abc"); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js b/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js index 0398634b3..62d9153a6 100644 --- a/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js +++ b/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js @@ -142,6 +142,15 @@ describe("Remote Module Security", function() { // In debug mode, this returns true because debug bypasses allowlist expect(isAllowed).toBe(true); }); + + it("should refuse lookalike-host prefixes at a URL-component boundary (Java helper)", function() { + // The Java helper is the production-path twin of the native gate. + // Debug still short-circuits to true, so this only asserts the + // helper exists and debug bypass still holds; production matching + // is covered by the native RemoteUrlMatchesAllowlistEntry logic. + expect(typeof com.tns.Runtime.isRemoteUrlAllowed).toBe("function"); + expect(com.tns.Runtime.isRemoteUrlAllowed("https://cdn.example.com.attacker.com/x.js")).toBe(true); + }); }); describe("Static Import HTTP Loading", function() { diff --git a/test-app/runtests.gradle b/test-app/runtests.gradle index 9cc19e6ff..aeb6f4951 100644 --- a/test-app/runtests.gradle +++ b/test-app/runtests.gradle @@ -35,6 +35,9 @@ def getBuildArguments = { -> if (onlyX86) { arguments.add("-PonlyX86") } + if (project.hasProperty("abis")) { + arguments.add("-Pabis=${project.property('abis')}") + } if (useCCache) { arguments.add("-PuseCCache") } @@ -68,13 +71,14 @@ task runAdbAsRoot(type: Exec) { } task deletePreviousResultXml(type: Exec) { + ignoreExitValue = true doFirst { println "Removing previous android_unit_test_results.xml" if (isWinOs) { - commandLine "cmd", "/c", "adb", runOnDeviceOrEmulator, "-e", "shell", "rm", "-rf", "/data/data/com.tns.testapplication/android_unit_test_results.xml" + commandLine "cmd", "/c", "adb", runOnDeviceOrEmulator, "-e", "shell", "run-as", "com.tns.testapplication", "rm", "-f", "android_unit_test_results.xml" } else { - commandLine "adb", runOnDeviceOrEmulator, "-e", "shell", "rm", "-rf", "/data/data/com.tns.testapplication/android_unit_test_results.xml" + commandLine "adb", runOnDeviceOrEmulator, "-e", "shell", "run-as", "com.tns.testapplication", "rm", "-f", "android_unit_test_results.xml" } } } diff --git a/test-app/tools/try_to_find_test_result_file.js b/test-app/tools/try_to_find_test_result_file.js index b9bebb19a..763bb32e3 100644 --- a/test-app/tools/try_to_find_test_result_file.js +++ b/test-app/tools/try_to_find_test_result_file.js @@ -135,7 +135,28 @@ async function tryPullResultsFile() { const { error } = await execAndStream(`${adbPrefix} pull ${resultsPath}`); if (!error) { - console.log("Tests results file found!"); + const fs = require("fs"); + try { + const text = fs.readFileSync("android_unit_test_results.xml", "utf8"); + if (text.trimStart().startsWith(" Date: Wed, 12 Aug 2026 17:27:24 -0700 Subject: [PATCH 06/36] refactor(runtime): drop the require() optional-module placeholder --- test-app/runtime/src/main/cpp/ModuleInternal.cpp | 10 ---------- test-app/runtime/src/main/cpp/ModuleInternal.h | 1 - 2 files changed, 11 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 09f45569b..adbaba1a1 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -86,16 +86,6 @@ static std::string PromiseRejectionMessage(Isolate* isolate, Local prom return errorMessage; } -// Helper function to check if a module name looks like an optional external module -bool ModuleInternal::IsLikelyOptionalModule(const std::string& moduleName) { - // Check if it's a bare module name (no path separators) that could be an npm package - if (moduleName.find('/') == std::string::npos && moduleName.find('\\') == std::string::npos && - moduleName[0] != '.' && moduleName[0] != '~' && moduleName[0] != '/') { - return true; - } - return false; -} - // A package-style specifier: neither a path nor a scheme, so it may be claimed // by a registry rather than resolved on disk. static bool IsBareSpecifier(const std::string& specifier) { diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.h b/test-app/runtime/src/main/cpp/ModuleInternal.h index def3e5d9b..d0eafc49c 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.h +++ b/test-app/runtime/src/main/cpp/ModuleInternal.h @@ -38,7 +38,6 @@ class ModuleInternal { static void CheckFileExists(v8::Isolate* isolate, const std::string& path, const std::string& baseDir); // Helper functions for ES module support - static bool IsLikelyOptionalModule(const std::string& moduleName); static bool IsESModule(const std::string& path); static v8::Local LoadESModule(v8::Isolate* isolate, const std::string& path); From b6d3bacf7de2c5ee1f419c1d979b1dbfe531c9d0 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 12 Aug 2026 17:27:28 -0700 Subject: [PATCH 07/36] refactor(runtime): rename HMRSupport to HttpLoader and fold DevFlags into ns:runtime Live log flags (logScriptLoading, httpFetchUrlLog) move onto ns:runtime setConfig/getConfig. Remote-module security stays boot-time nativescript.config only. Android does not expose releasedObjectPolicy. --- docs/ns-builtin-modules.md | 32 ++++++- .../main/assets/app/tests/testNsRuntime.js | 67 +++++++++++++ test-app/runtime/CMakeLists.txt | 1 + .../runtime/src/main/cpp/NsBuiltinModules.cpp | 94 +++++++++++++++++++ test-app/runtime/src/main/cpp/js/README.md | 3 +- .../runtime/src/main/cpp/js/ns-runtime.js | 14 +++ 6 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/testNsRuntime.js create mode 100644 test-app/runtime/src/main/cpp/js/ns-runtime.js diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index bbecf6a72..b050ad765 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -52,6 +52,32 @@ Rules: versions for readability; it is intended for humans and must not be parsed programmatically. +### `ns:runtime` (v1) + +Runtime-level configuration. Keys, value domains, and scope are defined and +validated natively; the module surface is a thin frozen wrapper. + +| export | description | +|---|---| +| `setConfig(key, value)` | Sets a runtime config key. Throws `TypeError` on an unknown key, an invalid value, or (for process-wide keys) when called from a worker isolate. | +| `getConfig(key)` | Returns the current value of a config key. Throws `TypeError` on an unknown key. Readable from any isolate. | + +Config keys: + +| key | values | scope | default | +|---|---|---|---| +| `logScriptLoading` | `true` \| `false` | process-wide (main-isolate writes only; read live by every isolate) | `false`, or the `logScriptLoading` value from nativescript.config / package.json at boot | +| `httpFetchUrlLog` | `true` \| `false` | process-wide (main-isolate writes only; read live by every isolate) | `false`, or the `httpFetchUrlLog` value from nativescript.config / package.json at boot | + +Remote-module security (`security.allowRemoteModules`, +`security.remoteModuleAllowlist`) is **not** part of this surface. Those +values are read once from nativescript.config / package.json the first time +the HTTP loader gates a fetch, and they cannot be inspected or changed +through `getConfig` / `setConfig`. + +iOS additionally registers `releasedObjectPolicy`; Android does not (it has +no released-native-counterpart machinery). + ### `ns:module` (v1) The module-loader control surface consumed by development tooling @@ -72,7 +98,11 @@ never present-but-throwing — so feature checks work. The module is registered in every build; the security boundary for remote module loading sits at the network layer (`security.allowRemoteModules` in nativescript.config, enforced inside `HttpLoader`), not the module -registry. +registry and not `ns:runtime` getConfig/setConfig. + +Note: `ns:module` (loader policy, structured, boot-time) is deliberately +separate from `ns:runtime` (live key-value runtime flags, `setConfig`/ +`getConfig`). ## `node:` compatibility shims diff --git a/test-app/app/src/main/assets/app/tests/testNsRuntime.js b/test-app/app/src/main/assets/app/tests/testNsRuntime.js new file mode 100644 index 000000000..dd9984815 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testNsRuntime.js @@ -0,0 +1,67 @@ +describe("ns:runtime", function () { + var runtime = require("ns:runtime"); + + it("exposes frozen exports", function () { + expect(Object.isFrozen(runtime)).toBe(true); + expect(typeof runtime.setConfig).toBe("function"); + expect(typeof runtime.getConfig).toBe("function"); + }); + + it("exposes exactly the declared surface", function () { + expect(Object.keys(runtime).sort()).toEqual(["getConfig", "setConfig"]); + }); + + it("rejects unknown keys", function () { + expect(function () { + runtime.setConfig("noSuchKey", 1); + }).toThrowError(TypeError, /Unknown runtime config key/); + expect(function () { + runtime.getConfig("noSuchKey"); + }).toThrowError(TypeError, /Unknown runtime config key/); + }); + + it("defaults logScriptLoading and httpFetchUrlLog from app config", function () { + expect(runtime.getConfig("logScriptLoading")).toBe(false); + expect(runtime.getConfig("httpFetchUrlLog")).toBe(false); + }); + + it("round-trips logScriptLoading and httpFetchUrlLog", function () { + runtime.setConfig("logScriptLoading", true); + expect(runtime.getConfig("logScriptLoading")).toBe(true); + runtime.setConfig("logScriptLoading", false); + expect(runtime.getConfig("logScriptLoading")).toBe(false); + + runtime.setConfig("httpFetchUrlLog", true); + expect(runtime.getConfig("httpFetchUrlLog")).toBe(true); + runtime.setConfig("httpFetchUrlLog", false); + expect(runtime.getConfig("httpFetchUrlLog")).toBe(false); + }); + + it("rejects non-boolean log flag values and keeps the current one", function () { + expect(function () { + runtime.setConfig("logScriptLoading", "yes"); + }).toThrowError(TypeError, /must be a boolean/); + expect(runtime.getConfig("logScriptLoading")).toBe(false); + expect(function () { + runtime.setConfig("httpFetchUrlLog", 1); + }).toThrowError(TypeError, /must be a boolean/); + expect(runtime.getConfig("httpFetchUrlLog")).toBe(false); + }); + + it("does not expose remote-module security through getConfig or setConfig", function () { + ["security", "allowRemoteModules", "remoteModuleAllowlist"].forEach(function (key) { + expect(function () { + runtime.getConfig(key); + }).toThrowError(TypeError, /Unknown runtime config key/); + expect(function () { + runtime.setConfig(key, true); + }).toThrowError(TypeError, /Unknown runtime config key/); + }); + }); + + it("does not expose releasedObjectPolicy (iOS-only)", function () { + expect(function () { + runtime.getConfig("releasedObjectPolicy"); + }).toThrowError(TypeError, /Unknown runtime config key/); + }); +}); diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index d332a354a..a50fb1456 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -75,6 +75,7 @@ set(RUNTIME_BUILTIN_JS ${RUNTIME_BUILTIN_JS_DIR}/json-helper.js ${RUNTIME_BUILTIN_JS_DIR}/node-util.js ${RUNTIME_BUILTIN_JS_DIR}/ns-module.js + ${RUNTIME_BUILTIN_JS_DIR}/ns-runtime.js ${RUNTIME_BUILTIN_JS_DIR}/ns-util.js ${RUNTIME_BUILTIN_JS_DIR}/performance.js ${RUNTIME_BUILTIN_JS_DIR}/primordials.js diff --git a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp index abdfc2ee1..b1e04fe56 100644 --- a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp +++ b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp @@ -7,6 +7,7 @@ #include "ArgConverter.h" #include "BuiltinLoader.h" #include "HttpLoader.h" +#include "Runtime.h" #include "RuntimeState.h" #include "console/Console.h" #include "robin_hood.h" @@ -33,10 +34,89 @@ struct Registration { */ constexpr Registration kRegistry[] = { {"ns:module", BuiltinId::kNsModule}, + {"ns:runtime", BuiltinId::kNsRuntime}, {"ns:util", BuiltinId::kNsUtil}, {"node:util", BuiltinId::kNodeUtil}, }; +constexpr const char* kLogScriptLoadingKey = "logScriptLoading"; +constexpr const char* kHttpFetchUrlLogKey = "httpFetchUrlLog"; + +void ThrowTypeError(Isolate* isolate, const std::string& message) { + isolate->ThrowException(Exception::TypeError(ArgConverter::ConvertToV8String(isolate, message))); +} + +bool EnsureMainIsolateWrite(Isolate* isolate, const std::string& key) { + Runtime* runtime = Runtime::GetRuntime(isolate); + if (runtime == nullptr || !runtime->IsMainThread()) { + ThrowTypeError(isolate, "'" + key + + "' is process-wide and can only be set from the main " + "isolate"); + return false; + } + return true; +} + +bool ParseBooleanValue(Isolate* isolate, const FunctionCallbackInfo& info, + const std::string& key, bool* out) { + if (!info[1]->IsBoolean()) { + ThrowTypeError(isolate, "'" + key + "' must be a boolean"); + return false; + } + *out = info[1].As()->Value(); + return true; +} + +void SetConfigCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 2 || !info[0]->IsString()) { + ThrowTypeError(isolate, "setConfig expects (key: string, value)"); + return; + } + std::string key = ArgConverter::ConvertToString(info[0].As()); + if (key == kLogScriptLoadingKey) { + if (!EnsureMainIsolateWrite(isolate, key)) { + return; + } + bool value = false; + if (!ParseBooleanValue(isolate, info, key, &value)) { + return; + } + tns::SetScriptLoadingLogEnabled(value); + return; + } + if (key == kHttpFetchUrlLogKey) { + if (!EnsureMainIsolateWrite(isolate, key)) { + return; + } + bool value = false; + if (!ParseBooleanValue(isolate, info, key, &value)) { + return; + } + tns::SetHttpFetchUrlLogEnabled(value); + return; + } + ThrowTypeError(isolate, "Unknown runtime config key: '" + key + "'"); +} + +void GetConfigCallback(const FunctionCallbackInfo& info) { + Isolate* isolate = info.GetIsolate(); + if (info.Length() < 1 || !info[0]->IsString()) { + ThrowTypeError(isolate, "getConfig expects (key: string)"); + return; + } + std::string key = ArgConverter::ConvertToString(info[0].As()); + if (key == kLogScriptLoadingKey) { + info.GetReturnValue().Set(v8::Boolean::New(isolate, tns::IsScriptLoadingLogEnabled())); + return; + } + if (key == kHttpFetchUrlLogKey) { + info.GetReturnValue().Set(v8::Boolean::New(isolate, tns::IsHttpFetchUrlLogEnabled())); + return; + } + ThrowTypeError(isolate, "Unknown runtime config key: '" + key + "'"); +} + const Registration* Find(const std::string& specifier) { for (const Registration& registration : kRegistry) { if (specifier == registration.specifier) { @@ -96,6 +176,20 @@ MaybeLocal BuildBinding(Local context, BuiltinId builtin) { } break; } + case BuiltinId::kNsRuntime: { + Local setConfig, getConfig; + if (!v8::Function::New(context, SetConfigCallback).ToLocal(&setConfig) || + !v8::Function::New(context, GetConfigCallback).ToLocal(&getConfig) || + !binding->Set(context, ArgConverter::ConvertToV8String(isolate, "setConfig"), + setConfig) + .FromMaybe(false) || + !binding->Set(context, ArgConverter::ConvertToV8String(isolate, "getConfig"), + getConfig) + .FromMaybe(false)) { + return MaybeLocal(); + } + break; + } case BuiltinId::kNsUtil: { // The console formatter is built once per realm; ns:util // re-exports that instance instead of creating a second one. diff --git a/test-app/runtime/src/main/cpp/js/README.md b/test-app/runtime/src/main/cpp/js/README.md index 4730d4b45..054a384f9 100644 --- a/test-app/runtime/src/main/cpp/js/README.md +++ b/test-app/runtime/src/main/cpp/js/README.md @@ -46,7 +46,8 @@ module.exports = somethingTheCallSiteNeeds; `node:util` shim: one source file per specifier, the shim owning every bit of Node compatibility. See `docs/ns-builtin-modules.md` for the cross-runtime contract. -- `ns-module.js` is the `ns:module` loader-control surface. +- `ns-module.js` is the `ns:module` loader-control surface and `ns-runtime.js` + is the `ns:runtime` live config surface (`setConfig`/`getConfig`). - Destructure `binding` and `primordials` once, at the top of the file, so the file's dependencies are visible and greppable. diff --git a/test-app/runtime/src/main/cpp/js/ns-runtime.js b/test-app/runtime/src/main/cpp/js/ns-runtime.js new file mode 100644 index 000000000..fc026722e --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/ns-runtime.js @@ -0,0 +1,14 @@ +"use strict"; + +// The `ns:runtime` builtin module: runtime-level configuration and (future) +// runtime introspection. See docs/ns-builtin-modules.md for the contract and +// the key registry — keys, their value domains, and their scope (process-wide +// vs per-isolate) are defined and validated on the native side, so this file +// stays a thin, frozen surface. + +const { setConfig, getConfig } = binding; +const { ObjectFreeze } = primordials; + +exports.setConfig = setConfig; +exports.getConfig = getConfig; +ObjectFreeze(exports); From 4e5802871733b11a43c41fa84d24f0a98fa0d8ac Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 13 Aug 2026 11:04:35 -0700 Subject: [PATCH 08/36] fix(runtime): harden HTTP fetch, extend names, and worker drain retries JNI mid-body read exceptions no longer spin the JS thread, async fetch threads detach from the JVM, and canonicalization config is published as an immutable snapshot so configureLoader cannot race a background fetch. --- test-app/runtime/src/main/cpp/HttpLoader.cpp | 92 +++++++++++++------ .../runtime/src/main/cpp/MetadataNode.cpp | 20 +++- .../runtime/src/main/cpp/ModuleInternal.cpp | 4 + test-app/runtime/src/main/cpp/Runtime.cpp | 1 + .../runtime/src/main/cpp/WorkerWrapper.cpp | 14 ++- test-app/runtime/src/main/cpp/WorkerWrapper.h | 2 + .../src/main/java/com/tns/DexFactory.java | 19 +++- .../tools/try_to_find_test_result_file.js | 12 ++- 8 files changed, 126 insertions(+), 38 deletions(-) diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp index 8d26e6f12..b2a0b6976 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.cpp +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,7 @@ #include "NativeScriptException.h" #include "Runtime.h" #include "robin_hood.h" +#include "v8-json.h" namespace tns { @@ -232,25 +234,33 @@ struct CanonicalizationConfig { std::vector devPathPrefixes; std::vector preserveQueryPrefixes; }; -static CanonicalizationConfig g_canonConfig; -static bool g_canonConfigured = false; +static std::mutex g_canonConfigMutex; +static std::shared_ptr g_canonConfig; + +static std::shared_ptr CurrentCanonicalizationConfig() { + std::lock_guard lock(g_canonConfigMutex); + return g_canonConfig; +} static void SetCanonicalizationConfig(CanonicalizationConfig config) { - g_canonConfig = std::move(config); - g_canonConfigured = true; + auto snapshot = std::make_shared(std::move(config)); + { + std::lock_guard lock(g_canonConfigMutex); + g_canonConfig = snapshot; + } if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE_FORCE( "[ns:module configureLoader] canonicalization set (strip=%lu devPrefixes=%lu " "preserve=%lu)", - (unsigned long)g_canonConfig.stripParams.size(), - (unsigned long)g_canonConfig.devPathPrefixes.size(), - (unsigned long)g_canonConfig.preserveQueryPrefixes.size()); + (unsigned long)snapshot->stripParams.size(), + (unsigned long)snapshot->devPathPrefixes.size(), + (unsigned long)snapshot->preserveQueryPrefixes.size()); } } static void ResetCanonicalizationConfig() { - g_canonConfig = CanonicalizationConfig{}; - g_canonConfigured = false; + std::lock_guard lock(g_canonConfigMutex); + g_canonConfig.reset(); } std::string CanonicalizeHttpUrlKey(const std::string& url) { @@ -278,16 +288,17 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) { std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos); std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1); + auto canon = CurrentCanonicalizationConfig(); { std::string pathOnly = originAndPath.substr(pathStart); - if (g_canonConfigured) { - for (const auto& p : g_canonConfig.preserveQueryPrefixes) { + if (canon) { + for (const auto& p : canon->preserveQueryPrefixes) { if (!p.empty() && pathOnly.find(p) != std::string::npos) { return noHash; } } bool isDevEndpoint = false; - for (const auto& p : g_canonConfig.devPathPrefixes) { + for (const auto& p : canon->devPathPrefixes) { if (!p.empty() && StartsWith(pathOnly, p.c_str())) { isDevEndpoint = true; break; @@ -322,9 +333,9 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) { size_t eq = pair.find('='); std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); bool drop; - if (g_canonConfigured) { - drop = std::find(g_canonConfig.stripParams.begin(), g_canonConfig.stripParams.end(), - name) != g_canonConfig.stripParams.end(); + if (canon) { + drop = std::find(canon->stripParams.begin(), canon->stripParams.end(), + name) != canon->stripParams.end(); } else { drop = (name == "import" || name == "t" || name == "v"); } @@ -698,14 +709,29 @@ static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, jobject baos = env.NewObject(clsBAOS, baosCtor); jbyteArray buffer = env.NewByteArray(8192); + bool readFailed = false; while (true) { jint n = env.CallIntMethod(inStream, readMethod, buffer); + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("read-body", excClass, excMsg); + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE_FORCE( + "[http-esm][fetch][exception] stage=read-body url=%s class=%s msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + } + readFailed = true; + break; + } if (n < 0) break; if (n == 0) continue; env.CallVoidMethod(baos, baosWrite, buffer, 0, n); } env.CallVoidMethod(inStream, closeIS); + if (readFailed) { + return false; + } jbyteArray bytes = static_cast(env.CallObjectMethod(baos, baosToByteArray)); env.CallVoidMethod(baos, baosClose); @@ -773,6 +799,26 @@ void FetchModuleBodyAsync(const std::string& url, } std::thread([url, completion = std::move(completion)]() mutable { + JavaVM* jvm = Runtime::GetJVM(); + bool attachedHere = false; + if (jvm != nullptr) { + JNIEnv* raw = nullptr; + if (jvm->GetEnv(reinterpret_cast(&raw), JNI_VERSION_1_6) != JNI_OK) { + if (jvm->AttachCurrentThread(&raw, nullptr) == JNI_OK) { + attachedHere = true; + } + } + } + struct DetachIfAttached { + JavaVM* jvm; + bool attached; + ~DetachIfAttached() { + if (attached && jvm != nullptr) { + jvm->DetachCurrentThread(); + } + } + } detachGuard{jvm, attachedHere}; + std::string out; std::string contentType; int status = 0; @@ -870,19 +916,9 @@ void ConfigureLoaderCallback(const v8::FunctionCallbackInfo& info) { v8::String::Utf8Value utf8(isolate, importMapVal); if (*utf8) jsonStr = *utf8; } else if (importMapVal->IsObject()) { - v8::Local jsonObj = - ctx->Global() - ->Get(ctx, ToV8String(isolate, "JSON")) - .ToLocalChecked() - .As(); - v8::Local stringify = - jsonObj->Get(ctx, ToV8String(isolate, "stringify")) - .ToLocalChecked() - .As(); - v8::Local args[] = {importMapVal}; - v8::Local result; - if (stringify->Call(ctx, jsonObj, 1, args).ToLocal(&result) && result->IsString()) { - v8::String::Utf8Value utf8(isolate, result); + v8::Local stringified; + if (v8::JSON::Stringify(ctx, importMapVal).ToLocal(&stringified)) { + v8::String::Utf8Value utf8(isolate, stringified); if (*utf8) jsonStr = *utf8; } } diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index e1a4671e1..a6a116ffc 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -1891,6 +1891,11 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio } } + size_t queryOrFragment = normalized.find_first_of("?#"); + if (queryOrFragment != string::npos) { + normalized.resize(queryOrFragment); + } + const string& appRoot = Constants::APP_ROOT_FOLDER_PATH; if (!appRoot.empty()) { stripPrefix(normalized, appRoot); @@ -1908,10 +1913,17 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio fullPathToFile = normalized; - std::replace(fullPathToFile.begin(), fullPathToFile.end(), '/', '_'); - std::replace(fullPathToFile.begin(), fullPathToFile.end(), '.', '_'); - std::replace(fullPathToFile.begin(), fullPathToFile.end(), '-', '_'); - std::replace(fullPathToFile.begin(), fullPathToFile.end(), ' ', '_'); + for (char& ch : fullPathToFile) { + const unsigned char c = static_cast(ch); + const bool isIdentifierChar = + (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + ch == '_'; + if (!isIdentifierChar) { + ch = '_'; + } + } std::vector pathParts; Util::SplitString(fullPathToFile, "_", pathParts); diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index adbaba1a1..f2c5e82c3 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -55,6 +55,7 @@ static std::string NormalizeHttpModuleUrl(const std::string& path) { static std::string PromiseRejectionMessage(Isolate* isolate, Local promise, const std::string& path) { std::string errorMessage = "Module evaluation promise rejected: " + path; + TryCatch tc(isolate); Local reason = promise->Result(); if (reason.IsEmpty()) { return errorMessage; @@ -83,6 +84,9 @@ static std::string PromiseRejectionMessage(Isolate* isolate, Local prom } } } + if (tc.HasCaught()) { + tc.Reset(); + } return errorMessage; } diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 38e570433..60a217122 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -361,6 +361,7 @@ static void PumpPendingHttpModuleGraph(v8::Isolate* isolate) { ALooper_pollOnce(10, nullptr, nullptr, nullptr); isolate->PerformMicrotaskCheckpoint(); if (std::chrono::duration(std::chrono::steady_clock::now() - start).count() > 60.0) { + DEBUG_WRITE("PumpPendingHttpModuleGraph: deadline expired with pending async module work"); break; } } diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index c8417fa9c..0e2c228ba 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -45,6 +45,7 @@ WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string w isTerminating_(false), isDisposed_(false), drainRetryPending_(false), + drainRetryAttempts_(0), javaLooperRef_(nullptr) {} void WorkerWrapper::Start() { @@ -164,7 +165,9 @@ void WorkerWrapper::DrainPendingTasks() { .ToLocal(&onMessageValue); if (!gotHandler || !onMessageValue->IsFunction()) { bool expected = false; - if (drainRetryPending_.compare_exchange_strong(expected, true)) { + if (drainRetryAttempts_ < kMaxDrainRetryAttempts && + drainRetryPending_.compare_exchange_strong(expected, true)) { + ++drainRetryAttempts_; const int workerId = workerId_; std::thread([workerId]() { usleep(50 * 1000); @@ -174,8 +177,15 @@ void WorkerWrapper::DrainPendingTasks() { wrapper->SignalMessageDrain(); } }).detach(); + return; } - return; + if (drainRetryAttempts_ < kMaxDrainRetryAttempts) { + return; + } + // Retry budget exhausted: fall through so the per-message loop + // logs the missing handler and drops the messages. + } else { + drainRetryAttempts_ = 0; } } diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.h b/test-app/runtime/src/main/cpp/WorkerWrapper.h index 5006d8ace..5fc46084c 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.h +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.h @@ -171,6 +171,8 @@ class WorkerWrapper : public std::enable_shared_from_this { std::atomic_bool isTerminating_; std::atomic_bool isDisposed_; std::atomic_bool drainRetryPending_; + int drainRetryAttempts_ = 0; + static constexpr int kMaxDrainRetryAttempts = 40; ConcurrentQueue queue_; diff --git a/test-app/runtime/src/main/java/com/tns/DexFactory.java b/test-app/runtime/src/main/java/com/tns/DexFactory.java index 29f302e50..345295cab 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -194,7 +194,7 @@ && injectDexIntoClassLoader((BaseDexClassLoader) classLoader, jarFilePath)) { } public Class findClass(String className) throws ClassNotFoundException { - String canonicalName = className.replace('/', '.').replace('$', '_'); + String canonicalName = className.replace('/', '.'); if (logger.isEnabled()) { logger.write(canonicalName); } @@ -204,7 +204,22 @@ public Class findClass(String className) throws ClassNotFoundException { return existingClass; } - return classLoader.loadClass(canonicalName); + String underscored = canonicalName.replace('$', '_'); + if (!underscored.equals(canonicalName)) { + existingClass = this.injectedDexClasses.get(underscored); + if (existingClass != null) { + return existingClass; + } + } + + try { + return classLoader.loadClass(canonicalName); + } catch (ClassNotFoundException e) { + if (!underscored.equals(canonicalName)) { + return classLoader.loadClass(underscored); + } + throw e; + } } public static String strJoin(String[] array, String separator) { diff --git a/test-app/tools/try_to_find_test_result_file.js b/test-app/tools/try_to_find_test_result_file.js index 763bb32e3..d12cfc7d8 100644 --- a/test-app/tools/try_to_find_test_result_file.js +++ b/test-app/tools/try_to_find_test_result_file.js @@ -131,6 +131,14 @@ async function checkForErrorActivity() { } } +function isCompleteJunitXml(text) { + if (!text || typeof text !== "string") { + return false; + } + const trimmed = text.trim(); + return /]/.test(trimmed) && trimmed.includes(""); +} + async function tryPullResultsFile() { const { error } = await execAndStream(`${adbPrefix} pull ${resultsPath}`); @@ -138,7 +146,7 @@ async function tryPullResultsFile() { const fs = require("fs"); try { const text = fs.readFileSync("android_unit_test_results.xml", "utf8"); - if (text.trimStart().startsWith(" Date: Fri, 14 Aug 2026 20:19:28 -0700 Subject: [PATCH 09/36] ci: build --- .../runtime/src/main/cpp/ModuleInternalCallbacks.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 698ecb45e..6071957f7 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -1017,7 +1017,7 @@ static uint64_t MonotonicUs() { // // EnqueueUrl(root) // → FetchModuleBodyAsync (background thread — see HttpLoader.cpp) -// → hop to the isolate's JS thread via LooperTasks::Post +// → hop to the isolate's JS thread via EventLoop::PostInternal // → CompileModuleForResolveRegisterOnly (registers under the canonical // URL key — the exact entry ResolveModuleCallback will look up) // → GetModuleRequests() → ResolveModuleRequestForWalk → EnqueueUrl(…) @@ -1033,7 +1033,7 @@ namespace { struct AsyncGraphLoad { v8::Isolate* isolate = nullptr; v8::Global context; - std::shared_ptr jsTasks; // isolate's JS thread queue + std::shared_ptr jsTasks; // isolate's JS thread queue std::string rootKey; // canonical registry key of the root URL robin_hood::unordered_set visited; // canonical keys (JS thread only) int pendingFetches = 0; // JS thread only @@ -1284,7 +1284,7 @@ static void AsyncGraphEnqueueUrl(const std::shared_ptr& load, } load->pendingFetches++; - std::shared_ptr jsTasks = load->jsTasks; + std::shared_ptr jsTasks = load->jsTasks; std::shared_ptr loadRef = load; FetchModuleBodyAsync(url, [loadRef, url, jsTasks](bool ok, int status, std::string body) { @@ -1295,7 +1295,7 @@ static void AsyncGraphEnqueueUrl(const std::shared_ptr& load, return; } auto bodyPtr = std::make_shared(std::move(body)); - jsTasks->Post([loadRef, url, ok, status, bodyPtr]() { + jsTasks->PostInternal([loadRef, url, ok, status, bodyPtr]() { AsyncGraphOnFetchCompleted(loadRef, url, ok, status, bodyPtr); }); }); @@ -1315,7 +1315,7 @@ void StartAsyncHttpModuleGraphLoad( load->onComplete = std::move(onComplete); Runtime* runtime = Runtime::GetRuntime(isolate); - load->jsTasks = runtime != nullptr ? runtime->GetLooperTasks() : nullptr; + load->jsTasks = runtime != nullptr ? runtime->GetEventLoop() : nullptr; AsyncGraphLoad::g_asyncGraphLoadsInFlightCounter().fetch_add( 1, std::memory_order_acq_rel); @@ -1344,7 +1344,7 @@ bool RunAsyncHttpModuleGraphLoadPumped(v8::Isolate* isolate, // Manual looper pump ("until either all is settled or the app takes // over"): the walk's completion tasks are posted to this thread's - // LooperTasks queue and dispatched via ALooper — polling the looper here + // EventLoop and dispatched via ALooper — polling the looper here // services them. ALooper_pollOnce with a small timeout keeps the pump // responsive without spinning. const auto deadline = From 593dc7f881b3801f1cbafe63c57119d4f6596990 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 10:39:14 -0300 Subject: [PATCH 10/36] refactor(runtime): move per-isolate loader state onto RuntimeState slots Consolidate the module loader's per-isolate state (registries, fallback maps, resolution stack, re-entry bookkeeping, waiter lists, in-flight async graph loads, and the loader vocabulary) into one ModuleLoaderState reached via RuntimeState::For, destroyed with the isolate's RuntimeState while the isolate is still alive. This replaces the mutex-guarded Isolate*-keyed map, seven thread_local containers, and the process-global import-map / volatile-pattern / canonicalization storage. g_moduleWaiters was process-wide (not thread_local like its siblings), so two isolates sharing a registry key could cross-isolate Global::Get; as a per-isolate member that hazard is gone. The loader vocabulary is now owned by the calling isolate: canonical keys are computed on the isolate's thread and threaded by value into the fetch transport, which never canonicalizes. Workers start with an empty vocabulary until the spawn-time copy lands later in this series. DestroyModuleStateForIsolate shrinks to QuiesceModuleLoadsForIsolate (flag in-flight fetches dead, Reset their context Globals early); everything else dies with the slot. CleanupImportMapGlobals is gone; CleanupHttpLoaderGlobals keeps only the transport's process-wide state. --- test-app/runtime/src/main/cpp/HttpLoader.cpp | 91 ++-- test-app/runtime/src/main/cpp/HttpLoader.h | 28 +- .../runtime/src/main/cpp/ModuleInternal.cpp | 6 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 499 ++++++++++-------- .../src/main/cpp/ModuleInternalCallbacks.h | 46 +- test-app/runtime/src/main/cpp/Runtime.cpp | 16 +- 6 files changed, 394 insertions(+), 292 deletions(-) diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp index b2a0b6976..d158d9acf 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.cpp +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -227,41 +227,7 @@ void SetDevBootComplete(v8::Isolate* isolate, v8::Local context, bo } // ───────────────────────────────────────────────────────────── -// Canonicalization vocabulary - -struct CanonicalizationConfig { - std::vector stripParams; - std::vector devPathPrefixes; - std::vector preserveQueryPrefixes; -}; -static std::mutex g_canonConfigMutex; -static std::shared_ptr g_canonConfig; - -static std::shared_ptr CurrentCanonicalizationConfig() { - std::lock_guard lock(g_canonConfigMutex); - return g_canonConfig; -} - -static void SetCanonicalizationConfig(CanonicalizationConfig config) { - auto snapshot = std::make_shared(std::move(config)); - { - std::lock_guard lock(g_canonConfigMutex); - g_canonConfig = snapshot; - } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE( - "[ns:module configureLoader] canonicalization set (strip=%lu devPrefixes=%lu " - "preserve=%lu)", - (unsigned long)snapshot->stripParams.size(), - (unsigned long)snapshot->devPathPrefixes.size(), - (unsigned long)snapshot->preserveQueryPrefixes.size()); - } -} - -static void ResetCanonicalizationConfig() { - std::lock_guard lock(g_canonConfigMutex); - g_canonConfig.reset(); -} +// Canonical module keys std::string CanonicalizeHttpUrlKey(const std::string& url) { std::string normalizedUrl = url; @@ -288,7 +254,7 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) { std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos); std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1); - auto canon = CurrentCanonicalizationConfig(); + const CanonicalizationConfig* canon = CanonicalizationConfigForCurrentIsolate(); { std::string pathOnly = originAndPath.substr(pathStart); if (canon) { @@ -356,6 +322,11 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) { // ───────────────────────────────────────────────────────────── // Eviction-driven fetch cache-bust +// +// Process-global because it belongs to the transport, not to any isolate: the +// HTTP cache layers it defeats are shared by the whole process. The set is +// keyed by canonical keys the callers compute on their own isolate's thread +// and pass in by value, so nothing here canonicalizes. static std::mutex g_bustNextFetchMutex; static robin_hood::unordered_set g_bustNextFetchKeys; @@ -370,16 +341,16 @@ void MarkUrlsForCacheBust(const std::vector& urls) { } } -static bool IsUrlMarkedForCacheBust(const std::string& url) { +static bool IsUrlMarkedForCacheBust(const std::string& canonicalKey) { std::lock_guard lock(g_bustNextFetchMutex); if (g_bustNextFetchKeys.empty()) return false; - return g_bustNextFetchKeys.find(CanonicalizeHttpUrlKey(url)) != g_bustNextFetchKeys.end(); + return g_bustNextFetchKeys.find(canonicalKey) != g_bustNextFetchKeys.end(); } -static void ClearCacheBustForUrl(const std::string& url) { +static void ClearCacheBustForUrl(const std::string& canonicalKey) { std::lock_guard lock(g_bustNextFetchMutex); if (g_bustNextFetchKeys.empty()) return; - g_bustNextFetchKeys.erase(CanonicalizeHttpUrlKey(url)); + g_bustNextFetchKeys.erase(canonicalKey); } static void ClearAllCacheBustMarks() { @@ -445,14 +416,15 @@ static bool DrainPendingJniException(JEnv& env, std::string& outClassName, std:: return true; } -static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, - std::string& contentType, int& status); +static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& canonicalKey, + std::string& out, std::string& contentType, int& status); static void MaybePumpJSThreadDuringBoot(); static inline void InvokeHttpFetchYield(); -static std::string ApplyCacheBustNonce(const std::string& url, bool* outBustRequested) { +static std::string ApplyCacheBustNonce(const std::string& url, const std::string& canonicalKey, + bool* outBustRequested) { std::string fetchUrl = url; - const bool bustRequested = IsUrlMarkedForCacheBust(url); + const bool bustRequested = IsUrlMarkedForCacheBust(canonicalKey); if (outBustRequested) *outBustRequested = bustRequested; if (bustRequested) { static std::atomic s_fetchSeq{0}; @@ -533,13 +505,18 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten const auto netStart = urlLogEnabled ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{}; - bool ok = PerformHttpFetchOnceSync(url, out, contentType, status); + // Canonicalize here, on the caller's isolate thread: the vocabulary the + // key depends on belongs to that isolate, and the transport below must + // never reach for it. + const std::string canonicalKey = CanonicalizeHttpUrlKey(url); + + bool ok = PerformHttpFetchOnceSync(url, canonicalKey, out, contentType, status); if (!ok) { if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE_FORCE("[http-loader] retrying %s after initial fetch error", url.c_str()); } usleep(120 * 1000); - ok = PerformHttpFetchOnceSync(url, out, contentType, status); + ok = PerformHttpFetchOnceSync(url, canonicalKey, out, contentType, status); } if (!ok || status < 200 || status >= 300) { return false; @@ -569,8 +546,11 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten return true; } -static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, - std::string& contentType, int& status) { +// Runs on whichever thread drives the fetch — the JS thread for the sync path, +// a detached background thread for the async one. `canonicalKey` is computed +// by the caller on its isolate's thread; nothing here may canonicalize. +static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& canonicalKey, + std::string& out, std::string& contentType, int& status) { out.clear(); contentType.clear(); status = 0; @@ -579,7 +559,7 @@ static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, } bool bustRequested = false; - const std::string fetchUrl = ApplyCacheBustNonce(url, &bustRequested); + const std::string fetchUrl = ApplyCacheBustNonce(url, canonicalKey, &bustRequested); try { JEnv env; @@ -755,7 +735,7 @@ static bool PerformHttpFetchOnceSync(const std::string& url, std::string& out, return false; } if (status >= 200 && status < 300 && bustRequested) { - ClearCacheBustForUrl(url); + ClearCacheBustForUrl(canonicalKey); } return status >= 200 && status < 300; } catch (NativeScriptException& nse) { @@ -798,7 +778,11 @@ void FetchModuleBodyAsync(const std::string& url, return; } - std::thread([url, completion = std::move(completion)]() mutable { + // Canonicalize before the hop: the vocabulary belongs to the calling + // isolate, and the fetch thread below has no isolate to read it from. + const std::string canonicalKey = CanonicalizeHttpUrlKey(url); + + std::thread([url, canonicalKey, completion = std::move(completion)]() mutable { JavaVM* jvm = Runtime::GetJVM(); bool attachedHere = false; if (jvm != nullptr) { @@ -823,14 +807,14 @@ void FetchModuleBodyAsync(const std::string& url, std::string contentType; int status = 0; const auto start = std::chrono::steady_clock::now(); - bool ok = PerformHttpFetchOnceSync(url, out, contentType, status); + bool ok = PerformHttpFetchOnceSync(url, canonicalKey, out, contentType, status); if (!ok) { if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE_FORCE("[http-loader][fetch-async] retrying %s after transport error", url.c_str()); } usleep(120 * 1000); - ok = PerformHttpFetchOnceSync(url, out, contentType, status); + ok = PerformHttpFetchOnceSync(url, canonicalKey, out, contentType, status); } ok = ok && status >= 200 && status < 300; if (ok && out.empty()) { @@ -876,7 +860,6 @@ static inline void InvokeHttpFetchYield() { void CleanupHttpLoaderGlobals() { ClearAllCacheBustMarks(); g_devSessionBootComplete.store(false, std::memory_order_relaxed); - ResetCanonicalizationConfig(); } // ───────────────────────────────────────────────────────────── diff --git a/test-app/runtime/src/main/cpp/HttpLoader.h b/test-app/runtime/src/main/cpp/HttpLoader.h index f1a22ae65..562555001 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.h +++ b/test-app/runtime/src/main/cpp/HttpLoader.h @@ -38,6 +38,25 @@ namespace tns { // ───────────────────────────────────────────────────────────── // HTTP loader helpers (used by dev/HMR and general-purpose HTTP module loading) // +// The canonical-key *mechanism* (fragment strip, cache-buster param drop, +// param sort) must be native because it keys the module registry inside V8's +// synchronous resolve walk. The *vocabulary* — which query params are pure +// cache busters, which path prefixes identify dev endpoints whose queries may +// be normalized, and which paths must keep their query verbatim because the +// query IS the identity — is server/framework policy, supplied by the dev +// client via ns:module `configureLoader({ canonicalization: {...} })`. It is +// per-isolate loader vocabulary — installed through SetCanonicalizationConfig +// in ModuleInternalCallbacks.h — so CanonicalizeHttpUrlKey runs on the +// isolate's own thread only. The transport never canonicalizes; it carries +// keys computed for it. +// +// When unconfigured, canonicalization is purely mechanical (fragment strip). +struct CanonicalizationConfig { + std::vector stripParams; // query param names to drop + std::vector devPathPrefixes; // StartsWith → normalize query + std::vector preserveQueryPrefixes; // contains → keep query +}; + // Normalize an HTTP(S) URL into a stable module registry/cache key. // - Always strips URL fragments. // - For NativeScript dev endpoints, drops known cache busters (t/v/import) @@ -109,11 +128,10 @@ void MarkUrlsForCacheBust(const std::vector& urls); void SetDevBootComplete(v8::Isolate* isolate, v8::Local context, bool value); -// Clear process-wide HTTP-loader state (cache-bust marks, boot-complete -// flag, canonicalization vocabulary). MUST be called inside -// Runtime::DestroyRuntime() before isolate disposal — and only for the MAIN -// isolate (worker teardown must not wipe shared state the main isolate -// still uses). +// Clear the transport's process-wide state (cache-bust marks, boot-complete +// flag). MUST be called inside Runtime::DestroyRuntime() before isolate +// disposal — and only for the MAIN isolate (worker teardown must not wipe +// shared state the main isolate still uses). void CleanupHttpLoaderGlobals(); // ───────────────────────────────────────────────────────────── diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index f2c5e82c3..ee3cd22c8 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -699,7 +699,11 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p } // 3) Register for resolution callback - auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto* registryPtr = ModuleRegistryFor(isolate); + if (registryPtr == nullptr) { + return Local(); + } + auto& g_moduleRegistry = *registryPtr; auto it = g_moduleRegistry.find(path); if (it != g_moduleRegistry.end()) { it->second.Reset(); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 6071957f7..4db681854 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -27,6 +27,7 @@ #include "NativeScriptException.h" #include "NsBuiltinModules.h" #include "Runtime.h" +#include "RuntimeState.h" #include "Util.h" #include "robin_hood.h" @@ -229,7 +230,95 @@ static std::string ExtractRelativePath(const std::string& path); static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, v8::Local context, const std::string& registryKey); -static bool IsVolatileUrl(const std::string& url); + +namespace { +struct AsyncGraphLoad; + +// Everything the dev client teaches one isolate's loader (see the header's +// long-form note): the import map, the canonicalization vocabulary and the +// volatile-URL patterns. +struct LoaderVocabulary { + // Bare specifier → resolved URL. Instead of rewriting import statements on + // the bundler side, the runtime resolves bare specifiers through this map to + // HTTP module URLs; source code is served as-is. + robin_hood::unordered_map importMap; + + // URLs matching any of these substrings are always re-fetched (the cache is + // evicted before loading). The vocabulary is server/framework policy, so the + // runtime carries no framework-specific URL strings of its own. + std::vector volatilePatterns; + + CanonicalizationConfig canonicalization; + // Distinguishes "no vocabulary supplied" (mechanical canonicalization only) + // from "supplied, and empty" — an empty vocabulary is explicit policy. + bool canonicalizationConfigured = false; +}; + +// ───────────────────────────────────────────────────────────── +// Per-isolate module-loader state +// +// Why per-isolate (not process-global, not thread_local): v8::Global +// handles are bound to the isolate that created them; reading their internal +// state from a different isolate is undefined behaviour. NS Workers each run +// a separate v8::Isolate on their own thread and, under HMR, may fetch the +// same URLs the main thread already loaded — a shared map would hand the +// worker isolate a Module the main isolate compiled, and V8's linker would +// read the cross-isolate export table and emit bogus errors like: +// SyntaxError: The requested module 'X' does not provide an export named 'Y' +// +// Lifetime: the state lives in a RuntimeState slot, so it is destroyed with +// the runtime (Runtime::DestroyRuntime → RuntimeState::Clear), on the +// runtime's own thread while the isolate is still alive — which lets the +// v8::Global members Reset safely in their own destructors and leaves nothing +// to static/thread destructors, where a post-disposal Reset would crash. +// Access from the isolate's own thread only, per the slot contract. +struct ModuleLoaderState { + ModuleHandleMap registry; // canonical key -> compiled module + ModuleHandleMap fallbackRegistry; // canonical key -> last good module + ModuleHandleMap fallbackByRelative; // relative path -> last good module + + // What the dev client taught THIS isolate's loader: import map, + // canonicalization vocabulary, volatile patterns. + LoaderVocabulary vocabulary; + + // In-flight async graph walks; entries are weak so a finished load frees + // itself. A pending background fetch completion can hold a load's + // shared_ptr past teardown, so QuiesceModuleLoadsForIsolate must flag these + // dead and Reset their context Globals while the isolate is still alive — + // the slot destructor alone is not enough for them. + std::vector> asyncGraphLoads; + + // Active resolution stack, used to detect and short-circuit self-recursive + // module loads, plus the re-entry bookkeeping keyed by registry key. + std::vector resolutionStack; + robin_hood::unordered_map reentryCounts; + robin_hood::unordered_map> + reentryParents; + robin_hood::unordered_map primaryImporters; + robin_hood::unordered_set modulesInFlight; + robin_hood::unordered_set modulesPendingReset; + + // Waiters: registry key -> Promise resolvers settled when the module + // finishes (instantiated/evaluated) or errors. + robin_hood::unordered_map>> + moduleWaiters; + // Dynamic HTTP import waiters: resolve to the module namespace. + robin_hood::unordered_map>> + httpDynamicWaiters; +}; + +// This isolate's loader state, or null once teardown has begun — callers must +// bail, not recreate state. +ModuleLoaderState* ModuleLoaderStateFor(v8::Isolate* isolate) { + if (isolate == nullptr) return nullptr; + return RuntimeState::For(isolate); +} +} // namespace + +static bool IsVolatileUrl(const LoaderVocabulary& vocabulary, + const std::string& url); // ───────────────────────────────────────────────────────────── // AdoptThenable @@ -306,7 +395,11 @@ static v8::MaybeLocal CompileModuleForResolveRegisterOnly( v8::Isolate* isolate, v8::Local context, const std::string& code, const std::string& urlStr) { v8::EscapableHandleScope hs(isolate); - auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) { + return v8::MaybeLocal(); + } + auto& g_moduleRegistry = moduleState->registry; const std::string registryKey = CanonicalizeRegistryKey(urlStr); if (IsScriptLoadingLogEnabled() && ShouldTraceRegistryKey(urlStr, registryKey)) { DEBUG_WRITE("[resolver][register-resolve-only] raw=%s key=%s", @@ -405,99 +498,28 @@ static v8::MaybeLocal CompileModuleForResolveRegisterOnly( return hs.Escape(mod); } -// ───────────────────────────────────────────────────────────── -// Per-isolate module registries -// -// Why per-isolate (not process-global, not thread_local): v8::Global -// handles are bound to the isolate that created them; reading their internal -// state from a different isolate is undefined behaviour. NS Workers each run -// a separate v8::Isolate on their own thread and, under HMR, may fetch the -// same URLs the main thread already loaded — a shared map would hand the -// worker isolate a Module the main isolate compiled, and V8's linker would -// read the cross-isolate export table and emit bogus errors like: -// SyntaxError: The requested module 'X' does not provide an export named 'Y' -// Keying by v8::Isolate* stays correct even if an isolate is ever entered -// from another thread under v8::Locker. -// -// Lifetime: the per-isolate state is created lazily on first access and torn -// down by DestroyModuleStateForIsolate(), which the Runtime destructor -// should call while the isolate is still alive (before disposal) — so every -// v8::Global is Reset() at a safe time. - -namespace { -struct PerIsolateModuleState { - ModuleHandleMap registry; // canonical key -> compiled module - ModuleHandleMap fallbackRegistry; // canonical key -> last good module - ModuleHandleMap fallbackByRelative; // relative path -> last good module -}; - -std::mutex& ModuleStateTableMutex() { - static std::mutex* mutex = new std::mutex(); - return *mutex; +// Each access site binds a local reference (e.g. +// `auto& g_moduleRegistry = moduleState->registry;`) so the bodies below read +// as though the maps were plain globals. Accessors return null once teardown +// has begun. +ModuleHandleMap* ModuleRegistryFor(v8::Isolate* isolate) { + auto* state = ModuleLoaderStateFor(isolate); + return state == nullptr ? nullptr : &state->registry; } -robin_hood::unordered_map>& -ModuleStateTable() { - static auto* table = new robin_hood::unordered_map< - v8::Isolate*, std::unique_ptr>(); - return *table; -} - -PerIsolateModuleState& ModuleStateFor(v8::Isolate* isolate) { - std::lock_guard lock(ModuleStateTableMutex()); - auto& table = ModuleStateTable(); - auto it = table.find(isolate); - if (it == table.end()) { - it = table.emplace(isolate, std::make_unique()).first; - } - return *it->second; -} -} // namespace - -ModuleHandleMap& ModuleRegistryFor(v8::Isolate* isolate) { - return ModuleStateFor(isolate).registry; -} - -static ModuleHandleMap& ModuleFallbackRegistryFor(v8::Isolate* isolate) { - return ModuleStateFor(isolate).fallbackRegistry; -} - -static ModuleHandleMap& ModuleFallbackByRelativeFor(v8::Isolate* isolate) { - return ModuleStateFor(isolate).fallbackByRelative; -} - -void DestroyModuleStateForIsolate(v8::Isolate* isolate) { - // First: neutralize any in-flight async graph loads for this isolate. Their - // fetch completions check the dead flag before touching V8, and their - // context Globals are Reset here while the isolate is still alive. +// Neutralize any in-flight async graph loads for `isolate`: their fetch +// completions check the dead flag before touching V8, and their context +// Globals are Reset here, while the isolate is still alive. The rest of the +// loader state is destroyed with the isolate's RuntimeState. +void QuiesceModuleLoadsForIsolate(v8::Isolate* isolate) { KillAsyncGraphLoadsForIsolate(isolate); - - std::unique_ptr state; - { - std::lock_guard lock(ModuleStateTableMutex()); - auto& table = ModuleStateTable(); - auto it = table.find(isolate); - if (it == table.end()) return; - state = std::move(it->second); - table.erase(it); - } - for (auto& kv : state->registry) kv.second.Reset(); - for (auto& kv : state->fallbackRegistry) kv.second.Reset(); - for (auto& kv : state->fallbackByRelative) kv.second.Reset(); } -// ───────────────────────────────────────────────────────────── -// Import map: bare specifier → resolved URL (populated by ns:module -// configureLoader). Instead of rewriting import statements on the bundler -// side, the runtime resolves bare specifiers through this map to HTTP module -// URLs. Source code is served as-is. -static robin_hood::unordered_map g_importMap; - -// Volatile URL patterns: URLs matching these substrings are always re-fetched -// (cache is evicted before loading). Configured at boot by the dev client — -// the vocabulary is server/framework policy, so the runtime carries no -// framework-specific URL strings here. -static std::vector g_volatilePatterns; +// The calling isolate's vocabulary, or null once teardown has begun. +static LoaderVocabulary* VocabularyForCurrentIsolate() { + auto* state = ModuleLoaderStateFor(v8::Isolate::TryGetCurrent()); + return state != nullptr ? &state->vocabulary : nullptr; +} static bool ShouldTraceRegistryKey(const std::string& rawKey, const std::string& registryKey) { @@ -551,7 +573,11 @@ static std::string CanonicalizeRegistryKey(const std::string& key) { v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, v8::Local context, const std::string& requestedUrl) { - auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) { + return v8::MaybeLocal(); + } + auto& g_moduleRegistry = moduleState->registry; const std::string registryKey = CanonicalizeHttpUrlKey(requestedUrl); if (IsScriptLoadingLogEnabled()) { @@ -751,6 +777,9 @@ struct JsonScanner { } // namespace void SetImportMap(const std::string& json) { + LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); + if (vocabulary == nullptr) return; + auto& g_importMap = vocabulary->importMap; g_importMap.clear(); if (json.empty()) return; @@ -802,15 +831,41 @@ void SetImportMap(const std::string& json) { } void SetVolatilePatterns(const std::vector& patterns) { - g_volatilePatterns = patterns; + LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); + if (vocabulary == nullptr) return; + vocabulary->volatilePatterns = patterns; if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[import-map] volatile patterns: %lu", - (unsigned long)g_volatilePatterns.size()); + (unsigned long)vocabulary->volatilePatterns.size()); + } +} + +const CanonicalizationConfig* CanonicalizationConfigForCurrentIsolate() { + const LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); + if (vocabulary == nullptr || !vocabulary->canonicalizationConfigured) { + return nullptr; } + return &vocabulary->canonicalization; } -static bool IsVolatileUrl(const std::string& url) { - for (const auto& pat : g_volatilePatterns) { +void SetCanonicalizationConfig(CanonicalizationConfig config) { + LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); + if (vocabulary == nullptr) return; + vocabulary->canonicalization = std::move(config); + vocabulary->canonicalizationConfigured = true; + if (IsScriptLoadingLogEnabled()) { + DEBUG_WRITE( + "[ns:module configureLoader] canonicalization set (strip=%lu " + "devPrefixes=%lu preserve=%lu)", + (unsigned long)vocabulary->canonicalization.stripParams.size(), + (unsigned long)vocabulary->canonicalization.devPathPrefixes.size(), + (unsigned long)vocabulary->canonicalization.preserveQueryPrefixes.size()); + } +} + +static bool IsVolatileUrl(const LoaderVocabulary& vocabulary, + const std::string& url) { + for (const auto& pat : vocabulary.volatilePatterns) { if (url.find(pat) != std::string::npos) return true; } return false; @@ -948,7 +1003,9 @@ static std::string NormalizeViteSpecifier(const std::string& specifier) { // Look up a specifier in the import map. Supports exact and prefix matches // (trailing-slash entries like "solid-js/" that map subpaths). -static std::string LookupImportMap(const std::string& specifier) { +static std::string LookupImportMap(const LoaderVocabulary& vocabulary, + const std::string& specifier) { + const auto& g_importMap = vocabulary.importMap; auto it = g_importMap.find(specifier); if (it != g_importMap.end()) { if (IsScriptLoadingLogEnabled()) { @@ -982,15 +1039,6 @@ static std::string LookupImportMap(const std::string& specifier) { return ""; } -void CleanupImportMapGlobals() { - // Process-global import-map state (not isolate-bound). The per-isolate - // module handle maps (registry / fallback / fallbackByRelative) are torn - // down separately by DestroyModuleStateForIsolate(), which the Runtime - // destructor invokes for every isolate before disposal. - g_importMap.clear(); - g_volatilePatterns.clear(); -} - // ───────────────────────────────────────────────────────────── // Worker isolate detection: iOS keys off Caches::Get(isolate)->isWorker. // Android encodes the same signal by installing a WORKER_WRAPPER pointer in @@ -1058,23 +1106,15 @@ struct AsyncGraphLoad { } }; -std::mutex& AsyncGraphLoadsMutex() { - static std::mutex* mutex = new std::mutex(); - return *mutex; -} - -robin_hood::unordered_map>>& -AsyncGraphLoadsByIsolate() { - static auto* table = new robin_hood::unordered_map< - v8::Isolate*, std::vector>>(); - return *table; -} - +// Registration and quiesce both run on the isolate's thread (the slot +// contract); background fetch completions only ever touch the AsyncGraphLoad +// they retain, never this list, so no lock is needed. void RegisterAsyncGraphLoad(v8::Isolate* isolate, const std::shared_ptr& load) { - std::lock_guard lock(AsyncGraphLoadsMutex()); - auto& loads = AsyncGraphLoadsByIsolate()[isolate]; + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return; + auto& loads = state->asyncGraphLoads; + // Prune expired entries opportunistically so the vector stays small. loads.erase(std::remove_if(loads.begin(), loads.end(), [](const std::weak_ptr& w) { return w.expired(); @@ -1091,25 +1131,20 @@ bool HasPendingAsyncModuleGraphWork() { // Isolate-teardown hook: mark every in-flight load owned by `isolate` dead // (pending fetch completions become no-ops) and Reset their context Globals -// NOW, while the isolate is still alive. +// NOW, while the isolate is still alive — nothing may destroy a v8::Global +// after isolate disposal, and a pending background fetch completion can hold a +// load's shared_ptr past teardown, so the slot destructor alone cannot cover +// these. Called from QuiesceModuleLoadsForIsolate. static void KillAsyncGraphLoadsForIsolate(v8::Isolate* isolate) { - std::vector> doomed; - { - std::lock_guard lock(AsyncGraphLoadsMutex()); - auto& table = AsyncGraphLoadsByIsolate(); - auto it = table.find(isolate); - if (it == table.end()) return; - for (auto& weak : it->second) { - if (auto load = weak.lock()) { - doomed.push_back(std::move(load)); - } + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return; + for (auto& weak : state->asyncGraphLoads) { + if (auto load = weak.lock()) { + load->dead.store(true, std::memory_order_release); + load->context.Reset(); } - table.erase(it); - } - for (auto& load : doomed) { - load->dead.store(true, std::memory_order_release); - load->context.Reset(); } + state->asyncGraphLoads.clear(); } // Resolve one static module request to an absolute HTTP(S) URL using the @@ -1127,12 +1162,13 @@ static std::string ResolveModuleRequestForWalk(const std::string& rawSpec, spec.insert(6, "/"); } - if (!g_importMap.empty()) { - std::string mapped = LookupImportMap(spec); + const LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); + if (vocabulary != nullptr && !vocabulary->importMap.empty()) { + std::string mapped = LookupImportMap(*vocabulary, spec); if (mapped.empty()) { std::string normalized = NormalizeViteSpecifier(spec); if (!normalized.empty()) { - mapped = LookupImportMap(normalized); + mapped = LookupImportMap(*vocabulary, normalized); } } if (!mapped.empty()) spec = mapped; @@ -1267,7 +1303,9 @@ static void AsyncGraphEnqueueUrl(const std::shared_ptr& load, if (!load->visited.insert(key).second) return; v8::Isolate* isolate = load->isolate; - auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& g_moduleRegistry = moduleState->registry; auto it = g_moduleRegistry.find(key); if (it != g_moduleRegistry.end()) { v8::Local existing = it->second.Get(isolate); @@ -1405,10 +1443,11 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { // Only ever called on an isolate's own JS thread during module // resolution/loading, so the entered isolate owns the maps to mutate. v8::Isolate* isolate = v8::Isolate::GetCurrent(); - if (isolate == nullptr) return; - auto& g_moduleRegistry = ModuleRegistryFor(isolate); - auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate); - auto& g_moduleFallbackByRelative = ModuleFallbackByRelativeFor(isolate); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& g_moduleRegistry = moduleState->registry; + auto& g_moduleFallbackRegistry = moduleState->fallbackRegistry; + auto& g_moduleFallbackByRelative = moduleState->fallbackByRelative; const std::string registryKey = CanonicalizeRegistryKey(canonicalPath); // Defensive: never operate on an anomalous/sentinel key. @@ -1424,13 +1463,14 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { return; } - auto classify = [](const std::string& s) -> const char* { + const LoaderVocabulary& vocabulary = moduleState->vocabulary; + auto classify = [&vocabulary](const std::string& s) -> const char* { if (s == "@") return "sentinel:@"; if (s.find("__invalid_at__.mjs") != std::string::npos) return "sentinel:invalid_at"; bool http = StartsWith(s, "http://") || StartsWith(s, "https://"); if (http) { - if (IsVolatileUrl(s)) return "http:volatile"; + if (IsVolatileUrl(vocabulary, s)) return "http:volatile"; if (s.find("/@ns/sfc/") != std::string::npos) return "http:sfc"; if (s.find("/@ns/m/") != std::string::npos) return "http:m"; return "http:other"; @@ -1496,8 +1536,9 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { std::vector GetLoadedModuleUrls() { std::vector urls; v8::Isolate* isolate = v8::Isolate::GetCurrent(); - if (isolate == nullptr) return urls; - auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return urls; + auto& g_moduleRegistry = moduleState->registry; urls.reserve(g_moduleRegistry.size()); for (const auto& entry : g_moduleRegistry) { @@ -1514,7 +1555,9 @@ std::vector GetLoadedModuleUrls() { void InvalidateModules(v8::Isolate* isolate, v8::Local context, const std::vector& urls) { - auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& g_moduleRegistry = moduleState->registry; if (urls.empty()) return; robin_hood::unordered_set seen; @@ -1563,8 +1606,10 @@ void InvalidateModules(v8::Isolate* isolate, v8::Local context, void UpdateModuleFallback(v8::Isolate* isolate, const std::string& canonicalPath, v8::Local module) { - auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate); - auto& g_moduleFallbackByRelative = ModuleFallbackByRelativeFor(isolate); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& g_moduleFallbackRegistry = moduleState->fallbackRegistry; + auto& g_moduleFallbackByRelative = moduleState->fallbackByRelative; auto fallbackIt = g_moduleFallbackRegistry.find(canonicalPath); if (fallbackIt != g_moduleFallbackRegistry.end()) { fallbackIt->second.Reset(); @@ -1591,28 +1636,11 @@ void UpdateModuleFallback(v8::Isolate* isolate, } // ───────────────────────────────────────────────────────────── -// Thread-local resolver state +// Resolver state // -// Recursion detection + module in-flight/waiter tracking. Everything here is -// touched only from the isolate's own JS thread, so thread_local is safe. -static thread_local std::vector g_moduleResolutionStack; -static thread_local robin_hood::unordered_map g_moduleReentryCounts; -static thread_local robin_hood::unordered_map> - g_moduleReentryParents; -static thread_local robin_hood::unordered_map g_modulePrimaryImporters; -static thread_local robin_hood::unordered_set g_modulesInFlight; -static thread_local robin_hood::unordered_set g_modulesPendingReset; +// The resolution stack, re-entry bookkeeping and waiter lists live in +// ModuleLoaderState (per isolate, in a RuntimeState slot). static constexpr size_t kMaxModuleReentryCount = 256; -// Waiters: module registry key -> list of Promise resolvers waiting for -// completion (instantiated/evaluated or errored). -static robin_hood::unordered_map>> - g_moduleWaiters; -// Dynamic HTTP import waiters: resolve to module namespace when available. -static thread_local robin_hood::unordered_map< - std::string, std::vector>> - g_httpDynamicWaiters; static bool IsModuleEvaluationInProgress(v8::Module::Status status) { return status == v8::Module::kInstantiating || @@ -1665,12 +1693,15 @@ static bool QueueModuleWaiterIfInFlight(v8::Isolate* isolate, const std::string& registryKey, v8::Local module, v8::Local resolver) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return false; + auto& g_modulesInFlight = moduleState->modulesInFlight; if (registryKey.empty() || module.IsEmpty() || !IsModuleEvaluationInProgress(module->GetStatus()) || g_modulesInFlight.find(registryKey) == g_modulesInFlight.end()) { return false; } - g_moduleWaiters[registryKey].emplace_back(isolate, resolver); + moduleState->moduleWaiters[registryKey].emplace_back(isolate, resolver); if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[dyn-import][await] queued module waiter for %s status=%s", registryKey.c_str(), @@ -1682,12 +1713,15 @@ static bool QueueModuleWaiterIfInFlight(v8::Isolate* isolate, static bool QueueHttpDynamicWaiterIfInFlight( v8::Isolate* isolate, const std::string& registryKey, v8::Local module, v8::Local resolver) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return false; + auto& g_modulesInFlight = moduleState->modulesInFlight; if (registryKey.empty() || module.IsEmpty() || !IsModuleEvaluationInProgress(module->GetStatus()) || g_modulesInFlight.find(registryKey) == g_modulesInFlight.end()) { return false; } - g_httpDynamicWaiters[registryKey].emplace_back(isolate, resolver); + moduleState->httpDynamicWaiters[registryKey].emplace_back(isolate, resolver); if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[dyn-import][http-await] queued waiter for %s status=%s", registryKey.c_str(), @@ -1729,6 +1763,9 @@ static void ResolveModuleWaiters(v8::Isolate* isolate, v8::Local context, const std::string& registryKey, v8::Local module) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& g_moduleWaiters = moduleState->moduleWaiters; auto waitIt = g_moduleWaiters.find(registryKey); if (waitIt == g_moduleWaiters.end()) return; std::vector> resolvers; @@ -1742,6 +1779,9 @@ static void RejectModuleWaiters(v8::Isolate* isolate, v8::Local context, const std::string& registryKey, v8::Local reason) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& g_moduleWaiters = moduleState->moduleWaiters; auto waitIt = g_moduleWaiters.find(registryKey); if (waitIt == g_moduleWaiters.end()) return; std::vector> resolvers; @@ -1754,6 +1794,9 @@ static void ResolveHttpDynamicWaiters(v8::Isolate* isolate, v8::Local context, const std::string& registryKey, v8::Local module) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& g_httpDynamicWaiters = moduleState->httpDynamicWaiters; auto waitIt = g_httpDynamicWaiters.find(registryKey); if (waitIt != g_httpDynamicWaiters.end()) { std::vector> resolvers; @@ -1762,13 +1805,16 @@ static void ResolveHttpDynamicWaiters(v8::Isolate* isolate, ResolveResolversWithModuleNamespace(isolate, context, resolvers, module, registryKey); } - g_modulesInFlight.erase(registryKey); + moduleState->modulesInFlight.erase(registryKey); } static void RejectHttpDynamicWaiters(v8::Isolate* isolate, v8::Local context, const std::string& registryKey, v8::Local reason) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& g_httpDynamicWaiters = moduleState->httpDynamicWaiters; auto waitIt = g_httpDynamicWaiters.find(registryKey); if (waitIt != g_httpDynamicWaiters.end()) { std::vector> resolvers; @@ -1776,7 +1822,7 @@ static void RejectHttpDynamicWaiters(v8::Isolate* isolate, g_httpDynamicWaiters.erase(waitIt); RejectResolversWithReason(isolate, context, resolvers, reason); } - g_modulesInFlight.erase(registryKey); + moduleState->modulesInFlight.erase(registryKey); } static void RejectResolversForInvalidation( @@ -1799,11 +1845,15 @@ static void RejectResolversForInvalidation( static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, v8::Local context, const std::string& registryKey) { - g_moduleReentryCounts.erase(registryKey); - g_moduleReentryParents.erase(registryKey); - g_modulePrimaryImporters.erase(registryKey); - g_modulesInFlight.erase(registryKey); - g_modulesPendingReset.erase(registryKey); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& g_moduleWaiters = moduleState->moduleWaiters; + auto& g_httpDynamicWaiters = moduleState->httpDynamicWaiters; + moduleState->reentryCounts.erase(registryKey); + moduleState->reentryParents.erase(registryKey); + moduleState->primaryImporters.erase(registryKey); + moduleState->modulesInFlight.erase(registryKey); + moduleState->modulesPendingReset.erase(registryKey); auto waitIt = g_moduleWaiters.find(registryKey); if (waitIt != g_moduleWaiters.end()) { @@ -1828,19 +1878,23 @@ static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, namespace { struct ResolutionStackGuard { - ResolutionStackGuard(v8::Isolate* isolate, std::vector& stack, + ResolutionStackGuard(v8::Isolate* isolate, ModuleLoaderState& state, const std::string& entry) - : isolate_(isolate), stack_(stack), entry_(entry), active_(true) { + : isolate_(isolate), + state_(state), + stack_(state.resolutionStack), + entry_(entry), + active_(true) { stack_.push_back(entry_); - g_moduleReentryCounts[entry_] = 0; - g_moduleReentryParents.erase(entry_); + state_.reentryCounts[entry_] = 0; + state_.reentryParents.erase(entry_); if (stack_.size() > 1) { - g_modulePrimaryImporters[entry_] = stack_[stack_.size() - 2]; + state_.primaryImporters[entry_] = stack_[stack_.size() - 2]; } else { - g_modulePrimaryImporters.erase(entry_); + state_.primaryImporters.erase(entry_); } - g_modulesInFlight.insert(entry_); - g_modulesPendingReset.erase(entry_); + state_.modulesInFlight.insert(entry_); + state_.modulesPendingReset.erase(entry_); if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[resolver][stack] push (%lu) %s", static_cast(stack_.size()), entry_.c_str()); @@ -1849,16 +1903,18 @@ struct ResolutionStackGuard { ~ResolutionStackGuard() { if (!active_ || stack_.empty()) return; - auto& g_moduleRegistry = ModuleRegistryFor(isolate_); - auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate_); + auto& g_moduleRegistry = state_.registry; + auto& g_moduleFallbackRegistry = state_.fallbackRegistry; + auto& g_moduleWaiters = state_.moduleWaiters; + auto& g_modulesPendingReset = state_.modulesPendingReset; if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[resolver][stack] pop (%lu) %s", static_cast(stack_.size()), entry_.c_str()); } - g_moduleReentryCounts.erase(entry_); - g_moduleReentryParents.erase(entry_); - g_modulePrimaryImporters.erase(entry_); - g_modulesInFlight.erase(entry_); + state_.reentryCounts.erase(entry_); + state_.reentryParents.erase(entry_); + state_.primaryImporters.erase(entry_); + state_.modulesInFlight.erase(entry_); v8::Module::Status finalStatus = v8::Module::kErrored; auto regIt = g_moduleRegistry.find(entry_); @@ -1919,6 +1975,7 @@ struct ResolutionStackGuard { private: v8::Isolate* isolate_; + ModuleLoaderState& state_; std::vector& stack_; std::string entry_; bool active_; @@ -1934,7 +1991,11 @@ static v8::MaybeLocal CompileJsonAsEsModule( v8::Isolate* isolate, v8::Local context, const std::string& absPath, const std::string& registryAbsPath, bool isWorker) { - auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) { + return v8::MaybeLocal(); + } + auto& g_moduleRegistry = moduleState->registry; if (isWorker && IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[resolver] Worker handling JSON module '%s'", absPath.c_str()); } @@ -2090,8 +2151,13 @@ v8::MaybeLocal ResolveModuleCallback( v8::Local /*import_assertions*/, v8::Local referrer) { v8::Isolate* isolate = v8::Isolate::GetCurrent(); - auto& g_moduleRegistry = ModuleRegistryFor(isolate); - auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) { + return v8::MaybeLocal(); + } + auto& g_moduleRegistry = moduleState->registry; + auto& g_moduleFallbackRegistry = moduleState->fallbackRegistry; + auto& g_moduleResolutionStack = moduleState->resolutionStack; v8::String::Utf8Value specUtf8(isolate, specifier); const std::string rawSpec = *specUtf8 ? *specUtf8 : ""; @@ -2135,12 +2201,13 @@ v8::MaybeLocal ResolveModuleCallback( } // Import map resolution (bare specifiers → resolved URLs). - if (!g_importMap.empty()) { - std::string mapped = LookupImportMap(normalizedSpec); + const LoaderVocabulary& vocabulary = moduleState->vocabulary; + if (!vocabulary.importMap.empty()) { + std::string mapped = LookupImportMap(vocabulary, normalizedSpec); if (mapped.empty()) { std::string normalized = NormalizeViteSpecifier(normalizedSpec); if (!normalized.empty()) { - mapped = LookupImportMap(normalized); + mapped = LookupImportMap(vocabulary, normalized); if (!mapped.empty() && IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[resolver][import-map] normalized: %s -> %s -> %s", normalizedSpec.c_str(), normalized.c_str(), @@ -2162,7 +2229,7 @@ v8::MaybeLocal ResolveModuleCallback( if (looksBare && IsScriptLoadingLogEnabled()) { DEBUG_WRITE( "[resolver][import-map][miss] bare='%s' importMap.size=%lu", - normalizedSpec.c_str(), (unsigned long)g_importMap.size()); + normalizedSpec.c_str(), (unsigned long)vocabulary.importMap.size()); } } } @@ -2486,8 +2553,7 @@ v8::MaybeLocal ResolveModuleCallback( return v8::MaybeLocal(); } - ResolutionStackGuard stackGuard(isolate, g_moduleResolutionStack, - registryAbsPath); + ResolutionStackGuard stackGuard(isolate, *moduleState, registryAbsPath); if (IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[resolver] -> LoadESModule %s", absPath.c_str()); } @@ -2521,8 +2587,9 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, const std::string& key, const std::string& requestUrl) { if (IsScriptLoadingLogEnabled()) { - auto& g_moduleRegistry = ModuleRegistryFor(isolate); - if (g_moduleRegistry.find(key) == g_moduleRegistry.end()) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState != nullptr && + moduleState->registry.find(key) == moduleState->registry.end()) { DEBUG_WRITE("[async-graph][fallback-sync-load] root missed walk: %s", key.c_str()); } @@ -2654,7 +2721,13 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( v8::Local resource_name, v8::Local specifier, v8::Local import_assertions) { v8::Isolate* isolate = v8::Isolate::GetCurrent(); - auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) { + return v8::MaybeLocal(); + } + auto& g_moduleRegistry = moduleState->registry; + auto& g_modulesInFlight = moduleState->modulesInFlight; + auto& g_httpDynamicWaiters = moduleState->httpDynamicWaiters; v8::String::Utf8Value specUtf8(isolate, specifier); const char* cSpec = (*specUtf8) ? *specUtf8 : ""; @@ -2725,12 +2798,14 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } // ── Import map resolution for dynamic import() ── - if (!g_importMap.empty() && !normalizedSpec.empty() && normalizedSpec != "@") { - std::string mapped = LookupImportMap(normalizedSpec); + const LoaderVocabulary& vocabulary = moduleState->vocabulary; + if (!vocabulary.importMap.empty() && !normalizedSpec.empty() && + normalizedSpec != "@") { + std::string mapped = LookupImportMap(vocabulary, normalizedSpec); if (mapped.empty()) { std::string normalized = NormalizeViteSpecifier(normalizedSpec); if (!normalized.empty()) { - mapped = LookupImportMap(normalized); + mapped = LookupImportMap(vocabulary, normalized); if (!mapped.empty() && IsScriptLoadingLogEnabled()) { DEBUG_WRITE("[dyn-import][import-map] normalized: %s -> %s -> %s", normalizedSpec.c_str(), normalized.c_str(), @@ -3183,7 +3258,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( // supplied exclusively by JS via ns:module `configureLoader({ // volatilePatterns })` — the runtime carries no framework or server URL // vocabulary of its own. - bool isVolatile = IsVolatileUrl(normalizedSpec); + bool isVolatile = IsVolatileUrl(vocabulary, normalizedSpec); if (isVolatile) { auto ex = g_moduleRegistry.find(key); if (ex != g_moduleRegistry.end()) { @@ -3604,7 +3679,9 @@ void InitializeImportMetaObject(v8::Local context, v8::Local module, v8::Local meta) { v8::Isolate* isolate = v8::Isolate::GetCurrent(); - auto& g_moduleRegistry = ModuleRegistryFor(isolate); + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + auto& g_moduleRegistry = moduleState->registry; std::string modulePath; for (auto& kv : g_moduleRegistry) { diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index 6e447f9bc..8a61d93ea 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -6,6 +6,7 @@ #include #include +#include "HttpLoader.h" #include "robin_hood.h" namespace tns { @@ -19,14 +20,16 @@ using ModuleHandleMap = // v8::Module handles for `isolate`. Keyed by v8::Isolate* (not thread) because // v8::Global handles are isolate-bound; see the long-form comment // above the definition in ModuleInternalCallbacks.cpp for the -// cross-isolate-handle bug this prevents. Callers bind a local alias, e.g. -// `auto& g_moduleRegistry = tns::ModuleRegistryFor(isolate);`. -ModuleHandleMap& ModuleRegistryFor(v8::Isolate* isolate); +// cross-isolate-handle bug this prevents. The map lives in a RuntimeState +// slot, so this returns null once the isolate's teardown has begun — callers +// must bail. +ModuleHandleMap* ModuleRegistryFor(v8::Isolate* isolate); -// Reset + drop every module handle owned by `isolate`. Must be called while -// the isolate is still alive (the Runtime destructor should call this before -// disposal). -void DestroyModuleStateForIsolate(v8::Isolate* isolate); +// Mark every in-flight async graph load owned by `isolate` dead and Reset +// their context Globals. Must be called while the isolate is still alive (the +// Runtime destructor calls this before disposal); the rest of the loader state +// is destroyed with the isolate's RuntimeState. +void QuiesceModuleLoadsForIsolate(v8::Isolate* isolate); // Utility to drop modules from the registry when compilation/instantiation // fails. Operates on the *current* isolate's maps (resolved internally); only @@ -115,16 +118,31 @@ void InitializeImportMetaObject(v8::Local context, v8::Local module, v8::Local meta); -// Import map support. -// Parse and store an import map from JSON. Expected shape: -// {"imports": {"key": "value", ...}} +// ── The loader vocabulary ───────────────────────────────────── +// +// Everything the dev client teaches one isolate's module loader: which bare +// specifiers resolve where, how URLs are keyed, and which URLs are never +// cached. Per-isolate, not process-wide — it lives in the isolate's loader +// state and dies with the isolate, so each isolate only ever reads and writes +// its own and nothing here needs synchronization. All of it must be set from +// the isolate's own thread. + +// Parse and store an import map from JSON on the calling isolate. Expected +// shape: {"imports": {"key": "value", ...}} void SetImportMap(const std::string& json); -// Set URL patterns that should bypass module cache (e.g. "/@ns/sfc/", "?v="). +// Set URL patterns that should bypass module cache (e.g. "/@ns/sfc/", "?v=") +// on the calling isolate. void SetVolatilePatterns(const std::vector& patterns); -// Clear import map state and vendor module cache. Must be called before -// isolate disposal. -void CleanupImportMapGlobals(); +// The calling isolate's canonicalization vocabulary, or null when it has none +// (the mechanical canonicalization applies). Isolate thread only — the +// transport never calls this, it carries canonical keys instead. +const CanonicalizationConfig* CanonicalizationConfigForCurrentIsolate(); + +// Install the client-supplied canonicalization vocabulary on the calling +// isolate. Its presence replaces the mechanical default entirely — empty +// vectors are honored as explicit policy. +void SetCanonicalizationConfig(CanonicalizationConfig config); } // namespace tns diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 60a217122..b366fde58 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -1069,15 +1069,17 @@ void Runtime::DestroyRuntime() { CallbackHandlers::RemoveIsolateEntries(m_isolate); FrameCallbacks::RemoveIsolateEntries(m_isolate); - // Drop this isolate's module registry (compiled modules, fallbacks, - // in-flight async graph loads) while the isolate is still alive. - tns::DestroyModuleStateForIsolate(m_isolate); - // Process-wide HTTP-loader / import-map state is shared across isolates; - // only the main isolate may clear it (worker teardown must not wipe the - // main isolate's session). + // Flag this isolate's in-flight async graph loads dead and Reset their + // context Globals while the isolate is still alive, so fetch completions + // still queued on background threads become no-ops. The rest of the loader + // state (registries, waiters, loader vocabulary) lives in a RuntimeState + // slot and is destroyed with it below. Worker isolates quiesce the same way. + tns::QuiesceModuleLoadsForIsolate(m_isolate); + // The transport's process-wide state (cache-bust marks, dev-boot flag) is + // shared across isolates; only the main isolate may clear it (worker + // teardown must not wipe the main isolate's session). if (m_isMainThread) { tns::CleanupHttpLoaderGlobals(); - tns::CleanupImportMapGlobals(); } // V8 does not run weak callbacks when an isolate is disposed, so anything From 781816a1e09a877385995da691a9152a1a338183 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 10:39:14 -0300 Subject: [PATCH 11/36] test: add an in-app HTTP module fixture server A dependency-free ServerSocket server in the test app, serving the module fixture routes the HTTP loader specs need (MIME-gate cases, JSON modules, graph leaves keyed by query, syntax errors, configurable delays), bound to an ephemeral 127.0.0.1 port and started lazily from JS via com.tns.tests.ModuleTestServer.ensureStarted(). Route set and response bodies mirror the iOS ModuleTestServer. --- .../java/com/tns/tests/ModuleTestServer.java | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java diff --git a/test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java b/test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java new file mode 100644 index 000000000..0d90eec58 --- /dev/null +++ b/test-app/app/src/main/java/com/tns/tests/ModuleTestServer.java @@ -0,0 +1,295 @@ +package com.tns.tests; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.ByteArrayOutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; + +/** + * Loopback HTTP/1.1 fixture server for the in-app Jasmine suite. Mirrors the + * module-serving routes of the iOS TestRunnerTests ModuleTestServer so the + * HTTP ESM loader specs can run identically on both platforms. + */ +public final class ModuleTestServer { + private static final String JS_MIME = "application/javascript; charset=utf-8"; + private static final Charset UTF8 = Charset.forName("UTF-8"); + + private static ServerSocket serverSocket; + private static Thread acceptThread; + private static int boundPort = -1; + private static final List workers = new ArrayList(); + + private ModuleTestServer() { + } + + /** + * Starts the server if it is not already running and returns the port it is + * bound to on 127.0.0.1. Safe to call from any thread, any number of times. + */ + public static synchronized int ensureStarted() { + if (serverSocket != null && !serverSocket.isClosed()) { + return boundPort; + } + try { + serverSocket = new ServerSocket(0, 64, InetAddress.getByName("127.0.0.1")); + } catch (IOException e) { + throw new RuntimeException("ModuleTestServer failed to bind", e); + } + boundPort = serverSocket.getLocalPort(); + + final ServerSocket listener = serverSocket; + acceptThread = new Thread(new Runnable() { + @Override + public void run() { + acceptLoop(listener); + } + }, "ModuleTestServer"); + acceptThread.setDaemon(true); + acceptThread.start(); + return boundPort; + } + + public static synchronized void stop() { + if (serverSocket != null) { + try { + serverSocket.close(); + } catch (IOException ignored) { + } + serverSocket = null; + } + synchronized (workers) { + for (Thread t : workers) { + t.interrupt(); + } + workers.clear(); + } + acceptThread = null; + boundPort = -1; + } + + public static synchronized int getPort() { + return boundPort; + } + + private static void acceptLoop(ServerSocket listener) { + while (!listener.isClosed()) { + final Socket socket; + try { + socket = listener.accept(); + } catch (IOException e) { + return; + } + // A thread per connection: the /esm/timeout.mjs route parks its own + // thread for delayMs, and the module-graph walk fetches concurrently. + Thread worker = new Thread(new Runnable() { + @Override + public void run() { + try { + handle(socket); + } catch (Throwable ignored) { + } finally { + try { + socket.close(); + } catch (IOException ignored) { + } + synchronized (workers) { + workers.remove(Thread.currentThread()); + } + } + } + }, "ModuleTestServer-conn"); + worker.setDaemon(true); + synchronized (workers) { + workers.add(worker); + } + worker.start(); + } + } + + private static void handle(Socket socket) throws IOException { + socket.setSoTimeout(30000); + socket.setTcpNoDelay(true); + + String head = readHead(socket.getInputStream()); + if (head == null) { + return; + } + int lineEnd = head.indexOf("\r\n"); + String requestLine = lineEnd < 0 ? head : head.substring(0, lineEnd); + String[] parts = requestLine.split(" "); + if (parts.length < 2) { + respond(socket, "400 Bad Request", null, new byte[0]); + return; + } + String method = parts[0]; + String target = parts[1]; + String path = target; + String query = ""; + int q = target.indexOf('?'); + if (q >= 0) { + path = target.substring(0, q); + query = target.substring(q + 1); + } + + if (!"GET".equals(method)) { + respondNotFound(socket); + return; + } + route(socket, path, query); + } + + private static void route(Socket socket, String path, String query) throws IOException { + if ("/esm/query.mjs".equals(path) || "/ns/m/query.mjs".equals(path)) { + String body = "export const path = \"" + jsStringLiteral(path) + "\";\n" + + "export const query = \"" + jsStringLiteral(query) + "\";\n" + + "export const evaluatedAt = " + System.currentTimeMillis() + ";\n" + + "export default { path, query, evaluatedAt };"; + respond(socket, "200 OK", JS_MIME, body.getBytes(UTF8)); + return; + } + + if ("/esm/html-fallback.mjs".equals(path)) { + // The SPA-fallback shape: an unknown path answered with the index + // document, 200 OK. The module loader must reject it on MIME rather + // than hand HTML to the JS parser. + String body = "\nindex\n"; + respond(socket, "200 OK", "text/html; charset=utf-8", body.getBytes(UTF8)); + return; + } + + if ("/esm/data.json".equals(path)) { + String body = "{\"kind\":\"json-module\",\"n\":41}"; + respond(socket, "200 OK", "application/json; charset=utf-8", body.getBytes(UTF8)); + return; + } + + if ("/esm/empty.mjs".equals(path)) { + respond(socket, "200 OK", JS_MIME, new byte[0]); + return; + } + + if ("/esm/no-mime.mjs".equals(path)) { + // Content-Type is omitted deliberately: this route exercises the + // loader's missing-MIME branch. + respond(socket, "200 OK", null, "export const ok = true;\n".getBytes(UTF8)); + return; + } + + if ("/esm/graph-leaf.mjs".equals(path)) { + // `k` gives each importer its own module identity (the query is part + // of the key when no canonicalization vocabulary is configured), so + // several specs share this one route. + String key = param(query, "k="); + if (key == null) { + key = "x"; + } + String body = "const bucket = \"__nsMixedOrder\" + \"" + key + "\";\n" + + "(globalThis[bucket] = globalThis[bucket] || []).push(\"leaf\");\n" + + "export const name = \"" + key + "\";"; + respond(socket, "200 OK", JS_MIME, body.getBytes(UTF8)); + return; + } + + if ("/esm/syntax-error.mjs".equals(path)) { + // Deliberately unparseable: pins that the loader surfaces V8's real + // compile error instead of a generic failure. + respond(socket, "200 OK", JS_MIME, "export const ok = ;\n".getBytes(UTF8)); + return; + } + + if ("/esm/timeout.mjs".equals(path)) { + int delayMs = 12000; + String raw = param(query, "delayMs="); + if (raw != null) { + try { + delayMs = Integer.parseInt(raw); + } catch (NumberFormatException ignored) { + } + } + try { + Thread.sleep(delayMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + String body = "export const evaluatedAt = " + System.currentTimeMillis() + + "; export default { evaluatedAt };"; + respond(socket, "200 OK", JS_MIME, body.getBytes(UTF8)); + return; + } + + respondNotFound(socket); + } + + private static void respondNotFound(Socket socket) throws IOException { + respond(socket, "404 Not Found", "text/plain; charset=utf-8", "Not Found".getBytes(UTF8)); + } + + /** Reads bytes up to and including the CRLFCRLF header terminator. */ + private static String readHead(InputStream in) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int matched = 0; + while (matched < 4) { + int b = in.read(); + if (b < 0) { + return null; + } + buffer.write(b); + char expected = (matched == 0 || matched == 2) ? '\r' : '\n'; + matched = (b == expected) ? matched + 1 : (b == '\r' ? 1 : 0); + if (buffer.size() > 64 * 1024) { + return null; + } + } + byte[] bytes = buffer.toByteArray(); + return new String(bytes, 0, bytes.length - 4, UTF8); + } + + /** + * Value of the first `&`-separated query component starting with `prefix`, + * undecoded. Unknown components (the loader appends cache-bust nonces) are + * ignored. + */ + private static String param(String query, String prefix) { + if (query == null || query.length() == 0) { + return null; + } + for (String pair : query.split("&")) { + if (pair.startsWith(prefix)) { + return pair.substring(prefix.length()); + } + } + return null; + } + + private static String jsStringLiteral(String s) { + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r"); + } + + private static void respond(Socket socket, String status, String contentType, byte[] body) + throws IOException { + StringBuilder headers = new StringBuilder(); + headers.append("HTTP/1.1 ").append(status).append("\r\n"); + if (contentType != null) { + headers.append("Content-Type: ").append(contentType).append("\r\n"); + } + headers.append("Content-Length: ").append(body.length).append("\r\n"); + // One request per connection: the client is HttpURLConnection, which + // would otherwise pool a socket this server never services again. + headers.append("Connection: close\r\n\r\n"); + + OutputStream out = socket.getOutputStream(); + out.write(headers.toString().getBytes(UTF8)); + out.write(body); + out.flush(); + } +} From f1250721433abe76e052920e64c8acf21866fdb1 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 10:46:48 -0300 Subject: [PATCH 12/36] perf(runtime): resolve module referrers through an identity-hash index Registry writes now maintain a GetIdentityHash() -> keys reverse index in the loader state, and the two module->key lookups (resolver referrer discovery, import.meta initialization) consult it instead of scanning the whole registry. Buckets hold candidates because hashes collide; lookups confirm by handle equality against the registry and prune candidates the registry no longer backs, so a stale entry can never answer. Writes outside the callbacks TU go through Unindex/IndexModuleForIsolate; unindexing happens before a Reset, while the outgoing handle's hash is still recoverable. --- .../runtime/src/main/cpp/ModuleInternal.cpp | 2 + .../src/main/cpp/ModuleInternalCallbacks.cpp | 113 +++++++++++++++--- .../src/main/cpp/ModuleInternalCallbacks.h | 15 +++ 3 files changed, 112 insertions(+), 18 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index ee3cd22c8..47ff16d92 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -704,11 +704,13 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p return Local(); } auto& g_moduleRegistry = *registryPtr; + UnindexModuleForIsolate(isolate, path); auto it = g_moduleRegistry.find(path); if (it != g_moduleRegistry.end()) { it->second.Reset(); } g_moduleRegistry[path].Reset(isolate, module); + IndexModuleForIsolate(isolate, path, module); } // 4) Instantiate (link) with ResolveModuleCallback diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 4db681854..79a743143 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -307,6 +307,15 @@ struct ModuleLoaderState { robin_hood::unordered_map>> httpDynamicWaiters; + + // Reverse index: v8::Module::GetIdentityHash() -> registry keys, so + // module→key lookups (resolver referrer discovery, import.meta) are O(1) + // instead of a scan of the whole registry. Hashes collide, so a bucket holds + // candidates; FindKeyForModule confirms each against the registry and prunes + // the ones it no longer backs, so a stale candidate can never answer a + // lookup. Covers `registry` only — the fallback maps are never looked up by + // handle. + robin_hood::unordered_map> keysByModuleHash; }; // This isolate's loader state, or null once teardown has begun — callers must @@ -315,8 +324,83 @@ ModuleLoaderState* ModuleLoaderStateFor(v8::Isolate* isolate) { if (isolate == nullptr) return nullptr; return RuntimeState::For(isolate); } + +// Record `key` as a candidate for `mod`'s identity hash. Call alongside every +// registry insert. +void IndexRegisteredModule(ModuleLoaderState& state, const std::string& key, + v8::Local mod) { + if (mod.IsEmpty()) return; + auto& keys = state.keysByModuleHash[mod->GetIdentityHash()]; + if (std::find(keys.begin(), keys.end(), key) == keys.end()) { + keys.push_back(key); + } +} + +// Drop `key` from the bucket of whatever module the registry holds under it +// right now. Call before replacing or erasing that entry, while the outgoing +// handle is still reachable — afterwards its hash is unrecoverable. +void UnindexRegistryKey(ModuleLoaderState& state, v8::Isolate* isolate, + const std::string& key) { + auto regIt = state.registry.find(key); + if (regIt == state.registry.end() || regIt->second.IsEmpty()) return; + v8::Local outgoing = regIt->second.Get(isolate); + if (outgoing.IsEmpty()) return; + auto bucketIt = state.keysByModuleHash.find(outgoing->GetIdentityHash()); + if (bucketIt == state.keysByModuleHash.end()) return; + auto& keys = bucketIt->second; + keys.erase(std::remove(keys.begin(), keys.end(), key), keys.end()); + if (keys.empty()) { + state.keysByModuleHash.erase(bucketIt); + } +} + +// The registry key whose live entry is `mod`, or empty. Prunes candidates the +// registry no longer confirms. +std::string FindKeyForModule(ModuleLoaderState& state, v8::Isolate* isolate, + v8::Local mod) { + if (mod.IsEmpty()) return std::string(); + auto bucketIt = state.keysByModuleHash.find(mod->GetIdentityHash()); + if (bucketIt == state.keysByModuleHash.end()) return std::string(); + auto& keys = bucketIt->second; + for (auto it = keys.begin(); it != keys.end();) { + auto regIt = state.registry.find(*it); + if (regIt == state.registry.end() || regIt->second.IsEmpty()) { + it = keys.erase(it); + continue; + } + if (regIt->second.Get(isolate) == mod) { + return *it; + } + ++it; + } + if (keys.empty()) { + state.keysByModuleHash.erase(bucketIt); + } + return std::string(); +} } // namespace +std::string LookupModuleKeyForModule(v8::Isolate* isolate, + v8::Local mod) { + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return std::string(); + return FindKeyForModule(*state, isolate, mod); +} + +void IndexModuleForIsolate(v8::Isolate* isolate, const std::string& canonicalKey, + v8::Local mod) { + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return; + IndexRegisteredModule(*state, canonicalKey, mod); +} + +void UnindexModuleForIsolate(v8::Isolate* isolate, + const std::string& canonicalKey) { + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return; + UnindexRegistryKey(*state, isolate, canonicalKey); +} + static bool IsVolatileUrl(const LoaderVocabulary& vocabulary, const std::string& url); @@ -494,7 +578,9 @@ static v8::MaybeLocal CompileModuleForResolveRegisterOnly( return hs.Escape(existing); } } + UnindexRegistryKey(*moduleState, isolate, registryKey); g_moduleRegistry[registryKey].Reset(isolate, mod); + IndexRegisteredModule(*moduleState, registryKey, mod); return hs.Escape(mod); } @@ -1501,6 +1587,7 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { if (IsScriptLoadingLogEnabled() && !isHttpKey) { DEBUG_WRITE("[resolver] removing stale module %s", registryKey.c_str()); } + UnindexRegistryKey(*moduleState, isolate, registryKey); it->second.Reset(); g_moduleRegistry.erase(it); } else if (IsScriptLoadingLogEnabled()) { @@ -2033,9 +2120,11 @@ static v8::MaybeLocal CompileJsonAsEsModule( v8::MaybeLocal evalResult = jsonModule->Evaluate(context); if (evalResult.IsEmpty()) return v8::MaybeLocal(); + UnindexRegistryKey(*moduleState, isolate, registryAbsPath); auto it = g_moduleRegistry.find(registryAbsPath); if (it != g_moduleRegistry.end()) it->second.Reset(); g_moduleRegistry[registryAbsPath].Reset(isolate, jsonModule); + IndexRegisteredModule(*moduleState, registryAbsPath, jsonModule); return v8::MaybeLocal(jsonModule); } @@ -2248,14 +2337,7 @@ v8::MaybeLocal ResolveModuleCallback( // Find the referrer's registered path so we can resolve relative specs // against its directory. - std::string referrerPath; - for (auto& kv : g_moduleRegistry) { - v8::Local registered = kv.second.Get(isolate); - if (!registered.IsEmpty() && registered == referrer) { - referrerPath = kv.first; - break; - } - } + std::string referrerPath = FindKeyForModule(*moduleState, isolate, referrer); bool specIsRelative = !spec.empty() && spec[0] == '.'; if (referrerPath.empty() && specIsRelative) { if (IsScriptLoadingLogEnabled()) { @@ -2837,7 +2919,10 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( CompileModuleFromSource(isolate, context, kEmptySrc, url); v8::Local mod; if (modMaybe.ToLocal(&mod)) { - g_moduleRegistry[CanonicalizeRegistryKey(url)].Reset(isolate, mod); + const std::string atStubKey = CanonicalizeRegistryKey(url); + UnindexRegistryKey(*moduleState, isolate, atStubKey); + g_moduleRegistry[atStubKey].Reset(isolate, mod); + IndexRegisteredModule(*moduleState, atStubKey, mod); if (mod->GetStatus() != v8::Module::kEvaluated) { if (mod->Evaluate(context).IsEmpty()) { resolver @@ -3681,16 +3766,8 @@ void InitializeImportMetaObject(v8::Local context, v8::Isolate* isolate = v8::Isolate::GetCurrent(); auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) return; - auto& g_moduleRegistry = moduleState->registry; - std::string modulePath; - for (auto& kv : g_moduleRegistry) { - v8::Local registered = kv.second.Get(isolate); - if (!registered.IsEmpty() && registered == module) { - modulePath = kv.first; - break; - } - } + std::string modulePath = FindKeyForModule(*moduleState, isolate, module); if (modulePath.empty()) return; std::string moduleUrl; diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index 8a61d93ea..2f1dc148b 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -36,6 +36,21 @@ void QuiesceModuleLoadsForIsolate(v8::Isolate* isolate); // ever called on the isolate's own JS thread during module resolution/loading. void RemoveModuleFromRegistry(const std::string& canonicalPath); +// The canonical registry key whose live entry is `mod`, or empty when the +// module is not registered for `isolate`. O(1) via the loader state's +// identity-hash index. +std::string LookupModuleKeyForModule(v8::Isolate* isolate, + v8::Local mod); + +// Keep the identity-hash index in step with a registry write performed outside +// ModuleInternalCallbacks.cpp. Unindex first, while the key's outgoing module +// is still reachable — once its handle is Reset its hash is unrecoverable and +// the bucket entry would leak — then index the incoming one after the write. +void UnindexModuleForIsolate(v8::Isolate* isolate, + const std::string& canonicalKey); +void IndexModuleForIsolate(v8::Isolate* isolate, const std::string& canonicalKey, + v8::Local mod); + // Authoritative HTTP URL loader for dev-served ESM. This compiles and // registers the module under its canonical URL key without evaluating it. v8::MaybeLocal LoadHttpModuleForUrl( From 76321ed1a799b5ae45396d5b57349304982d21a2 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 11:02:23 -0300 Subject: [PATCH 13/36] feat(runtime): category-scoped debug logging behind NS_DEBUG One trace facility for the module subsystem: a dense category enum (esm, fetch, registry) over a relaxed-atomic bitmask, a TNS_DEBUG macro whose arguments are never evaluated while the category is off, and a cold emit path writing to a per-category logcat tag (TNS.esm, TNS.fetch, TNS.registry). Compiled into release builds too - a build that cannot be traced cannot be diagnosed. Error and lifecycle logs stay unconditional. Enablement: the NS_DEBUG environment variable at process init (the only way to trace boot), and ns:runtime setConfig('debug', 'esm,fetch') at runtime - process-wide, main-isolate write, each write replacing the whole set. getConfig('debug') returns the enabled list. The per-flag keys logScriptLoading and httpFetchUrlLog are gone, along with their nativescript.config plumbing through AppConfig and the JNI seeding. UncaughtErrorPolicy's hardcoded ordinal read moves from 15 to 14 now that the two removed keys no longer precede it. --- docs/ns-builtin-modules.md | 23 +- test-app/runtime/CMakeLists.txt | 1 + test-app/runtime/src/main/cpp/HttpLoader.cpp | 204 ++---- test-app/runtime/src/main/cpp/HttpLoader.h | 13 - .../src/main/cpp/ModuleInternalCallbacks.cpp | 630 +++++++----------- .../runtime/src/main/cpp/NsBuiltinModules.cpp | 54 +- test-app/runtime/src/main/cpp/Runtime.cpp | 6 +- test-app/runtime/src/main/cpp/TraceLog.cpp | 153 +++++ test-app/runtime/src/main/cpp/TraceLog.h | 75 +++ .../src/main/java/com/tns/AppConfig.java | 20 +- .../src/main/java/com/tns/Runtime.java | 23 - 11 files changed, 563 insertions(+), 639 deletions(-) create mode 100644 test-app/runtime/src/main/cpp/TraceLog.cpp create mode 100644 test-app/runtime/src/main/cpp/TraceLog.h diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index b050ad765..e60ad64ef 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -66,8 +66,7 @@ Config keys: | key | values | scope | default | |---|---|---|---| -| `logScriptLoading` | `true` \| `false` | process-wide (main-isolate writes only; read live by every isolate) | `false`, or the `logScriptLoading` value from nativescript.config / package.json at boot | -| `httpFetchUrlLog` | `true` \| `false` | process-wide (main-isolate writes only; read live by every isolate) | `false`, or the `httpFetchUrlLog` value from nativescript.config / package.json at boot | +| `debug` | comma-separated category list, e.g. `"esm,fetch"` | process-wide (main-isolate writes only; read live by every isolate) | the `NS_DEBUG` environment variable, or `""` | Remote-module security (`security.allowRemoteModules`, `security.remoteModuleAllowlist`) is **not** part of this surface. Those @@ -78,6 +77,26 @@ through `getConfig` / `setConfig`. iOS additionally registers `releasedObjectPolicy`; Android does not (it has no released-native-counterpart machinery). +`debug` turns on the runtime's category-scoped trace logs. Categories: + +| category | covers | +|---|---| +| `esm` | module resolution, compilation, linking, evaluation, registry keying | +| `fetch` | the HTTP module transport (one line per fetched URL — high volume) | +| `registry` | registry invalidation and dynamic-import cache bookkeeping | + +Each write replaces the whole set, so `setConfig('debug', '')` disables +tracing and no caller needs to know what was already on. `getConfig('debug')` +returns the canonical comma-separated list of what is enabled. Unknown names +are ignored, with one warning line naming the valid ones. + +The same list can be given before boot as the `NS_DEBUG` environment variable +(`NS_DEBUG=esm,fetch`), which is the only way to trace boot itself. Traces are +compiled into release builds as well: a release build that cannot be traced is +a release build that cannot be diagnosed. Each category writes to its own +logcat tag (`TNS.esm`, `TNS.fetch`, `TNS.registry`), so `adb logcat -s TNS.esm` +filters them without matching message text. + ### `ns:module` (v1) The module-loader control surface consumed by development tooling diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index a50fb1456..9c085b026 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -230,6 +230,7 @@ add_library( src/main/cpp/URLSearchParamsImpl.cpp src/main/cpp/URLPatternImpl.cpp src/main/cpp/HttpLoader.cpp + src/main/cpp/TraceLog.cpp # Node-API: vendored upstream implementation plus the embedder half # (env lifecycle, module registry, async work, threadsafe functions) diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp index d158d9acf..83bb37944 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.cpp +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -20,6 +20,7 @@ #include "NativeScriptAssert.h" #include "NativeScriptException.h" #include "Runtime.h" +#include "TraceLog.h" #include "robin_hood.h" #include "v8-json.h" @@ -38,61 +39,6 @@ static inline v8::Local ToV8String(v8::Isolate* isolate, const std:: return ArgConverter::ConvertToV8String(isolate, str); } -// ───────────────────────────────────────────────────────────── -// Live ns:runtime log flags (boot default from Java, then setConfig) - -static std::atomic g_logScriptLoading{false}; -static std::atomic g_httpFetchUrlLog{false}; -static std::once_flag s_logFlagsInitFlag; - -static void EnsureLogFlagsInitialized() { - std::call_once(s_logFlagsInitFlag, []() { - try { - JEnv env; - jclass runtimeClass = env.FindClass("com/tns/Runtime"); - if (runtimeClass == nullptr) { - return; - } - jmethodID logMid = - env.GetStaticMethodID(runtimeClass, "getLogScriptLoadingEnabled", "()Z"); - if (logMid != nullptr) { - g_logScriptLoading.store(env.CallStaticBooleanMethod(runtimeClass, logMid) == - JNI_TRUE, - std::memory_order_relaxed); - } - jmethodID urlLogMid = - env.GetStaticMethodID(runtimeClass, "getHttpFetchUrlLogEnabled", "()Z"); - if (urlLogMid != nullptr) { - g_httpFetchUrlLog.store(env.CallStaticBooleanMethod(runtimeClass, urlLogMid) == - JNI_TRUE, - std::memory_order_relaxed); - } - } catch (...) { - // keep defaults (false) - } - }); -} - -bool IsScriptLoadingLogEnabled() { - EnsureLogFlagsInitialized(); - return g_logScriptLoading.load(std::memory_order_relaxed); -} - -void SetScriptLoadingLogEnabled(bool enabled) { - EnsureLogFlagsInitialized(); - g_logScriptLoading.store(enabled, std::memory_order_relaxed); -} - -bool IsHttpFetchUrlLogEnabled() { - EnsureLogFlagsInitialized(); - return g_httpFetchUrlLog.load(std::memory_order_relaxed); -} - -void SetHttpFetchUrlLogEnabled(bool enabled) { - EnsureLogFlagsInitialized(); - g_httpFetchUrlLog.store(enabled, std::memory_order_relaxed); -} - // ───────────────────────────────────────────────────────────── // Remote-module security gate @@ -221,9 +167,7 @@ static inline bool IsDevSessionBootComplete() { void SetDevBootComplete(v8::Isolate* isolate, v8::Local context, bool value) { SetBooleanGlobal(isolate, context, "__NS_HMR_BOOT_COMPLETE__", value); g_devSessionBootComplete.store(value, std::memory_order_relaxed); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE("[dev-boot] __NS_HMR_BOOT_COMPLETE__=%s", value ? "true" : "false"); - } + TNS_DEBUG(Esm, "[dev-boot] __NS_HMR_BOOT_COMPLETE__=%s", value ? "true" : "false"); } // ───────────────────────────────────────────────────────────── @@ -495,13 +439,11 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten if (!IsRemoteUrlAllowed(url)) { status = 403; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE("[http-esm][security][blocked] %s", url.c_str()); - } + TNS_DEBUG(Esm, "[http-esm][security][blocked] %s", url.c_str()); return false; } - const bool urlLogEnabled = IsHttpFetchUrlLogEnabled(); + const bool urlLogEnabled = LogCategoryEnabled(LogCategory::Fetch); const auto netStart = urlLogEnabled ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{}; @@ -512,9 +454,7 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten bool ok = PerformHttpFetchOnceSync(url, canonicalKey, out, contentType, status); if (!ok) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE("[http-loader] retrying %s after initial fetch error", url.c_str()); - } + TNS_DEBUG(Esm, "[http-loader] retrying %s after initial fetch error", url.c_str()); usleep(120 * 1000); ok = PerformHttpFetchOnceSync(url, canonicalKey, out, contentType, status); } @@ -523,23 +463,18 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten } if (out.empty()) { out = "export {};\n"; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE( - "[http-loader] empty 2xx body for %s — serving canonical empty module", - url.c_str()); - } - } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE("[http-loader] fetched status=%d content-type=%s bytes=%llu", status, - contentType.empty() ? "" : contentType.c_str(), - (unsigned long long)out.size()); + TNS_DEBUG(Esm, "[http-loader] empty 2xx body for %s — serving canonical empty module", + url.c_str()); } + TNS_DEBUG(Esm, "[http-loader] fetched status=%d content-type=%s bytes=%llu", status, + contentType.empty() ? "" : contentType.c_str(), + (unsigned long long)out.size()); if (urlLogEnabled) { const auto netMs = std::chrono::duration_cast( std::chrono::steady_clock::now() - netStart) .count(); - DEBUG_WRITE_FORCE("[http-loader][fetch][network] %s bytes=%lu ms=%lld", url.c_str(), - (unsigned long)out.size(), (long long)netMs); + TNS_DEBUG(Fetch, "[http-loader][fetch][network] %s bytes=%lu ms=%lld", url.c_str(), + (unsigned long)out.size(), (long long)netMs); } InvokeHttpFetchYield(); @@ -554,9 +489,7 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& out.clear(); contentType.clear(); status = 0; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE("[http-esm][fetch][enter] url=%s", url.c_str()); - } + TNS_DEBUG(Esm, "[http-esm][fetch][enter] url=%s", url.c_str()); bool bustRequested = false; const std::string fetchUrl = ApplyCacheBustNonce(url, canonicalKey, &bustRequested); @@ -578,11 +511,8 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& std::string excClass, excMsg; if (DrainPendingJniException(env, excClass, excMsg)) { RecordLastHttpFetchError("url-ctor", excClass, excMsg); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE( - "[http-esm][fetch][exception] stage=url-ctor url=%s class=%s msg=%s", - url.c_str(), excClass.c_str(), excMsg.c_str()); - } + TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=url-ctor url=%s class=%s msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); return false; } } @@ -592,12 +522,9 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& std::string excClass, excMsg; if (DrainPendingJniException(env, excClass, excMsg)) { RecordLastHttpFetchError("open-connection", excClass, excMsg); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE( - "[http-esm][fetch][exception] stage=open-connection url=%s class=%s " - "msg=%s", - url.c_str(), excClass.c_str(), excMsg.c_str()); - } + TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=open-connection url=%s class=%s " + "msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); return false; } } @@ -643,12 +570,10 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& std::string excClass, excMsg; if (DrainPendingJniException(env, excClass, excMsg)) { RecordLastHttpFetchError("get-response-code", excClass, excMsg); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE( - "[http-esm][fetch][exception] stage=get-response-code url=%s class=%s " - "msg=%s", - url.c_str(), excClass.c_str(), excMsg.c_str()); - } + TNS_DEBUG(Esm, + "[http-esm][fetch][exception] stage=get-response-code url=%s class=%s " + "msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); return false; } } @@ -666,12 +591,10 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& std::string excClass, excMsg; if (DrainPendingJniException(env, excClass, excMsg)) { RecordLastHttpFetchError("get-input-stream", excClass, excMsg); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE( - "[http-esm][fetch][exception] stage=get-input-stream url=%s class=%s " - "msg=%s", - url.c_str(), excClass.c_str(), excMsg.c_str()); - } + TNS_DEBUG(Esm, + "[http-esm][fetch][exception] stage=get-input-stream url=%s class=%s " + "msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); return false; } } @@ -695,11 +618,9 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& std::string excClass, excMsg; if (DrainPendingJniException(env, excClass, excMsg)) { RecordLastHttpFetchError("read-body", excClass, excMsg); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE( - "[http-esm][fetch][exception] stage=read-body url=%s class=%s msg=%s", - url.c_str(), excClass.c_str(), excMsg.c_str()); - } + TNS_DEBUG(Esm, + "[http-esm][fetch][exception] stage=read-body url=%s class=%s msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); readFailed = true; break; } @@ -744,26 +665,19 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& what = nse.GetErrorMessage(); } RecordLastHttpFetchError("native-script-exception", "tns::NativeScriptException", what); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE( - "[http-esm][fetch][exception] stage=native-script-exception url=%s msg=%s", - url.c_str(), what.c_str()); - } + TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=native-script-exception url=%s msg=%s", + url.c_str(), what.c_str()); return false; } catch (std::exception& ex) { std::string what = ex.what() ? ex.what() : ""; RecordLastHttpFetchError("std-exception", "std::exception", what); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE("[http-esm][fetch][exception] stage=std-exception url=%s msg=%s", - url.c_str(), what.c_str()); - } + TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=std-exception url=%s msg=%s", + url.c_str(), what.c_str()); return false; } catch (...) { RecordLastHttpFetchError("unknown-cpp-exception", "", ""); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE("[http-esm][fetch][exception] stage=unknown-cpp-exception url=%s", - url.c_str()); - } + TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=unknown-cpp-exception url=%s", + url.c_str()); return false; } } @@ -771,9 +685,7 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& void FetchModuleBodyAsync(const std::string& url, std::function completion) { if (!IsRemoteUrlAllowed(url)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE("[http-esm][security][blocked] %s", url.c_str()); - } + TNS_DEBUG(Esm, "[http-esm][security][blocked] %s", url.c_str()); completion(false, 403, std::string()); return; } @@ -809,10 +721,8 @@ void FetchModuleBodyAsync(const std::string& url, const auto start = std::chrono::steady_clock::now(); bool ok = PerformHttpFetchOnceSync(url, canonicalKey, out, contentType, status); if (!ok) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE("[http-loader][fetch-async] retrying %s after transport error", - url.c_str()); - } + TNS_DEBUG(Esm, "[http-loader][fetch-async] retrying %s after transport error", + url.c_str()); usleep(120 * 1000); ok = PerformHttpFetchOnceSync(url, canonicalKey, out, contentType, status); } @@ -820,16 +730,16 @@ void FetchModuleBodyAsync(const std::string& url, if (ok && out.empty()) { out = "export {};\n"; } - if (!ok && IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE("[http-loader][fetch-async][error] url=%s status=%d", url.c_str(), - status); + if (!ok) { + TNS_DEBUG(Esm, "[http-loader][fetch-async][error] url=%s status=%d", url.c_str(), + status); } - if (ok && IsHttpFetchUrlLogEnabled()) { + if (ok && LogCategoryEnabled(LogCategory::Fetch)) { const auto ms = std::chrono::duration_cast( std::chrono::steady_clock::now() - start) .count(); - DEBUG_WRITE_FORCE("[http-loader][fetch][async] %s bytes=%lu ms=%lld", url.c_str(), - (unsigned long)out.size(), (long long)ms); + TNS_DEBUG(Fetch, "[http-loader][fetch][async] %s bytes=%lu ms=%lld", url.c_str(), + (unsigned long)out.size(), (long long)ms); } completion(ok, status, std::move(out)); }).detach(); @@ -880,12 +790,9 @@ void ConfigureLoaderCallback(const v8::FunctionCallbackInfo& info) { v8::Isolate* isolate = info.GetIsolate(); v8::HandleScope scope(isolate); v8::Local ctx = isolate->GetCurrentContext(); - bool logScriptLoading = tns::IsScriptLoadingLogEnabled(); if (info.Length() < 1 || !info[0]->IsObject()) { - if (logScriptLoading) { - DEBUG_WRITE_FORCE("[ns:module configureLoader] expected config object argument"); - } + TNS_DEBUG(Esm, "[ns:module configureLoader] expected config object argument"); return; } @@ -907,10 +814,8 @@ void ConfigureLoaderCallback(const v8::FunctionCallbackInfo& info) { } if (!jsonStr.empty()) { SetImportMap(jsonStr); - if (logScriptLoading) { - DEBUG_WRITE_FORCE("[ns:module configureLoader] import map set (%zu bytes)", - jsonStr.size()); - } + TNS_DEBUG(Esm, "[ns:module configureLoader] import map set (%zu bytes)", + jsonStr.size()); } } @@ -935,10 +840,8 @@ void ConfigureLoaderCallback(const v8::FunctionCallbackInfo& info) { std::vector patterns; if (readStringArray(config, "volatilePatterns", patterns) && !patterns.empty()) { SetVolatilePatterns(patterns); - if (logScriptLoading) { - DEBUG_WRITE_FORCE("[ns:module configureLoader] %zu volatile patterns set", - patterns.size()); - } + TNS_DEBUG(Esm, "[ns:module configureLoader] %zu volatile patterns set", + patterns.size()); } } @@ -980,17 +883,16 @@ void InvalidateModulesCallback(const v8::FunctionCallbackInfo& info) } } - if (tns::IsScriptLoadingLogEnabled()) { - DEBUG_WRITE_FORCE("[ns-hmr][android-invalidate] called urls.count=%zu", urls.size()); + if (tns::LogCategoryEnabled(tns::LogCategory::Registry)) { + TNS_DEBUG(Registry, "invalidate called urls.count=%zu", urls.size()); size_t shown = 0; for (const auto& u : urls) { if (shown >= 32) break; - DEBUG_WRITE_FORCE("[ns-hmr][android-invalidate] url[%zu]=%s", shown, u.c_str()); + TNS_DEBUG(Registry, "invalidate url[%zu]=%s", shown, u.c_str()); shown++; } if (urls.size() > shown) { - DEBUG_WRITE_FORCE("[ns-hmr][android-invalidate] (hidden %zu more URL(s))", - urls.size() - shown); + TNS_DEBUG(Registry, "invalidate (hidden %zu more URL(s))", urls.size() - shown); } } diff --git a/test-app/runtime/src/main/cpp/HttpLoader.h b/test-app/runtime/src/main/cpp/HttpLoader.h index 562555001..4f3172175 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.h +++ b/test-app/runtime/src/main/cpp/HttpLoader.h @@ -155,19 +155,6 @@ bool IsRemoteUrlAllowed(const std::string& url); // config init. Fail-safe false until initialized. bool IsDebuggable(); -// Verbose script/module-loading diagnostics. Process-wide ns:runtime key -// `logScriptLoading`; boot default is the nativescript.config / package.json -// value (false when absent). Live value is readable via getConfig and -// writable via setConfig from the main isolate. -bool IsScriptLoadingLogEnabled(); -void SetScriptLoadingLogEnabled(bool enabled); - -// One log line per HTTP fetch URL (high volume). Process-wide ns:runtime -// key `httpFetchUrlLog`; boot default is the nativescript.config / -// package.json value (false when absent). -bool IsHttpFetchUrlLogEnabled(); -void SetHttpFetchUrlLogEnabled(bool enabled); - // ───────────────────────────────────────────────────────────── // The `ns:module` builtin binding // diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 79a743143..53740cc9d 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -28,6 +28,7 @@ #include "NsBuiltinModules.h" #include "Runtime.h" #include "RuntimeState.h" +#include "TraceLog.h" #include "Util.h" #include "robin_hood.h" @@ -485,9 +486,10 @@ static v8::MaybeLocal CompileModuleForResolveRegisterOnly( } auto& g_moduleRegistry = moduleState->registry; const std::string registryKey = CanonicalizeRegistryKey(urlStr); - if (IsScriptLoadingLogEnabled() && ShouldTraceRegistryKey(urlStr, registryKey)) { - DEBUG_WRITE("[resolver][register-resolve-only] raw=%s key=%s", - urlStr.c_str(), registryKey.c_str()); + if (LogCategoryEnabled(LogCategory::Esm) && + ShouldTraceRegistryKey(urlStr, registryKey)) { + TNS_DEBUG(Esm, "[resolver][register-resolve-only] raw=%s key=%s", + urlStr.c_str(), registryKey.c_str()); } v8::Local sourceText = @@ -505,7 +507,7 @@ static v8::MaybeLocal CompileModuleForResolveRegisterOnly( { v8::TryCatch tcCompile(isolate); if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { - if (IsDebuggable() && IsScriptLoadingLogEnabled()) { + if (IsDebuggable() && LogCategoryEnabled(LogCategory::Esm)) { uint64_t h = 1469598103934665603ull; // FNV-1a 64-bit for (unsigned char c : code) { h ^= c; @@ -561,12 +563,12 @@ static v8::MaybeLocal CompileModuleForResolveRegisterOnly( classification = "underscore-helper-unmapped"; } if (srcLineStr.size() > 240) srcLineStr = srcLineStr.substr(0, 240); - DEBUG_WRITE( - "[http-esm][compile][v8-error][%s] %s line=%d col=%d..%d " - "hash=%llx bytes=%lu msg=%s srcLine=%s snippet=%s", - classification, urlStr.c_str(), lineNum, startCol, endCol, - (unsigned long long)h, (unsigned long)code.size(), - msgStr.c_str(), srcLineStr.c_str(), snippet.c_str()); + TNS_DEBUG(Esm, + "[http-esm][compile][v8-error][%s] %s line=%d col=%d..%d " + "hash=%llx bytes=%lu msg=%s srcLine=%s snippet=%s", + classification, urlStr.c_str(), lineNum, startCol, endCol, + (unsigned long long)h, (unsigned long)code.size(), + msgStr.c_str(), srcLineStr.c_str(), snippet.c_str()); } return v8::MaybeLocal(); } @@ -648,10 +650,9 @@ static std::string CanonicalizeRegistryKey(const std::string& key) { } } - if (IsScriptLoadingLogEnabled() && - (traceEvenWithoutChange || registryKey != key)) { - DEBUG_WRITE("[resolver][registry-key][%s] raw=%s key=%s", classification, - key.c_str(), registryKey.c_str()); + if (traceEvenWithoutChange || registryKey != key) { + TNS_DEBUG(Esm, "[resolver][registry-key][%s] raw=%s key=%s", classification, + key.c_str(), registryKey.c_str()); } return registryKey; } @@ -666,23 +667,17 @@ v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, auto& g_moduleRegistry = moduleState->registry; const std::string registryKey = CanonicalizeHttpUrlKey(requestedUrl); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][load][begin] request=%s key=%s", - requestedUrl.c_str(), registryKey.c_str()); - } + TNS_DEBUG(Esm, "[http-esm][load][begin] request=%s key=%s", + requestedUrl.c_str(), registryKey.c_str()); auto itExisting = g_moduleRegistry.find(registryKey); if (itExisting != g_moduleRegistry.end()) { v8::Local existing = itExisting->second.Get(isolate); if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][load][cache-hit] key=%s", registryKey.c_str()); - } + TNS_DEBUG(Esm, "[http-esm][load][cache-hit] key=%s", registryKey.c_str()); return v8::MaybeLocal(existing); } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][load][drop-errored] key=%s", registryKey.c_str()); - } + TNS_DEBUG(Esm, "[http-esm][load][drop-errored] key=%s", registryKey.c_str()); RemoveModuleFromRegistry(registryKey); } @@ -690,10 +685,8 @@ v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, std::string contentType; int status = 0; if (!HttpFetchText(requestedUrl, body, contentType, status) || body.empty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][load][fetch-fail] request=%s key=%s status=%d", - requestedUrl.c_str(), registryKey.c_str(), status); - } + TNS_DEBUG(Esm, "[http-esm][load][fetch-fail] request=%s key=%s status=%d", + requestedUrl.c_str(), registryKey.c_str(), status); if (IsDebuggable()) { std::string msg = "HTTP import failed: " + requestedUrl + " (status=" + std::to_string(status) + ")"; @@ -706,10 +699,8 @@ v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, v8::MaybeLocal loaded = CompileModuleForResolveRegisterOnly(isolate, context, body, registryKey); if (loaded.IsEmpty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][load][compile-fail] request=%s key=%s bytes=%zu", - requestedUrl.c_str(), registryKey.c_str(), body.size()); - } + TNS_DEBUG(Esm, "[http-esm][load][compile-fail] request=%s key=%s bytes=%zu", + requestedUrl.c_str(), registryKey.c_str(), body.size()); if (IsDebuggable()) { std::string msg = "HTTP import compile failed: " + requestedUrl; isolate->ThrowException(v8::Exception::Error( @@ -718,11 +709,9 @@ v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, return v8::MaybeLocal(); } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[http-esm][load][ok] request=%s key=%s type=%s bytes=%zu", - requestedUrl.c_str(), registryKey.c_str(), - contentType.c_str(), body.size()); - } + TNS_DEBUG(Esm, "[http-esm][load][ok] request=%s key=%s type=%s bytes=%zu", + requestedUrl.c_str(), registryKey.c_str(), + contentType.c_str(), body.size()); return loaded; } @@ -871,9 +860,7 @@ void SetImportMap(const std::string& json) { JsonScanner sc(json); if (!sc.Consume('{')) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[import-map] parse failed: not an object"); - } + TNS_DEBUG(Esm, "[import-map] parse failed: not an object"); return; } @@ -907,23 +894,19 @@ void SetImportMap(const std::string& json) { if (!sc.Consume(',')) break; } - if (IsScriptLoadingLogEnabled()) { - if (!foundImports) { - DEBUG_WRITE("[import-map] no 'imports' object found"); - } - DEBUG_WRITE("[import-map] loaded %lu entries", - (unsigned long)g_importMap.size()); + if (!foundImports) { + TNS_DEBUG(Esm, "[import-map] no 'imports' object found"); } + TNS_DEBUG(Esm, "[import-map] loaded %lu entries", + (unsigned long)g_importMap.size()); } void SetVolatilePatterns(const std::vector& patterns) { LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); if (vocabulary == nullptr) return; vocabulary->volatilePatterns = patterns; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[import-map] volatile patterns: %lu", - (unsigned long)vocabulary->volatilePatterns.size()); - } + TNS_DEBUG(Esm, "[import-map] volatile patterns: %lu", + (unsigned long)vocabulary->volatilePatterns.size()); } const CanonicalizationConfig* CanonicalizationConfigForCurrentIsolate() { @@ -939,14 +922,11 @@ void SetCanonicalizationConfig(CanonicalizationConfig config) { if (vocabulary == nullptr) return; vocabulary->canonicalization = std::move(config); vocabulary->canonicalizationConfigured = true; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( - "[ns:module configureLoader] canonicalization set (strip=%lu " - "devPrefixes=%lu preserve=%lu)", - (unsigned long)vocabulary->canonicalization.stripParams.size(), - (unsigned long)vocabulary->canonicalization.devPathPrefixes.size(), - (unsigned long)vocabulary->canonicalization.preserveQueryPrefixes.size()); - } + TNS_DEBUG(Esm, "[ns:module configureLoader] canonicalization set (strip=%lu " + "devPrefixes=%lu preserve=%lu)", + (unsigned long)vocabulary->canonicalization.stripParams.size(), + (unsigned long)vocabulary->canonicalization.devPathPrefixes.size(), + (unsigned long)vocabulary->canonicalization.preserveQueryPrefixes.size()); } static bool IsVolatileUrl(const LoaderVocabulary& vocabulary, @@ -992,10 +972,8 @@ static std::string NormalizeViteSpecifier(const std::string& specifier) { } } } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[import-map][normalize] vite-deps: %s -> %s", - specifier.c_str(), id.c_str()); - } + TNS_DEBUG(Esm, "[import-map][normalize] vite-deps: %s -> %s", + specifier.c_str(), id.c_str()); return id; } } @@ -1076,10 +1054,8 @@ static std::string NormalizeViteSpecifier(const std::string& specifier) { } } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[import-map][normalize] node_modules: %s -> %s", - specifier.c_str(), normalized.c_str()); - } + TNS_DEBUG(Esm, "[import-map][normalize] node_modules: %s -> %s", + specifier.c_str(), normalized.c_str()); return normalized; } } @@ -1094,10 +1070,8 @@ static std::string LookupImportMap(const LoaderVocabulary& vocabulary, const auto& g_importMap = vocabulary.importMap; auto it = g_importMap.find(specifier); if (it != g_importMap.end()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[import-map] exact: %s -> %s", specifier.c_str(), - it->second.c_str()); - } + TNS_DEBUG(Esm, "[import-map] exact: %s -> %s", specifier.c_str(), + it->second.c_str()); return it->second; } std::string bestKey; @@ -1116,10 +1090,8 @@ static std::string LookupImportMap(const LoaderVocabulary& vocabulary, if (!bestKey.empty()) { std::string remainder = specifier.substr(bestKey.size()); std::string resolved = bestValue + remainder; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[import-map] prefix: %s -> %s (via %s)", specifier.c_str(), - resolved.c_str(), bestKey.c_str()); - } + TNS_DEBUG(Esm, "[import-map] prefix: %s -> %s (via %s)", specifier.c_str(), + resolved.c_str(), bestKey.c_str()); return resolved; } return ""; @@ -1308,10 +1280,11 @@ static void AsyncGraphMaybeComplete(const std::shared_ptr& load, v8::Local context) { if (load->completed || load->pendingFetches > 0) return; load->completed = true; - if (IsScriptLoadingLogEnabled()) { + if (LogCategoryEnabled(LogCategory::Esm)) { const uint64_t endUs = MonotonicUs(); const uint64_t ms = endUs > load->startUs ? (endUs - load->startUs) / 1000ull : 0ull; - DEBUG_WRITE( + TNS_DEBUG( + Esm, "[async-graph][done] root=%s urls=%lu fetched=%lu compiled=%lu ms=%llu ok=%d", load->rootKey.c_str(), (unsigned long)load->visited.size(), (unsigned long)load->fetchedCount, (unsigned long)load->compiledCount, @@ -1354,9 +1327,9 @@ static void AsyncGraphOnFetchCompleted( load->failed = true; load->failureMessage = "HTTP import failed: " + url + " (status=" + std::to_string(status) + ")"; - } else if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[async-graph][dep-fetch-fail] %s status=%d (left to sync resolver)", - url.c_str(), status); + } else { + TNS_DEBUG(Esm, "[async-graph][dep-fetch-fail] %s status=%d (left to sync resolver)", + url.c_str(), status); } } else { load->fetchedCount++; @@ -1367,9 +1340,9 @@ static void AsyncGraphOnFetchCompleted( if (isRoot) { load->failed = true; load->failureMessage = "HTTP import compile failed: " + url; - } else if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[async-graph][dep-compile-fail] %s (left to sync resolver)", - url.c_str()); + } else { + TNS_DEBUG(Esm, "[async-graph][dep-compile-fail] %s (left to sync resolver)", + url.c_str()); } } else { load->compiledCount++; @@ -1445,10 +1418,8 @@ void StartAsyncHttpModuleGraphLoad( 1, std::memory_order_acq_rel); RegisterAsyncGraphLoad(isolate, load); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[async-graph][start] root=%s key=%s", rootUrl.c_str(), - load->rootKey.c_str()); - } + TNS_DEBUG(Esm, "[async-graph][start] root=%s key=%s", rootUrl.c_str(), + load->rootKey.c_str()); AsyncGraphEnqueueUrl(load, rootUrl); // Root already registered (or nothing fetchable): complete inline. @@ -1477,8 +1448,9 @@ bool RunAsyncHttpModuleGraphLoadPumped(v8::Isolate* isolate, while (!*done && std::chrono::steady_clock::now() < deadline) { ALooper_pollOnce(10 /* ms */, nullptr, nullptr, nullptr); } - if (!*done && IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( + if (!*done) { + TNS_DEBUG( + Esm, "[async-graph][pumped][timeout] root=%s after %.1fs (sync loader takes over)", rootUrl.c_str(), timeoutSeconds); } @@ -1542,10 +1514,8 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { return s.find("__invalid_at__.mjs") != std::string::npos; }; if (isSentinel(registryKey)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][guard-v3] ignore remove for sentinel %s", - registryKey.c_str()); - } + TNS_DEBUG(Esm, "[resolver][guard-v3] ignore remove for sentinel %s", + registryKey.c_str()); return; } @@ -1565,15 +1535,13 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { return "path"; }; - if (IsScriptLoadingLogEnabled()) { - if (registryKey != canonicalPath) { - DEBUG_WRITE("[resolver][remove:pre] raw=%s key=%s class=%s", - canonicalPath.c_str(), registryKey.c_str(), - classify(registryKey)); - } else { - DEBUG_WRITE("[resolver][remove:pre] key=%s class=%s", registryKey.c_str(), - classify(registryKey)); - } + if (registryKey != canonicalPath) { + TNS_DEBUG(Esm, "[resolver][remove:pre] raw=%s key=%s class=%s", + canonicalPath.c_str(), registryKey.c_str(), + classify(registryKey)); + } else { + TNS_DEBUG(Esm, "[resolver][remove:pre] key=%s class=%s", registryKey.c_str(), + classify(registryKey)); } size_t regPre = g_moduleRegistry.size(); @@ -1584,14 +1552,15 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { if (it != g_moduleRegistry.end()) { bool isHttpKey = StartsWith(registryKey, "http://") || StartsWith(registryKey, "https://"); - if (IsScriptLoadingLogEnabled() && !isHttpKey) { - DEBUG_WRITE("[resolver] removing stale module %s", registryKey.c_str()); + if (!isHttpKey) { + TNS_DEBUG(Esm, "[resolver] removing stale module %s", registryKey.c_str()); } UnindexRegistryKey(*moduleState, isolate, registryKey); it->second.Reset(); g_moduleRegistry.erase(it); - } else if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( + } else { + TNS_DEBUG( + Esm, "[resolver][remove:miss] key not found, proceed to clear fallbacks (%s)", registryKey.c_str()); } @@ -1609,15 +1578,11 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { } } - if (IsScriptLoadingLogEnabled()) { - size_t regPost = g_moduleRegistry.size(); - size_t fbPost = g_moduleFallbackRegistry.size(); - size_t relPost = g_moduleFallbackByRelative.size(); - DEBUG_WRITE( - "[resolver][remove:post] reg %lu->%lu fb %lu->%lu rel %lu->%lu", - (unsigned long)regPre, (unsigned long)regPost, (unsigned long)fbPre, - (unsigned long)fbPost, (unsigned long)relPre, (unsigned long)relPost); - } + TNS_DEBUG(Esm, "[resolver][remove:post] reg %lu->%lu fb %lu->%lu rel %lu->%lu", + (unsigned long)regPre, (unsigned long)g_moduleRegistry.size(), + (unsigned long)fbPre, (unsigned long)g_moduleFallbackRegistry.size(), + (unsigned long)relPre, + (unsigned long)g_moduleFallbackByRelative.size()); } std::vector GetLoadedModuleUrls() { @@ -1659,16 +1624,13 @@ void InvalidateModules(v8::Isolate* isolate, v8::Local context, uniqueUrls.push_back(registryKey); } - const bool logScriptLoading = IsScriptLoadingLogEnabled(); size_t hits = 0, misses = 0; for (const auto& url : uniqueUrls) { bool present = g_moduleRegistry.find(url) != g_moduleRegistry.end(); if (present) hits++; else misses++; - if (logScriptLoading) { - DEBUG_WRITE("[ns-hmr][android-invalidate] %s key=%s", - present ? "HIT " : "MISS", url.c_str()); - } + TNS_DEBUG(Registry, "invalidate %s key=%s", present ? "HIT " : "MISS", + url.c_str()); RejectAndClearInvalidatedModuleState(isolate, context, url); RemoveModuleFromRegistry(url); } @@ -1681,13 +1643,10 @@ void InvalidateModules(v8::Isolate* isolate, v8::Local context, // identity stays the canonical URL. MarkUrlsForCacheBust(uniqueUrls); - if (logScriptLoading) { - DEBUG_WRITE( - "[ns-hmr][android-invalidate] summary unique=%lu hits=%lu misses=%lu " - "(registry now=%lu)", - (unsigned long)uniqueUrls.size(), (unsigned long)hits, - (unsigned long)misses, (unsigned long)g_moduleRegistry.size()); - } + TNS_DEBUG(Registry, "invalidate summary unique=%lu hits=%lu misses=%lu " + "(registry now=%lu)", + (unsigned long)uniqueUrls.size(), (unsigned long)hits, + (unsigned long)misses, (unsigned long)g_moduleRegistry.size()); } void UpdateModuleFallback(v8::Isolate* isolate, @@ -1703,10 +1662,8 @@ void UpdateModuleFallback(v8::Isolate* isolate, } if (!module.IsEmpty()) { g_moduleFallbackRegistry[canonicalPath].Reset(isolate, module); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver] fallback updated for %s from evaluated module", - canonicalPath.c_str()); - } + TNS_DEBUG(Esm, "[resolver] fallback updated for %s from evaluated module", + canonicalPath.c_str()); std::string relative = ExtractRelativePath(canonicalPath); if (!relative.empty()) { auto relativeIt = g_moduleFallbackByRelative.find(relative); @@ -1714,10 +1671,8 @@ void UpdateModuleFallback(v8::Isolate* isolate, relativeIt->second.Reset(); } g_moduleFallbackByRelative[relative].Reset(isolate, module); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver] fallback relative updated for %s", - relative.c_str()); - } + TNS_DEBUG(Esm, "[resolver] fallback relative updated for %s", + relative.c_str()); } } } @@ -1789,11 +1744,9 @@ static bool QueueModuleWaiterIfInFlight(v8::Isolate* isolate, return false; } moduleState->moduleWaiters[registryKey].emplace_back(isolate, resolver); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][await] queued module waiter for %s status=%s", - registryKey.c_str(), - ModuleStatusToString(module->GetStatus())); - } + TNS_DEBUG(Esm, "[dyn-import][await] queued module waiter for %s status=%s", + registryKey.c_str(), + ModuleStatusToString(module->GetStatus())); return true; } @@ -1809,11 +1762,9 @@ static bool QueueHttpDynamicWaiterIfInFlight( return false; } moduleState->httpDynamicWaiters[registryKey].emplace_back(isolate, resolver); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][http-await] queued waiter for %s status=%s", - registryKey.c_str(), - ModuleStatusToString(module->GetStatus())); - } + TNS_DEBUG(Esm, "[dyn-import][http-await] queued waiter for %s status=%s", + registryKey.c_str(), + ModuleStatusToString(module->GetStatus())); return true; } @@ -1840,9 +1791,7 @@ static v8::Local BuildModuleFailureReason(v8::Isolate* isolate, } } } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][failure] %s", message.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][failure] %s", message.c_str()); return v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, message)); } @@ -1957,10 +1906,8 @@ static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, g_httpDynamicWaiters.erase(dynamicWaitIt); RejectResolversForInvalidation(isolate, context, resolvers, registryKey); } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][invalidate-state] cleared in-flight state for %s", - registryKey.c_str()); - } + TNS_DEBUG(Esm, "[resolver][invalidate-state] cleared in-flight state for %s", + registryKey.c_str()); } namespace { @@ -1982,10 +1929,8 @@ struct ResolutionStackGuard { } state_.modulesInFlight.insert(entry_); state_.modulesPendingReset.erase(entry_); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][stack] push (%lu) %s", - static_cast(stack_.size()), entry_.c_str()); - } + TNS_DEBUG(Esm, "[resolver][stack] push (%lu) %s", + static_cast(stack_.size()), entry_.c_str()); } ~ResolutionStackGuard() { @@ -1994,10 +1939,8 @@ struct ResolutionStackGuard { auto& g_moduleFallbackRegistry = state_.fallbackRegistry; auto& g_moduleWaiters = state_.moduleWaiters; auto& g_modulesPendingReset = state_.modulesPendingReset; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][stack] pop (%lu) %s", - static_cast(stack_.size()), entry_.c_str()); - } + TNS_DEBUG(Esm, "[resolver][stack] pop (%lu) %s", + static_cast(stack_.size()), entry_.c_str()); state_.reentryCounts.erase(entry_); state_.reentryParents.erase(entry_); state_.primaryImporters.erase(entry_); @@ -2032,11 +1975,9 @@ struct ResolutionStackGuard { v8::Module::Status status = module.IsEmpty() ? v8::Module::kErrored : module->GetStatus(); if (status != v8::Module::kEvaluated && status != v8::Module::kErrored) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( - "[resolver] dropping incomplete module after unwind %s (status=%s)", - entry_.c_str(), ModuleStatusToString(status)); - } + TNS_DEBUG(Esm, + "[resolver] dropping incomplete module after unwind %s (status=%s)", + entry_.c_str(), ModuleStatusToString(status)); RemoveModuleFromRegistry(entry_); } } @@ -2049,11 +1990,9 @@ struct ResolutionStackGuard { if (!activeModule.IsEmpty() && activeModule->GetStatus() == v8::Module::kEvaluated) { g_moduleFallbackRegistry[entry_].Reset(isolate_, activeModule); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( - "[resolver] updated fallback module for %s after successful evaluation", - entry_.c_str()); - } + TNS_DEBUG(Esm, + "[resolver] updated fallback module for %s after successful evaluation", + entry_.c_str()); } } } @@ -2076,16 +2015,13 @@ struct ResolutionStackGuard { // JSON value. Handles registry insertion and eager evaluation. static v8::MaybeLocal CompileJsonAsEsModule( v8::Isolate* isolate, v8::Local context, - const std::string& absPath, const std::string& registryAbsPath, - bool isWorker) { + const std::string& absPath, const std::string& registryAbsPath) { auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) { return v8::MaybeLocal(); } auto& g_moduleRegistry = moduleState->registry; - if (isWorker && IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver] Worker handling JSON module '%s'", absPath.c_str()); - } + TNS_DEBUG(Esm, "[resolver][json] wrapping %s", absPath.c_str()); std::string jsonText = Runtime::GetRuntime(isolate)->ReadFileText(absPath); std::string moduleSource = "export default " + jsonText + ";"; @@ -2277,15 +2213,11 @@ v8::MaybeLocal ResolveModuleCallback( normalizedSpec.insert(6, "/"); } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][spec] %s", normalizedSpec.c_str()); - } + TNS_DEBUG(Esm, "[resolver][spec] %s", normalizedSpec.c_str()); // Guard against a bare '@' spec — invalid; refuse to poison the registry. if (normalizedSpec == "@") { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][normalize] ignoring invalid '@' static spec"); - } + TNS_DEBUG(Esm, "[resolver][normalize] ignoring invalid '@' static spec"); return v8::MaybeLocal(); } @@ -2297,27 +2229,24 @@ v8::MaybeLocal ResolveModuleCallback( std::string normalized = NormalizeViteSpecifier(normalizedSpec); if (!normalized.empty()) { mapped = LookupImportMap(vocabulary, normalized); - if (!mapped.empty() && IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][import-map] normalized: %s -> %s -> %s", - normalizedSpec.c_str(), normalized.c_str(), - mapped.c_str()); + if (!mapped.empty()) { + TNS_DEBUG(Esm, "[resolver][import-map] normalized: %s -> %s -> %s", + normalizedSpec.c_str(), normalized.c_str(), mapped.c_str()); } } } if (!mapped.empty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][import-map] rewrite: %s -> %s", - normalizedSpec.c_str(), mapped.c_str()); - } + TNS_DEBUG(Esm, "[resolver][import-map] rewrite: %s -> %s", + normalizedSpec.c_str(), mapped.c_str()); normalizedSpec = mapped; } else { bool looksBare = !normalizedSpec.empty() && normalizedSpec[0] != '/' && normalizedSpec[0] != '.' && normalizedSpec.find("://") == std::string::npos && normalizedSpec.find('\\') == std::string::npos; - if (looksBare && IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( - "[resolver][import-map][miss] bare='%s' importMap.size=%lu", + if (looksBare) { + TNS_DEBUG( + Esm, "[resolver][import-map][miss] bare='%s' importMap.size=%lu", normalizedSpec.c_str(), (unsigned long)vocabulary.importMap.size()); } } @@ -2331,19 +2260,15 @@ v8::MaybeLocal ResolveModuleCallback( } const bool isWorker = IsCurrentIsolateWorker(isolate); - if (isWorker && IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver] Worker trying to resolve '%s'", spec.c_str()); - } + TNS_DEBUG(Esm, "[resolver] resolving '%s'", spec.c_str()); // Find the referrer's registered path so we can resolve relative specs // against its directory. std::string referrerPath = FindKeyForModule(*moduleState, isolate, referrer); bool specIsRelative = !spec.empty() && spec[0] == '.'; if (referrerPath.empty() && specIsRelative) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver] No referrer for relative '%s' - assuming app root", - spec.c_str()); - } + TNS_DEBUG(Esm, "[resolver] No referrer for relative '%s' - assuming app root", + spec.c_str()); referrerPath = GetApplicationPath() + "/index.mjs"; } @@ -2361,10 +2286,8 @@ v8::MaybeLocal ResolveModuleCallback( if (!resolvedHttp.empty() && (StartsWith(resolvedHttp, "http://") || StartsWith(resolvedHttp, "https://"))) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][http-rel] base=%s spec=%s -> %s", - referrerPath.c_str(), spec.c_str(), resolvedHttp.c_str()); - } + TNS_DEBUG(Esm, "[resolver][http-rel] base=%s spec=%s -> %s", + referrerPath.c_str(), spec.c_str(), resolvedHttp.c_str()); return LoadHttpModuleForUrl(isolate, context, resolvedHttp); } } else if (!referrerIsHttp && specIsRootAbs) { @@ -2386,10 +2309,8 @@ v8::MaybeLocal ResolveModuleCallback( std::string resolved = ResolveHttpRelative(refBase, spec); if (StartsWith(resolved, "http://") || StartsWith(resolved, "https://")) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][http-origin][fallback] origin=%s spec=%s -> %s", - refBase.c_str(), spec.c_str(), resolved.c_str()); - } + TNS_DEBUG(Esm, "[resolver][http-origin][fallback] origin=%s spec=%s -> %s", + refBase.c_str(), spec.c_str(), resolved.c_str()); return LoadHttpModuleForUrl(isolate, context, resolved); } } @@ -2404,10 +2325,8 @@ v8::MaybeLocal ResolveModuleCallback( std::string cleanSpec = spec.rfind("./", 0) == 0 ? spec.substr(2) : spec; std::string candidate = NormalizePath(baseDir + cleanSpec); candidateBases.push_back(candidate); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][normalize-rel] %s + %s -> %s", baseDir.c_str(), - cleanSpec.c_str(), candidate.c_str()); - } + TNS_DEBUG(Esm, "[resolver][normalize-rel] %s + %s -> %s", baseDir.c_str(), + cleanSpec.c_str(), candidate.c_str()); } else if (StartsWith(spec, "file://")) { // Absolute file URL. Handle the two virtual roots the runtime emits. std::string tail = spec.substr(7); @@ -2426,10 +2345,8 @@ v8::MaybeLocal ResolveModuleCallback( candidate = tail; } candidateBases.push_back(NormalizePath(candidate)); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][file-url] tail=%s -> %s", tail.c_str(), - candidateBases.back().c_str()); - } + TNS_DEBUG(Esm, "[resolver][file-url] tail=%s -> %s", tail.c_str(), + candidateBases.back().c_str()); } else if (!spec.empty() && spec[0] == '~') { std::string tail = spec.size() >= 2 && spec[1] == '/' ? spec.substr(2) : spec.substr(1); @@ -2438,10 +2355,8 @@ v8::MaybeLocal ResolveModuleCallback( // Also try appPath/app for projects that bundle JS under an app folder. std::string baseApp = NormalizePath(appPath + "/app/" + tail); if (baseApp != base) candidateBases.push_back(baseApp); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][tilde] spec=%s base=%s appBase=%s", spec.c_str(), - base.c_str(), baseApp.c_str()); - } + TNS_DEBUG(Esm, "[resolver][tilde] spec=%s base=%s appBase=%s", spec.c_str(), + base.c_str(), baseApp.c_str()); } else if (!spec.empty() && spec[0] == '/') { // Absolute path. Dynamic import may already have resolved a relative // specifier to a real filesystem path under the application root; use @@ -2449,9 +2364,7 @@ v8::MaybeLocal ResolveModuleCallback( // paths like /app/... or /src/... still resolve against appPath. if (!appPath.empty() && spec.rfind(appPath, 0) == 0) { candidateBases.push_back(NormalizePath(spec)); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][abs-fs] spec=%s", spec.c_str()); - } + TNS_DEBUG(Esm, "[resolver][abs-fs] spec=%s", spec.c_str()); } else { std::string base = NormalizePath(appPath + spec); candidateBases.push_back(base); @@ -2461,10 +2374,8 @@ v8::MaybeLocal ResolveModuleCallback( std::string baseNoApp = NormalizePath(appPath + tailNoApp); if (baseNoApp != base) candidateBases.push_back(baseNoApp); } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][abs] spec=%s base=%s", spec.c_str(), - base.c_str()); - } + TNS_DEBUG(Esm, "[resolver][abs] spec=%s base=%s", spec.c_str(), + base.c_str()); } } else { // Bare specifier — resolve relative to the application root. @@ -2493,9 +2404,7 @@ v8::MaybeLocal ResolveModuleCallback( } if (!(StartsWith(tail, "http://") || StartsWith(tail, "https://"))) return false; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver][http-embedded] %s -> %s", p.c_str(), tail.c_str()); - } + TNS_DEBUG(Esm, "[resolver][http-embedded] %s -> %s", p.c_str(), tail.c_str()); if (moduleOut != nullptr) { *moduleOut = LoadHttpModuleForUrl(isolate, context, tail); } @@ -2573,8 +2482,7 @@ v8::MaybeLocal ResolveModuleCallback( // JSON module: compile a synthetic ESM. if (EndsWith(absPath, ".json")) { - return CompileJsonAsEsModule(isolate, context, absPath, registryAbsPath, - isWorker); + return CompileJsonAsEsModule(isolate, context, absPath, registryAbsPath); } // Cache lookup. @@ -2595,10 +2503,8 @@ v8::MaybeLocal ResolveModuleCallback( if (!inCurrentStack) shouldReuse = false; } if (shouldReuse) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver] cache hit %s (status=%s)", absPath.c_str(), - ModuleStatusToString(status)); - } + TNS_DEBUG(Esm, "[resolver] cache hit %s (status=%s)", absPath.c_str(), + ModuleStatusToString(status)); return v8::MaybeLocal(existing); } if (!existing.IsEmpty() && status == v8::Module::kEvaluated) { @@ -2615,11 +2521,8 @@ v8::MaybeLocal ResolveModuleCallback( auto cycleIt = std::find(g_moduleResolutionStack.begin(), g_moduleResolutionStack.end(), registryAbsPath); if (cycleIt != g_moduleResolutionStack.end()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( - "[resolver] Detected recursive load for %s (stack len %lu)", - absPath.c_str(), (unsigned long)g_moduleResolutionStack.size()); - } + TNS_DEBUG(Esm, "[resolver] Detected recursive load for %s (stack len %lu)", + absPath.c_str(), (unsigned long)g_moduleResolutionStack.size()); auto existing = g_moduleRegistry.find(registryAbsPath); if (existing != g_moduleRegistry.end()) { return v8::MaybeLocal(existing->second.Get(isolate)); @@ -2636,9 +2539,7 @@ v8::MaybeLocal ResolveModuleCallback( } ResolutionStackGuard stackGuard(isolate, *moduleState, registryAbsPath); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[resolver] -> LoadESModule %s", absPath.c_str()); - } + TNS_DEBUG(Esm, "[resolver] -> LoadESModule %s", absPath.c_str()); try { tns::ModuleInternal::LoadESModule(isolate, absPath); } catch (NativeScriptException& ex) { @@ -2668,12 +2569,12 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, v8::Local context, const std::string& key, const std::string& requestUrl) { - if (IsScriptLoadingLogEnabled()) { + if (LogCategoryEnabled(LogCategory::Esm)) { auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState != nullptr && moduleState->registry.find(key) == moduleState->registry.end()) { - DEBUG_WRITE("[async-graph][fallback-sync-load] root missed walk: %s", - key.c_str()); + TNS_DEBUG(Esm, "[async-graph][fallback-sync-load] root missed walk: %s", + key.c_str()); } } v8::MaybeLocal modMaybe = @@ -2696,11 +2597,9 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, } if (IsModuleEvaluationInProgress(mod->GetStatus())) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( - "[dyn-import][http-loader] waiting on existing evaluation for %s status=%s", - key.c_str(), ModuleStatusToString(mod->GetStatus())); - } + TNS_DEBUG(Esm, + "[dyn-import][http-loader] waiting on existing evaluation for %s status=%s", + key.c_str(), ModuleStatusToString(mod->GetStatus())); return; } @@ -2755,10 +2654,10 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, ? info[0] : v8::Exception::Error(ArgConverter::ConvertToV8String( iso, "Evaluation failed (http-loader TLA)")); - if (IsScriptLoadingLogEnabled()) { + if (LogCategoryEnabled(LogCategory::Esm)) { v8::String::Utf8Value r(iso, reason); if (*r) { - DEBUG_WRITE("[dyn-import][http-loader][tla] rejected: %s", *r); + TNS_DEBUG(Esm, "[dyn-import][http-loader][tla] rejected: %s", *r); } } RejectHttpDynamicWaiters(iso, ctx, keyLocal, reason); @@ -2813,13 +2712,13 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( v8::String::Utf8Value specUtf8(isolate, specifier); const char* cSpec = (*specUtf8) ? *specUtf8 : ""; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import] -> %s", cSpec); + TNS_DEBUG(Esm, "[dyn-import] -> %s", cSpec); + if (LogCategoryEnabled(LogCategory::Esm)) { v8::Local resName = resource_name; if (!resName.IsEmpty() && resName->IsString()) { v8::String::Utf8Value rn(isolate, resName); if (*rn) { - DEBUG_WRITE("[dyn-import][referrer] %s", *rn); + TNS_DEBUG(Esm, "[dyn-import][referrer] %s", *rn); } } } @@ -2866,10 +2765,8 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } if (normalizedSpec != rawSpec) { specifier = ArgConverter::ConvertToV8String(isolate, normalizedSpec); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][normalize] %s -> %s", rawSpec.c_str(), - normalizedSpec.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][normalize] %s -> %s", rawSpec.c_str(), + normalizedSpec.c_str()); } v8::EscapableHandleScope scope(isolate); @@ -2888,20 +2785,17 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( std::string normalized = NormalizeViteSpecifier(normalizedSpec); if (!normalized.empty()) { mapped = LookupImportMap(vocabulary, normalized); - if (!mapped.empty() && IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][import-map] normalized: %s -> %s -> %s", - normalizedSpec.c_str(), normalized.c_str(), - mapped.c_str()); + if (!mapped.empty()) { + TNS_DEBUG(Esm, "[dyn-import][import-map] normalized: %s -> %s -> %s", + normalizedSpec.c_str(), normalized.c_str(), mapped.c_str()); } } } if (!mapped.empty()) { normalizedSpec = mapped; specifier = ArgConverter::ConvertToV8String(isolate, normalizedSpec); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][import-map] rewrite: %s -> %s", - rawSpec.c_str(), normalizedSpec.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][import-map] rewrite: %s -> %s", + rawSpec.c_str(), normalizedSpec.c_str()); } } @@ -2909,10 +2803,8 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( // Defensive guard: some dev-time toolchains emit a stray import('@') during // bootstrap. Treat it as a no-op module to avoid a hard failure. if (!normalizedSpec.empty() && normalizedSpec == "@") { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( - "[dyn-import] ignoring invalid '@' spec (returning empty module)"); - } + TNS_DEBUG(Esm, + "[dyn-import] ignoring invalid '@' spec (returning empty module)"); const char* kEmptySrc = "export {}\n"; std::string url = "file:///app/__invalid_at__.mjs"; v8::MaybeLocal modMaybe = @@ -2945,31 +2837,25 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( if (!normalizedSpec.empty() && StartsWith(normalizedSpec, "blob:nativescript/")) { const std::string blobRegistryKey = CanonicalizeRegistryKey(normalizedSpec); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][blob] trying blob URL %s key=%s", - normalizedSpec.c_str(), blobRegistryKey.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][blob] trying blob URL %s key=%s", + normalizedSpec.c_str(), blobRegistryKey.c_str()); auto existingIt = g_moduleRegistry.find(blobRegistryKey); if (existingIt != g_moduleRegistry.end()) { v8::Local existing = existingIt->second.Get(isolate); if (!existing.IsEmpty()) { v8::Module::Status existingStatus = existing->GetStatus(); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][blob-cache] hit %s status=%s", - blobRegistryKey.c_str(), - ModuleStatusToString(existingStatus)); - } + TNS_DEBUG(Esm, "[dyn-import][blob-cache] hit %s status=%s", + blobRegistryKey.c_str(), + ModuleStatusToString(existingStatus)); if (existingStatus == v8::Module::kErrored) { RemoveModuleFromRegistry(blobRegistryKey); } else if (IsModuleEvaluationInProgress(existingStatus)) { g_modulesInFlight.insert(blobRegistryKey); g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( - "[dyn-import][blob-await] queued waiter for %s status=%s", - blobRegistryKey.c_str(), ModuleStatusToString(existingStatus)); - } + TNS_DEBUG(Esm, + "[dyn-import][blob-await] queued waiter for %s status=%s", + blobRegistryKey.c_str(), ModuleStatusToString(existingStatus)); return scope.Escape(resolver->GetPromise()); } else { resolver->Resolve(context, existing->GetModuleNamespace()) @@ -2982,10 +2868,8 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } if (g_modulesInFlight.find(blobRegistryKey) != g_modulesInFlight.end()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][blob] coalesce in-flight %s", - blobRegistryKey.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][blob] coalesce in-flight %s", + blobRegistryKey.c_str()); g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); return scope.Escape(resolver->GetPromise()); } @@ -3001,9 +2885,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( ->Get(context, ArgConverter::ConvertToV8String(isolate, "URL")) .ToLocal(&urlCtorVal) || !urlCtorVal->IsFunction()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][blob] URL constructor not found"); - } + TNS_DEBUG(Esm, "[dyn-import][blob] URL constructor not found"); RejectHttpDynamicWaiters( isolate, context, blobRegistryKey, v8::Exception::Error(ArgConverter::ConvertToV8String( @@ -3018,9 +2900,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( "InternalAccessor")) .ToLocal(&internalAccessorVal) || !internalAccessorVal->IsObject()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][blob] URL.InternalAccessor not found"); - } + TNS_DEBUG(Esm, "[dyn-import][blob] URL.InternalAccessor not found"); RejectHttpDynamicWaiters( isolate, context, blobRegistryKey, v8::Exception::Error(ArgConverter::ConvertToV8String( @@ -3036,9 +2916,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( ArgConverter::ConvertToV8String(isolate, "getData")) .ToLocal(&getDataVal) || !getDataVal->IsFunction()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][blob] URL.InternalAccessor.getData not found"); - } + TNS_DEBUG(Esm, "[dyn-import][blob] URL.InternalAccessor.getData not found"); RejectHttpDynamicWaiters( isolate, context, blobRegistryKey, v8::Exception::Error(ArgConverter::ConvertToV8String( @@ -3053,10 +2931,8 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( if (!getDataFn->Call(context, internalAccessor, 1, &urlArg) .ToLocal(&blobDataVal) || blobDataVal->IsNullOrUndefined()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][blob] blob not found in BLOB_STORE: %s", - normalizedSpec.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][blob] blob not found in BLOB_STORE: %s", + normalizedSpec.c_str()); std::string msg = "Blob not found: " + normalizedSpec; RejectHttpDynamicWaiters( isolate, context, blobRegistryKey, @@ -3065,9 +2941,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } if (!blobDataVal->IsObject()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][blob] blob data is not an object"); - } + TNS_DEBUG(Esm, "[dyn-import][blob] blob data is not an object"); RejectHttpDynamicWaiters( isolate, context, blobRegistryKey, v8::Exception::Error( @@ -3081,9 +2955,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( ->Get(context, ArgConverter::ConvertToV8String(isolate, "blob")) .ToLocal(&blobVal) || !blobVal->IsObject()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][blob] blob property not found"); - } + TNS_DEBUG(Esm, "[dyn-import][blob] blob property not found"); RejectHttpDynamicWaiters( isolate, context, blobRegistryKey, v8::Exception::Error(ArgConverter::ConvertToV8String( @@ -3097,9 +2969,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( ->Get(context, ArgConverter::ConvertToV8String(isolate, "text")) .ToLocal(&textFnVal) || !textFnVal->IsFunction()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][blob] Blob.text() not available"); - } + TNS_DEBUG(Esm, "[dyn-import][blob] Blob.text() not available"); RejectHttpDynamicWaiters( isolate, context, blobRegistryKey, v8::Exception::Error(ArgConverter::ConvertToV8String( @@ -3133,9 +3003,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( textFailure = "Blob.text() did not return a thenable"; } if (!textFailure.empty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][blob] %s", textFailure.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][blob] %s", textFailure.c_str()); RejectHttpDynamicWaiters( isolate, context, blobRegistryKey, v8::Exception::Error( @@ -3172,10 +3040,8 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( v8::String::Utf8Value codeUtf8(iso, info[0]); std::string code = *codeUtf8 ? *codeUtf8 : ""; - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][blob] compiling blob module, code length=%zu", - code.size()); - } + TNS_DEBUG(Esm, "[dyn-import][blob] compiling blob module, code length=%zu", + code.size()); v8::MaybeLocal modMaybe = CompileModuleForResolveRegisterOnly(iso, ctx, code, d->blobUrl); @@ -3202,11 +3068,9 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } if (IsModuleEvaluationInProgress(mod->GetStatus())) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( - "[dyn-import][blob] waiting on existing evaluation for %s status=%s", - d->registryKey.c_str(), ModuleStatusToString(mod->GetStatus())); - } + TNS_DEBUG(Esm, + "[dyn-import][blob] waiting on existing evaluation for %s status=%s", + d->registryKey.c_str(), ModuleStatusToString(mod->GetStatus())); delete d; return; } @@ -3332,10 +3196,8 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( if (!normalizedSpec.empty() && (StartsWith(normalizedSpec, "http://") || StartsWith(normalizedSpec, "https://"))) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][http-loader] trying URL %s", - normalizedSpec.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][http-loader] trying URL %s", + normalizedSpec.c_str()); std::string key = CanonicalizeHttpUrlKey(normalizedSpec); // Volatile-pattern eviction: if the URL matches any configured volatile @@ -3347,18 +3209,14 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( if (isVolatile) { auto ex = g_moduleRegistry.find(key); if (ex != g_moduleRegistry.end()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][http-cache] drop volatile %s", key.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][http-cache] drop volatile %s", key.c_str()); RemoveModuleFromRegistry(key); } } // Coalesce concurrent dynamic imports for the same HTTP key. auto inflight = g_modulesInFlight.find(key) != g_modulesInFlight.end(); if (inflight) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][http] coalesce in-flight %s", key.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][http] coalesce in-flight %s", key.c_str()); g_httpDynamicWaiters[key].emplace_back(isolate, resolver); return scope.Escape(resolver->GetPromise()); } @@ -3367,37 +3225,29 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( if (itExisting != g_moduleRegistry.end()) { v8::Local existing = itExisting->second.Get(isolate); if (!existing.IsEmpty()) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][http-cache] hit %s status=%s", key.c_str(), - ModuleStatusToString(existing->GetStatus())); - } + TNS_DEBUG(Esm, "[dyn-import][http-cache] hit %s status=%s", key.c_str(), + ModuleStatusToString(existing->GetStatus())); v8::Module::Status st = existing->GetStatus(); if (st == v8::Module::kErrored) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][http-cache] dropping errored module for %s", - key.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][http-cache] dropping errored module for %s", + key.c_str()); RemoveModuleFromRegistry(key); } else if (IsModuleEvaluationInProgress(st)) { if (QueueHttpDynamicWaiterIfInFlight(isolate, key, existing, resolver)) { return scope.Escape(resolver->GetPromise()); } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( - "[dyn-import][http-cache] avoiding re-entrant Evaluate for %s status=%s", - key.c_str(), ModuleStatusToString(st)); - } + TNS_DEBUG(Esm, + "[dyn-import][http-cache] avoiding re-entrant Evaluate for %s status=%s", + key.c_str(), ModuleStatusToString(st)); resolver->Resolve(context, existing->GetModuleNamespace()) .FromMaybe(false); return scope.Escape(resolver->GetPromise()); } else { if (st != v8::Module::kEvaluated) { g_modulesInFlight.insert(key); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][http-cache] awaiting evaluation %s", - key.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][http-cache] awaiting evaluation %s", + key.c_str()); g_httpDynamicWaiters[key].emplace_back(isolate, resolver); if (st == v8::Module::kUninstantiated) { v8::TryCatch tcInstantiate(isolate); @@ -3470,11 +3320,12 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( : v8::Exception::Error( ArgConverter::ConvertToV8String( iso, "Evaluation failed (http-cache TLA)")); - if (IsScriptLoadingLogEnabled()) { + if (LogCategoryEnabled(LogCategory::Esm)) { v8::String::Utf8Value r(iso, reason); if (*r) { - DEBUG_WRITE("[dyn-import][http-cache][tla] rejected: %s", - *r); + TNS_DEBUG(Esm, + "[dyn-import][http-cache][tla] rejected: %s", + *r); } } RejectHttpDynamicWaiters(iso, ctx, keyLocal, reason); @@ -3545,23 +3396,20 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( std::string baseDir = slash == std::string::npos ? std::string() : refPath.substr(0, slash + 1); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][ref] url=%s base=%s spec=%s", refUrl.c_str(), - baseDir.c_str(), normalizedSpec.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][ref] url=%s base=%s spec=%s", refUrl.c_str(), + baseDir.c_str(), normalizedSpec.c_str()); std::string fsPath = NormalizePath(baseDir + normalizedSpec); if (!fsPath.empty()) { adjustedSpecifier = ArgConverter::ConvertToV8String(isolate, fsPath); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][normalize-rel] %s + %s -> %s", - baseDir.c_str(), normalizedSpec.c_str(), - fsPath.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import][normalize-rel] %s + %s -> %s", + baseDir.c_str(), normalizedSpec.c_str(), + fsPath.c_str()); } } - } else if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( + } else { + TNS_DEBUG( + Esm, "[dyn-import][ref] missing resource name; cannot normalize relative " "spec against referrer"); } @@ -3570,11 +3418,11 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( v8::TryCatch resolveTc(isolate); v8::MaybeLocal maybeModule = ResolveModuleCallback( context, adjustedSpecifier, import_assertions, refMod); - if (IsScriptLoadingLogEnabled()) { + if (LogCategoryEnabled(LogCategory::Esm)) { v8::String::Utf8Value adj(isolate, adjustedSpecifier); const char* cAdj = (*adj) ? *adj : ""; - DEBUG_WRITE("[dyn-import][resolver-call] raw=%s normalized=%s adjusted=%s", - rawSpec.c_str(), normalizedSpec.c_str(), cAdj); + TNS_DEBUG(Esm, "[dyn-import][resolver-call] raw=%s normalized=%s adjusted=%s", + rawSpec.c_str(), normalizedSpec.c_str(), cAdj); } v8::String::Utf8Value adjustedSpecUtf8(isolate, adjustedSpecifier); std::string adjustedRegistryKey = @@ -3601,10 +3449,8 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( v8::TryCatch ictc(isolate); if (!module->InstantiateModule(context, &ResolveModuleCallback) .FromMaybe(false)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import] instantiate failed %s", - normalizedSpec.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import] instantiate failed %s", + normalizedSpec.c_str()); std::string msg = std::string("Failed to instantiate module: ") + normalizedSpec; if (ictc.HasCaught()) { @@ -3627,13 +3473,11 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( resolver)) { return scope.Escape(resolver->GetPromise()); } - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( - "[dyn-import] avoiding re-entrant Evaluate for %s status=%s", - adjustedRegistryKey.empty() ? rawSpec.c_str() - : adjustedRegistryKey.c_str(), - ModuleStatusToString(module->GetStatus())); - } + TNS_DEBUG(Esm, + "[dyn-import] avoiding re-entrant Evaluate for %s status=%s", + adjustedRegistryKey.empty() ? rawSpec.c_str() + : adjustedRegistryKey.c_str(), + ModuleStatusToString(module->GetStatus())); resolver->Resolve(context, module->GetModuleNamespace()).Check(); return scope.Escape(resolver->GetPromise()); } @@ -3641,10 +3485,8 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( if (module->GetStatus() != v8::Module::kEvaluated) { v8::Local evalResult; if (!module->Evaluate(context).ToLocal(&evalResult)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import] evaluation failed %s", - normalizedSpec.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import] evaluation failed %s", + normalizedSpec.c_str()); std::string msg = std::string("Evaluation failed for module: ") + normalizedSpec; v8::Local ex = v8::Exception::Error( @@ -3673,9 +3515,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( v8::Local ctx = d->ctx.Get(iso); v8::Local modLocal = d->mod.Get(iso); v8::Local res = d->res.Get(iso); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import][tla] fulfilled, resolving namespace"); - } + TNS_DEBUG(Esm, "[dyn-import][tla] fulfilled, resolving namespace"); if (!res.IsEmpty()) res->Resolve(ctx, modLocal->GetModuleNamespace()).FromMaybe(false); delete d; @@ -3694,10 +3534,10 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( ? info[0] : v8::Exception::Error(ArgConverter::ConvertToV8String( iso, "Evaluation failed (TLA)")); - if (IsScriptLoadingLogEnabled()) { + if (LogCategoryEnabled(LogCategory::Esm)) { v8::String::Utf8Value r(iso, reason); if (*r) { - DEBUG_WRITE("[dyn-import][tla] rejected: %s", *r); + TNS_DEBUG(Esm, "[dyn-import][tla] rejected: %s", *r); } } if (!res.IsEmpty()) res->Reject(ctx, reason).FromMaybe(false); @@ -3726,11 +3566,9 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( v8::Local defVal; if (!o->Get(context, ArgConverter::ConvertToV8String(isolate, "default")) .ToLocal(&defVal)) { - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE( - "[dyn-import][verify] ns.default threw after eval (generic) %s", - normalizedSpec.c_str()); - } + TNS_DEBUG(Esm, + "[dyn-import][verify] ns.default threw after eval (generic) %s", + normalizedSpec.c_str()); resolver ->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String( isolate, "TDZ on default after eval (generic)"))) @@ -3739,14 +3577,10 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } } resolver->Resolve(context, module->GetModuleNamespace()).Check(); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import] resolved %s", normalizedSpec.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import] resolved %s", normalizedSpec.c_str()); } catch (NativeScriptException& ex) { ex.ReThrowToV8(); - if (IsScriptLoadingLogEnabled()) { - DEBUG_WRITE("[dyn-import] native failed %s", normalizedSpec.c_str()); - } + TNS_DEBUG(Esm, "[dyn-import] native failed %s", normalizedSpec.c_str()); resolver ->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String( isolate, "Native error during dynamic import"))) diff --git a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp index b1e04fe56..ba9943911 100644 --- a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp +++ b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp @@ -7,8 +7,10 @@ #include "ArgConverter.h" #include "BuiltinLoader.h" #include "HttpLoader.h" +#include "NativeScriptAssert.h" #include "Runtime.h" #include "RuntimeState.h" +#include "TraceLog.h" #include "console/Console.h" #include "robin_hood.h" @@ -39,8 +41,7 @@ constexpr Registration kRegistry[] = { {"node:util", BuiltinId::kNodeUtil}, }; -constexpr const char* kLogScriptLoadingKey = "logScriptLoading"; -constexpr const char* kHttpFetchUrlLogKey = "httpFetchUrlLog"; +constexpr const char* kDebugKey = "debug"; void ThrowTypeError(Isolate* isolate, const std::string& message) { isolate->ThrowException(Exception::TypeError(ArgConverter::ConvertToV8String(isolate, message))); @@ -57,16 +58,6 @@ bool EnsureMainIsolateWrite(Isolate* isolate, const std::string& key) { return true; } -bool ParseBooleanValue(Isolate* isolate, const FunctionCallbackInfo& info, - const std::string& key, bool* out) { - if (!info[1]->IsBoolean()) { - ThrowTypeError(isolate, "'" + key + "' must be a boolean"); - return false; - } - *out = info[1].As()->Value(); - return true; -} - void SetConfigCallback(const FunctionCallbackInfo& info) { Isolate* isolate = info.GetIsolate(); if (info.Length() < 2 || !info[0]->IsString()) { @@ -74,26 +65,28 @@ void SetConfigCallback(const FunctionCallbackInfo& info) { return; } std::string key = ArgConverter::ConvertToString(info[0].As()); - if (key == kLogScriptLoadingKey) { + if (key == kDebugKey) { if (!EnsureMainIsolateWrite(isolate, key)) { return; } - bool value = false; - if (!ParseBooleanValue(isolate, info, key, &value)) { + if (!info[1]->IsString()) { + ThrowTypeError(isolate, "'" + key + "' must be a comma-separated category string (" + + tns::AllLogCategoryNames() + + "), or '' to disable tracing"); return; } - tns::SetScriptLoadingLogEnabled(value); - return; - } - if (key == kHttpFetchUrlLogKey) { - if (!EnsureMainIsolateWrite(isolate, key)) { - return; - } - bool value = false; - if (!ParseBooleanValue(isolate, info, key, &value)) { - return; + // The list replaces the whole mask, so a caller never has to know what + // was already on to turn something off. + std::string value = ArgConverter::ConvertToString(info[1].As()); + bool hadUnknown = false; + uint32_t mask = tns::ParseLogCategories(value, &hadUnknown); + tns::SetEnabledLogCategories(mask); + if (hadUnknown) { + DEBUG_WRITE_FORCE( + "ns:runtime setConfig('debug', '%s'): ignoring unknown categories; valid " + "categories are %s", + value.c_str(), tns::AllLogCategoryNames().c_str()); } - tns::SetHttpFetchUrlLogEnabled(value); return; } ThrowTypeError(isolate, "Unknown runtime config key: '" + key + "'"); @@ -106,12 +99,9 @@ void GetConfigCallback(const FunctionCallbackInfo& info) { return; } std::string key = ArgConverter::ConvertToString(info[0].As()); - if (key == kLogScriptLoadingKey) { - info.GetReturnValue().Set(v8::Boolean::New(isolate, tns::IsScriptLoadingLogEnabled())); - return; - } - if (key == kHttpFetchUrlLogKey) { - info.GetReturnValue().Set(v8::Boolean::New(isolate, tns::IsHttpFetchUrlLogEnabled())); + if (key == kDebugKey) { + info.GetReturnValue().Set( + ArgConverter::ConvertToV8String(isolate, tns::EnabledLogCategoryNames())); return; } ThrowTypeError(isolate, "Unknown runtime config key: '" + key + "'"); diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index b366fde58..f009241ae 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -39,6 +39,7 @@ #include "SimpleAllocator.h" #include "SimpleProfiler.h" #include "StructuredClone.h" +#include "TraceLog.h" #include "URLImpl.h" #include "URLPatternImpl.h" #include "URLSearchParamsImpl.h" @@ -84,6 +85,9 @@ void LogAndAbortUncaught() { } void Runtime::Init(JavaVM* vm, void* reserved) { + // Before anything worth tracing runs, so NS_DEBUG covers boot itself. + tns::InitializeLogCategoriesFromEnvironment(); + __android_log_print(ANDROID_LOG_INFO, "TNS.Runtime", "NativeScript Runtime Version %s, commit %s", NATIVE_SCRIPT_RUNTIME_VERSION, @@ -261,7 +265,7 @@ void Runtime::Init(JNIEnv* env, jstring filesPath, jstring nativeLibDir, } JniLocalRef uncaughtErrorPolicy(env->GetObjectArrayElement( - args, (jsize)15 /* KnownKeys.UncaughtErrorPolicy */)); + args, (jsize)14 /* KnownKeys.UncaughtErrorPolicy */)); if (!uncaughtErrorPolicy.IsNull()) { auto policy = ArgConverter::jstringToString(uncaughtErrorPolicy); if (policy == "throw") { diff --git a/test-app/runtime/src/main/cpp/TraceLog.cpp b/test-app/runtime/src/main/cpp/TraceLog.cpp new file mode 100644 index 000000000..7f6d6d404 --- /dev/null +++ b/test-app/runtime/src/main/cpp/TraceLog.cpp @@ -0,0 +1,153 @@ +#include "TraceLog.h" + +#include + +#include +#include +#include +#include + +#include "NativeScriptAssert.h" + +namespace tns { + +namespace { + +// Index-aligned with tns::LogCategory; the only place a category name lives. +constexpr const char* kLogCategoryNames[] = {"esm", "fetch", "registry"}; +// One logcat tag per category, so `adb logcat -s TNS.esm` filters without +// matching message text. +constexpr const char* kLogCategoryTags[] = {"TNS.esm", "TNS.fetch", "TNS.registry"}; +constexpr size_t kLogCategoryCount = static_cast(LogCategory::kCount); +static_assert(sizeof(kLogCategoryNames) / sizeof(kLogCategoryNames[0]) == kLogCategoryCount, + "every LogCategory needs exactly one name"); +static_assert(sizeof(kLogCategoryTags) / sizeof(kLogCategoryTags[0]) == kLogCategoryCount, + "every LogCategory needs exactly one logcat tag"); + +void WriteDebugLine(LogCategory category, const char* message) { + size_t index = static_cast(category); + const char* tag = index < kLogCategoryCount ? kLogCategoryTags[index] : "TNS.Native"; + __android_log_print(ANDROID_LOG_DEBUG, tag, "%s", message); +} + +std::string TrimAsciiSpace(const std::string& value) { + size_t begin = value.find_first_not_of(" \t"); + if (begin == std::string::npos) { + return std::string(); + } + size_t end = value.find_last_not_of(" \t"); + return value.substr(begin, end - begin + 1); +} + +} // namespace + +const char* LogCategoryName(LogCategory category) { + size_t index = static_cast(category); + return index < kLogCategoryCount ? kLogCategoryNames[index] : "unknown"; +} + +std::string AllLogCategoryNames() { + std::string names; + for (size_t i = 0; i < kLogCategoryCount; ++i) { + if (!names.empty()) { + names += ","; + } + names += kLogCategoryNames[i]; + } + return names; +} + +uint32_t ParseLogCategories(const std::string& list, bool* hadUnknown) { + if (hadUnknown != nullptr) { + *hadUnknown = false; + } + + uint32_t mask = 0; + size_t start = 0; + while (start <= list.size()) { + size_t comma = list.find(',', start); + size_t length = comma == std::string::npos ? std::string::npos : comma - start; + std::string name = TrimAsciiSpace(list.substr(start, length)); + + if (!name.empty()) { + bool matched = false; + for (size_t i = 0; i < kLogCategoryCount; ++i) { + if (name == kLogCategoryNames[i]) { + mask |= 1u << i; + matched = true; + break; + } + } + if (!matched && hadUnknown != nullptr) { + *hadUnknown = true; + } + } + + if (comma == std::string::npos) { + break; + } + start = comma + 1; + } + return mask; +} + +std::string EnabledLogCategoryNames() { + uint32_t mask = g_enabledLogCategories.load(std::memory_order_relaxed); + std::string names; + for (size_t i = 0; i < kLogCategoryCount; ++i) { + if ((mask & (1u << i)) == 0) { + continue; + } + if (!names.empty()) { + names += ","; + } + names += kLogCategoryNames[i]; + } + return names; +} + +void SetEnabledLogCategories(uint32_t mask) { + g_enabledLogCategories.store(mask, std::memory_order_relaxed); +} + +void InitializeLogCategoriesFromEnvironment() { + const char* value = getenv("NS_DEBUG"); + if (value == nullptr || *value == '\0') { + return; + } + + bool hadUnknown = false; + SetEnabledLogCategories(ParseLogCategories(value, &hadUnknown)); + if (hadUnknown) { + DEBUG_WRITE_FORCE("NS_DEBUG: ignoring unknown categories in '%s'; valid categories are %s", + value, AllLogCategoryNames().c_str()); + } +} + +void EmitDebugLog(LogCategory category, const char* format, ...) { + va_list ap; + va_start(ap, format); + + char stackBuffer[1024]; + va_list apCopy; + va_copy(apCopy, ap); + int needed = vsnprintf(stackBuffer, sizeof(stackBuffer), format, apCopy); + va_end(apCopy); + + if (needed < 0) { + va_end(ap); + return; + } + + if (static_cast(needed) < sizeof(stackBuffer)) { + WriteDebugLine(category, stackBuffer); + } else { + std::vector heapBuffer(static_cast(needed) + 1); + vsnprintf(heapBuffer.data(), heapBuffer.size(), format, ap); + WriteDebugLine(category, heapBuffer.data()); + } + + va_end(ap); +} + +} // namespace tns diff --git a/test-app/runtime/src/main/cpp/TraceLog.h b/test-app/runtime/src/main/cpp/TraceLog.h new file mode 100644 index 000000000..cad96d3c7 --- /dev/null +++ b/test-app/runtime/src/main/cpp/TraceLog.h @@ -0,0 +1,75 @@ +#ifndef TEST_APP_TRACELOG_H +#define TEST_APP_TRACELOG_H + +#include +#include +#include + +namespace tns { + +/* + * Category-scoped debug tracing. + * + * A process-wide bitmask of enabled categories, tested inline at every call + * site, so a disabled category costs one relaxed load and a well-predicted + * branch. Present in every build: these are traces, and a release build that + * cannot be traced is a release build that cannot be diagnosed. Error and + * lifecycle logs are unconditional and do not belong here. + * + * Turned on by the NS_DEBUG environment variable (read once at process init) + * or by ns:runtime's `debug` config key. + */ +enum class LogCategory : uint8_t { + Esm, // module resolution, compilation, linking, evaluation + Fetch, // the HTTP module transport + Registry, // registry invalidation and dynamic-import cache bookkeeping + kCount +}; + +/* + * One bit per LogCategory. Written from process init and from main-isolate + * setConfig; read from every thread. Relaxed suffices -- a trace line racing a + * toggle changes nothing but that line. + */ +inline std::atomic g_enabledLogCategories{0}; + +inline bool LogCategoryEnabled(LogCategory category) { + return (g_enabledLogCategories.load(std::memory_order_relaxed) & + (1u << static_cast(category))) != 0; +} + +/* + * Writes one trace line under `category`, to that category's own logcat tag. + * Out of line so nothing but the enabled test lands at the call site. + */ +void EmitDebugLog(LogCategory category, const char* format, ...) + __attribute__((format(printf, 2, 3))); + +const char* LogCategoryName(LogCategory category); +// Every category name, comma separated -- for the "valid categories are ..." +// diagnostic. +std::string AllLogCategoryNames(); +// A comma-separated category list to a mask. Unknown names are skipped and +// reported through `hadUnknown` rather than failing the whole list. +uint32_t ParseLogCategories(const std::string& list, bool* hadUnknown); +// The canonical comma-separated list of the categories currently enabled. +std::string EnabledLogCategoryNames(); +void SetEnabledLogCategories(uint32_t mask); +// Applies NS_DEBUG. Call once, before anything worth tracing runs. +void InitializeLogCategoriesFromEnvironment(); + +/* + * A MACRO rather than a function or template on purpose: the arguments must + * not be evaluated unless the category is on, and call sites routinely build + * strings that cost far more than the line they would print. + */ +#define TNS_DEBUG(category, ...) \ + do { \ + if (tns::LogCategoryEnabled(tns::LogCategory::category)) [[unlikely]] { \ + tns::EmitDebugLog(tns::LogCategory::category, __VA_ARGS__); \ + } \ + } while (0) + +} // namespace tns + +#endif // TEST_APP_TRACELOG_H diff --git a/test-app/runtime/src/main/java/com/tns/AppConfig.java b/test-app/runtime/src/main/java/com/tns/AppConfig.java index ff0df0048..d56f32c0a 100644 --- a/test-app/runtime/src/main/java/com/tns/AppConfig.java +++ b/test-app/runtime/src/main/java/com/tns/AppConfig.java @@ -24,10 +24,8 @@ protected enum KnownKeys { DiscardUncaughtJsExceptions("discardUncaughtJsExceptions", false), EnableLineBreakpoins("enableLineBreakpoints", false), EnableMultithreadedJavascript("enableMultithreadedJavascript", false), - LogScriptLoading("logScriptLoading", false), // Appended last: native code reads this array by ordinal. - UncaughtErrorPolicy("uncaughtErrorPolicy", "report"), - HttpFetchUrlLog("httpFetchUrlLog", false); + UncaughtErrorPolicy("uncaughtErrorPolicy", "report"); private final String name; private final Object defaultValue; @@ -86,12 +84,6 @@ public AppConfig(File appDir) { String profiling = rootObject.getString(KnownKeys.Profiling.getName()); values[KnownKeys.Profiling.ordinal()] = profiling; } - if (rootObject.has(KnownKeys.LogScriptLoading.getName())) { - values[KnownKeys.LogScriptLoading.ordinal()] = rootObject.getBoolean(KnownKeys.LogScriptLoading.getName()); - } - if (rootObject.has(KnownKeys.HttpFetchUrlLog.getName())) { - values[KnownKeys.HttpFetchUrlLog.ordinal()] = rootObject.getBoolean(KnownKeys.HttpFetchUrlLog.getName()); - } if (rootObject.has(KnownKeys.DiscardUncaughtJsExceptions.getName())) { boolean discard = rootObject.getBoolean(KnownKeys.DiscardUncaughtJsExceptions.getName()); if (discard) { @@ -229,16 +221,6 @@ public boolean getEnableMultithreadedJavascript() { return (boolean)values[KnownKeys.EnableMultithreadedJavascript.ordinal()]; } - public boolean getLogScriptLoading() { - Object v = values[KnownKeys.LogScriptLoading.ordinal()]; - return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; - } - - public boolean getHttpFetchUrlLog() { - Object v = values[KnownKeys.HttpFetchUrlLog.ordinal()]; - return (v instanceof Boolean) ? ((Boolean)v).booleanValue() : false; - } - // Security conf /** diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 1fcce9083..e1947e1fc 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -293,29 +293,6 @@ public static boolean isDebuggable() { } } - // Expose logScriptLoading flag for native code without re-reading package.json - public static boolean getLogScriptLoadingEnabled() { - Runtime runtime = com.tns.Runtime.getCurrentRuntime(); - if (runtime != null && runtime.config != null && runtime.config.appConfig != null) { - return runtime.config.appConfig.getLogScriptLoading(); - } - if (staticConfiguration != null && staticConfiguration.appConfig != null) { - return staticConfiguration.appConfig.getLogScriptLoading(); - } - return false; - } - - public static boolean getHttpFetchUrlLogEnabled() { - Runtime runtime = com.tns.Runtime.getCurrentRuntime(); - if (runtime != null && runtime.config != null && runtime.config.appConfig != null) { - return runtime.config.appConfig.getHttpFetchUrlLog(); - } - if (staticConfiguration != null && staticConfiguration.appConfig != null) { - return staticConfiguration.appConfig.getHttpFetchUrlLog(); - } - return false; - } - // Security config /** From 1bf98527735c16d9570a5aa7638e03d50c8683cf Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 13:04:42 -0300 Subject: [PATCH 14/36] refactor(runtime): make the ESM resolver compile-and-register only ResolveModuleCallback no longer instantiates or evaluates anything: a disk dependency is read, compiled (ModuleInternal::CompileFileEsModule) and registered inline, and V8 drives graph discovery from the root's InstantiateModule. Cycles terminate through the registry via the module-map self-insert pattern - a back-edge finds the existing entry whatever its status. Evaluation happens once, at the root (ModuleInternal::LoadESModule), in spec order. Everything that compensated for resolver-order evaluation is deleted: the resolution stack and its RAII guard, the re-entry counters/parents/primary importers, modulesPendingReset, the moduleWaiters queue, and both HMR fallback registries (last-known-good serving) - a failed reload after invalidation now rejects loudly instead of silently serving a stale module. Dynamic-import coalescing (modulesInFlight + httpDynamicWaiters) stays. Registry entries are reused whatever their status - only kErrored is dropped and recompiled - and JSON modules are cached like any other module instead of being recompiled per resolve. The root registers under CanonicalizeRegistryKey, the same key derivation the resolver uses for dependencies, so a module reached as a root and as a dependency share one identity (and one import.meta.url). --- .../runtime/src/main/cpp/ModuleInternal.cpp | 113 +++--- .../runtime/src/main/cpp/ModuleInternal.h | 9 + .../src/main/cpp/ModuleInternalCallbacks.cpp | 380 +++--------------- .../src/main/cpp/ModuleInternalCallbacks.h | 13 +- 4 files changed, 133 insertions(+), 382 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 47ff16d92..5c494659a 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -641,13 +641,39 @@ Local ModuleInternal::LoadData(Isolate* isolate, const string& path) { return json; } +MaybeLocal ModuleInternal::CompileFileEsModule(Isolate* isolate, const std::string& path) { + string url = "file://" + path; + string content = Runtime::GetRuntime(isolate)->ReadFileText(path); + + Local sourceText = ArgConverter::ConvertToV8String(isolate, content); + + Local urlString; + if (!String::NewFromUtf8(isolate, url.c_str(), NewStringType::kNormal).ToLocal(&urlString)) { + throw NativeScriptException(string("Failed to create URL string for ES module ") + path); + } + + ScriptOrigin origin(urlString, 0, 0, false, -1, Local(), false, false, + true // ← is_module + ); + ScriptCompiler::Source source(sourceText, origin); + + return ScriptCompiler::CompileModule(isolate, &source); +} + +// The root entry point for an ES module graph: compile + register the root, +// then instantiate and evaluate it once. Dependencies are compiled and +// registered by ResolveModuleCallback while V8 walks the graph from here; +// nothing below the root evaluates on its own. Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& path) { auto context = isolate->GetCurrentContext(); const bool isHttpModule = IsHttpModulePath(path); - const std::string requestPath = isHttpModule ? NormalizeHttpModuleUrl(path) : path; + // The key the resolver would derive for this same module as someone's + // dependency. Keying the root by anything else mints a second identity for + // one file, so a cycle back to the root would not terminate on its entry. + const std::string canonicalPath = CanonicalizeRegistryKey(path); + const std::string requestPath = isHttpModule ? NormalizeHttpModuleUrl(path) : canonicalPath; Local module; - ScriptCompiler::CachedData* cacheData = nullptr; if (isHttpModule) { RunAsyncHttpModuleGraphLoadPumped(isolate, context, requestPath, 60.0); @@ -662,80 +688,74 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p throw NativeScriptException(message); } if (module->GetStatus() == Module::kEvaluated) { - UpdateModuleFallback(isolate, CanonicalizeHttpUrlKey(requestPath), module); return module->GetModuleNamespace(); } } else { - // 1) Prepare URL & source - string url = "file://" + path; - string content = Runtime::GetRuntime(isolate)->ReadFileText(path); - - Local sourceText = ArgConverter::ConvertToV8String(isolate, content); - - Local urlString; - if (!String::NewFromUtf8(isolate, url.c_str(), NewStringType::kNormal).ToLocal(&urlString)) { - throw NativeScriptException(string("Failed to create URL string for ES module ") + path); + auto* registryPtr = ModuleRegistryFor(isolate); + if (registryPtr == nullptr) { + return Local(); } + auto& g_moduleRegistry = *registryPtr; - ScriptOrigin origin(urlString, 0, 0, false, -1, Local(), false, false, - true // ← is_module - ); - ScriptCompiler::Source source(sourceText, origin, cacheData); + auto existingIt = g_moduleRegistry.find(canonicalPath); + if (existingIt != g_moduleRegistry.end()) { + Local existing = existingIt->second.Get(isolate); + Module::Status status = existing.IsEmpty() ? Module::kErrored : existing->GetStatus(); + if (status == Module::kErrored) { + RemoveModuleFromRegistry(canonicalPath); + } else if (status == Module::kEvaluated) { + return existing->GetModuleNamespace(); + } else if (status == Module::kUninstantiated || status == Module::kInstantiated) { + // Recompiling would mint a second module identity while importers still + // hold this one; reuse it and let InstantiateModule below no-op + // (kInstantiated) or link it (kUninstantiated). + module = existing; + } + } - // 2) Compile with its own TryCatch - { + if (module.IsEmpty()) { TryCatch tcCompile(isolate); - MaybeLocal maybeMod = ScriptCompiler::CompileModule( - isolate, &source, - cacheData ? ScriptCompiler::kConsumeCodeCache : ScriptCompiler::kNoCompileOptions); - - if (!maybeMod.ToLocal(&module)) { + if (!CompileFileEsModule(isolate, canonicalPath).ToLocal(&module)) { if (tcCompile.HasCaught()) { - throw NativeScriptException(tcCompile, "Cannot compile ES module " + path); + throw NativeScriptException(tcCompile, "Cannot compile ES module " + canonicalPath); } else { - throw NativeScriptException(string("Cannot compile ES module ") + path); + throw NativeScriptException(string("Cannot compile ES module ") + canonicalPath); } } - } - // 3) Register for resolution callback - auto* registryPtr = ModuleRegistryFor(isolate); - if (registryPtr == nullptr) { - return Local(); - } - auto& g_moduleRegistry = *registryPtr; - UnindexModuleForIsolate(isolate, path); - auto it = g_moduleRegistry.find(path); - if (it != g_moduleRegistry.end()) { - it->second.Reset(); + UnindexModuleForIsolate(isolate, canonicalPath); + auto it = g_moduleRegistry.find(canonicalPath); + if (it != g_moduleRegistry.end()) { + it->second.Reset(); + } + g_moduleRegistry[canonicalPath].Reset(isolate, module); + IndexModuleForIsolate(isolate, canonicalPath, module); } - g_moduleRegistry[path].Reset(isolate, module); - IndexModuleForIsolate(isolate, path, module); } - // 4) Instantiate (link) with ResolveModuleCallback + // Instantiate (link) with ResolveModuleCallback if (module->GetStatus() < Module::kInstantiated) { TryCatch tcLink(isolate); bool linked = module->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false); if (!linked) { if (tcLink.HasCaught()) { - throw NativeScriptException(tcLink, "Cannot instantiate module " + path); + throw NativeScriptException(tcLink, "Cannot instantiate module " + canonicalPath); } else { - throw NativeScriptException(string("Cannot instantiate module ") + path); + throw NativeScriptException(string("Cannot instantiate module ") + canonicalPath); } } } - // 5) Evaluate with its own TryCatch + // Evaluate with its own TryCatch Local result; { TryCatch tcEval(isolate); if (!module->Evaluate(context).ToLocal(&result)) { if (tcEval.HasCaught()) { - throw NativeScriptException(tcEval, "Cannot evaluate module " + path); + throw NativeScriptException(tcEval, "Cannot evaluate module " + canonicalPath); } else { - throw NativeScriptException(string("Cannot evaluate module ") + path); + throw NativeScriptException(string("Cannot evaluate module ") + canonicalPath); } } @@ -752,13 +772,13 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p if (state == Promise::kRejected) { Local reason = promise->Result(); isolate->ThrowException(reason); - throw NativeScriptException(PromiseRejectionMessage(isolate, promise, path)); + throw NativeScriptException(PromiseRejectionMessage(isolate, promise, canonicalPath)); } break; } if (std::chrono::steady_clock::now() >= deadline) { - throw NativeScriptException(string("Module evaluation promise timed out: ") + path); + throw NativeScriptException(string("Module evaluation promise timed out: ") + canonicalPath); } ALooper_pollOnce(10, nullptr, nullptr, nullptr); @@ -767,7 +787,6 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p } } - // 6) Return the namespace return module->GetModuleNamespace(); } diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.h b/test-app/runtime/src/main/cpp/ModuleInternal.h index d0eafc49c..54e9c0ffd 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.h +++ b/test-app/runtime/src/main/cpp/ModuleInternal.h @@ -41,6 +41,15 @@ class ModuleInternal { static bool IsESModule(const std::string& path); static v8::Local LoadESModule(v8::Isolate* isolate, const std::string& path); + /* + * Read + compile `path` as an ES module WITHOUT registering, instantiating or + * evaluating it. On compile failure the exception is left pending on the isolate + * (or a NativeScriptException is thrown for setup failures) and the result is empty. + * This is the resolver's file loader: the resolver must only ever hand V8 a + * compiled module — evaluation order belongs to V8. + */ + static v8::MaybeLocal CompileFileEsModule(v8::Isolate* isolate, const std::string& path); + static int MODULE_PROLOGUE_LENGTH; private: enum class ModulePathKind { diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 53740cc9d..ac598597c 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -223,11 +223,9 @@ static std::string ResolveHttpRelative(const std::string& referrerUrl, // Forward declarations for helpers referenced before their definitions. static bool ShouldTraceRegistryKey(const std::string& rawKey, const std::string& registryKey); -static std::string CanonicalizeRegistryKey(const std::string& key); static const char* ModuleStatusToString(v8::Module::Status status); static void KillAsyncGraphLoadsForIsolate(v8::Isolate* isolate); static bool IsCurrentIsolateWorker(v8::Isolate* isolate); -static std::string ExtractRelativePath(const std::string& path); static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, v8::Local context, const std::string& registryKey); @@ -274,9 +272,7 @@ struct LoaderVocabulary { // to static/thread destructors, where a post-disposal Reset would crash. // Access from the isolate's own thread only, per the slot contract. struct ModuleLoaderState { - ModuleHandleMap registry; // canonical key -> compiled module - ModuleHandleMap fallbackRegistry; // canonical key -> last good module - ModuleHandleMap fallbackByRelative; // relative path -> last good module + ModuleHandleMap registry; // canonical key -> compiled module // What the dev client taught THIS isolate's loader: import map, // canonicalization vocabulary, volatile patterns. @@ -289,21 +285,9 @@ struct ModuleLoaderState { // the slot destructor alone is not enough for them. std::vector> asyncGraphLoads; - // Active resolution stack, used to detect and short-circuit self-recursive - // module loads, plus the re-entry bookkeeping keyed by registry key. - std::vector resolutionStack; - robin_hood::unordered_map reentryCounts; - robin_hood::unordered_map> - reentryParents; - robin_hood::unordered_map primaryImporters; + // HTTP dynamic imports currently fetching/evaluating, for coalescing. robin_hood::unordered_set modulesInFlight; - robin_hood::unordered_set modulesPendingReset; - // Waiters: registry key -> Promise resolvers settled when the module - // finishes (instantiated/evaluated) or errors. - robin_hood::unordered_map>> - moduleWaiters; // Dynamic HTTP import waiters: resolve to the module namespace. robin_hood::unordered_map>> @@ -314,8 +298,7 @@ struct ModuleLoaderState { // instead of a scan of the whole registry. Hashes collide, so a bucket holds // candidates; FindKeyForModule confirms each against the registry and prunes // the ones it no longer backs, so a stale candidate can never answer a - // lookup. Covers `registry` only — the fallback maps are never looked up by - // handle. + // lookup. robin_hood::unordered_map> keysByModuleHash; }; @@ -617,7 +600,7 @@ static bool ShouldTraceRegistryKey(const std::string& rawKey, StartsWith(registryKey, "blob:"); } -static std::string CanonicalizeRegistryKey(const std::string& key) { +std::string CanonicalizeRegistryKey(const std::string& key) { if (key.empty()) return key; std::string registryKey; @@ -1460,25 +1443,6 @@ bool RunAsyncHttpModuleGraphLoadPumped(v8::Isolate* isolate, // ───────────────────────────────────────────────────────────── // Registry mutation + diagnostics -// Compute a relative path key for fallback lookup (mirrors iOS's helper). -// On Android there is no separate Documents directory — everything lives -// under the application path. -static std::string ExtractRelativePath(const std::string& path) { - std::string appPrefix = NormalizePath(GetApplicationPath()); - if (!appPrefix.empty()) { - std::string directPrefix = appPrefix + "/"; - if (path.rfind(directPrefix, 0) == 0) { - return path.substr(directPrefix.size()); - } - // Some code paths carry "…/app/…" twice (bundled app folder). - std::string appFolderPrefix = appPrefix + "/app/"; - if (path.rfind(appFolderPrefix, 0) == 0) { - return path.substr(appFolderPrefix.size()); - } - } - return ""; -} - static const char* ModuleStatusToString(v8::Module::Status status) { switch (status) { case v8::Module::kUninstantiated: @@ -1504,8 +1468,6 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) return; auto& g_moduleRegistry = moduleState->registry; - auto& g_moduleFallbackRegistry = moduleState->fallbackRegistry; - auto& g_moduleFallbackByRelative = moduleState->fallbackByRelative; const std::string registryKey = CanonicalizeRegistryKey(canonicalPath); // Defensive: never operate on an anomalous/sentinel key. @@ -1545,8 +1507,6 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { } size_t regPre = g_moduleRegistry.size(); - size_t fbPre = g_moduleFallbackRegistry.size(); - size_t relPre = g_moduleFallbackByRelative.size(); auto it = g_moduleRegistry.find(registryKey); if (it != g_moduleRegistry.end()) { @@ -1559,30 +1519,12 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { it->second.Reset(); g_moduleRegistry.erase(it); } else { - TNS_DEBUG( - Esm, - "[resolver][remove:miss] key not found, proceed to clear fallbacks (%s)", - registryKey.c_str()); - } - auto fb = g_moduleFallbackRegistry.find(registryKey); - if (fb != g_moduleFallbackRegistry.end()) { - fb->second.Reset(); - g_moduleFallbackRegistry.erase(fb); - } - std::string rel = ExtractRelativePath(registryKey); - if (!rel.empty()) { - auto fbr = g_moduleFallbackByRelative.find(rel); - if (fbr != g_moduleFallbackByRelative.end()) { - fbr->second.Reset(); - g_moduleFallbackByRelative.erase(fbr); - } + TNS_DEBUG(Esm, "[resolver][remove:miss] key not found (%s)", + registryKey.c_str()); } - TNS_DEBUG(Esm, "[resolver][remove:post] reg %lu->%lu fb %lu->%lu rel %lu->%lu", - (unsigned long)regPre, (unsigned long)g_moduleRegistry.size(), - (unsigned long)fbPre, (unsigned long)g_moduleFallbackRegistry.size(), - (unsigned long)relPre, - (unsigned long)g_moduleFallbackByRelative.size()); + TNS_DEBUG(Esm, "[resolver][remove:post] reg %lu->%lu", (unsigned long)regPre, + (unsigned long)g_moduleRegistry.size()); } std::vector GetLoadedModuleUrls() { @@ -1649,40 +1591,11 @@ void InvalidateModules(v8::Isolate* isolate, v8::Local context, (unsigned long)misses, (unsigned long)g_moduleRegistry.size()); } -void UpdateModuleFallback(v8::Isolate* isolate, - const std::string& canonicalPath, - v8::Local module) { - auto* moduleState = ModuleLoaderStateFor(isolate); - if (moduleState == nullptr) return; - auto& g_moduleFallbackRegistry = moduleState->fallbackRegistry; - auto& g_moduleFallbackByRelative = moduleState->fallbackByRelative; - auto fallbackIt = g_moduleFallbackRegistry.find(canonicalPath); - if (fallbackIt != g_moduleFallbackRegistry.end()) { - fallbackIt->second.Reset(); - } - if (!module.IsEmpty()) { - g_moduleFallbackRegistry[canonicalPath].Reset(isolate, module); - TNS_DEBUG(Esm, "[resolver] fallback updated for %s from evaluated module", - canonicalPath.c_str()); - std::string relative = ExtractRelativePath(canonicalPath); - if (!relative.empty()) { - auto relativeIt = g_moduleFallbackByRelative.find(relative); - if (relativeIt != g_moduleFallbackByRelative.end()) { - relativeIt->second.Reset(); - } - g_moduleFallbackByRelative[relative].Reset(isolate, module); - TNS_DEBUG(Esm, "[resolver] fallback relative updated for %s", - relative.c_str()); - } - } -} - // ───────────────────────────────────────────────────────────── // Resolver state // -// The resolution stack, re-entry bookkeeping and waiter lists live in -// ModuleLoaderState (per isolate, in a RuntimeState slot). -static constexpr size_t kMaxModuleReentryCount = 256; +// The dynamic-import in-flight set and waiter lists live in ModuleLoaderState +// (per isolate, in a RuntimeState slot). static bool IsModuleEvaluationInProgress(v8::Module::Status status) { return status == v8::Module::kInstantiating || @@ -1731,25 +1644,6 @@ static void RejectResolversWithReason( } } -static bool QueueModuleWaiterIfInFlight(v8::Isolate* isolate, - const std::string& registryKey, - v8::Local module, - v8::Local resolver) { - auto* moduleState = ModuleLoaderStateFor(isolate); - if (moduleState == nullptr) return false; - auto& g_modulesInFlight = moduleState->modulesInFlight; - if (registryKey.empty() || module.IsEmpty() || - !IsModuleEvaluationInProgress(module->GetStatus()) || - g_modulesInFlight.find(registryKey) == g_modulesInFlight.end()) { - return false; - } - moduleState->moduleWaiters[registryKey].emplace_back(isolate, resolver); - TNS_DEBUG(Esm, "[dyn-import][await] queued module waiter for %s status=%s", - registryKey.c_str(), - ModuleStatusToString(module->GetStatus())); - return true; -} - static bool QueueHttpDynamicWaiterIfInFlight( v8::Isolate* isolate, const std::string& registryKey, v8::Local module, v8::Local resolver) { @@ -1795,37 +1689,6 @@ static v8::Local BuildModuleFailureReason(v8::Isolate* isolate, return v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, message)); } -static void ResolveModuleWaiters(v8::Isolate* isolate, - v8::Local context, - const std::string& registryKey, - v8::Local module) { - auto* moduleState = ModuleLoaderStateFor(isolate); - if (moduleState == nullptr) return; - auto& g_moduleWaiters = moduleState->moduleWaiters; - auto waitIt = g_moduleWaiters.find(registryKey); - if (waitIt == g_moduleWaiters.end()) return; - std::vector> resolvers; - resolvers.swap(waitIt->second); - g_moduleWaiters.erase(waitIt); - ResolveResolversWithModuleNamespace(isolate, context, resolvers, module, - registryKey); -} - -static void RejectModuleWaiters(v8::Isolate* isolate, - v8::Local context, - const std::string& registryKey, - v8::Local reason) { - auto* moduleState = ModuleLoaderStateFor(isolate); - if (moduleState == nullptr) return; - auto& g_moduleWaiters = moduleState->moduleWaiters; - auto waitIt = g_moduleWaiters.find(registryKey); - if (waitIt == g_moduleWaiters.end()) return; - std::vector> resolvers; - resolvers.swap(waitIt->second); - g_moduleWaiters.erase(waitIt); - RejectResolversWithReason(isolate, context, resolvers, reason); -} - static void ResolveHttpDynamicWaiters(v8::Isolate* isolate, v8::Local context, const std::string& registryKey, @@ -1883,21 +1746,8 @@ static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, const std::string& registryKey) { auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) return; - auto& g_moduleWaiters = moduleState->moduleWaiters; auto& g_httpDynamicWaiters = moduleState->httpDynamicWaiters; - moduleState->reentryCounts.erase(registryKey); - moduleState->reentryParents.erase(registryKey); - moduleState->primaryImporters.erase(registryKey); moduleState->modulesInFlight.erase(registryKey); - moduleState->modulesPendingReset.erase(registryKey); - - auto waitIt = g_moduleWaiters.find(registryKey); - if (waitIt != g_moduleWaiters.end()) { - std::vector> resolvers; - resolvers.swap(waitIt->second); - g_moduleWaiters.erase(waitIt); - RejectResolversForInvalidation(isolate, context, resolvers, registryKey); - } auto dynamicWaitIt = g_httpDynamicWaiters.find(registryKey); if (dynamicWaitIt != g_httpDynamicWaiters.end()) { @@ -1910,104 +1760,6 @@ static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, registryKey.c_str()); } -namespace { -struct ResolutionStackGuard { - ResolutionStackGuard(v8::Isolate* isolate, ModuleLoaderState& state, - const std::string& entry) - : isolate_(isolate), - state_(state), - stack_(state.resolutionStack), - entry_(entry), - active_(true) { - stack_.push_back(entry_); - state_.reentryCounts[entry_] = 0; - state_.reentryParents.erase(entry_); - if (stack_.size() > 1) { - state_.primaryImporters[entry_] = stack_[stack_.size() - 2]; - } else { - state_.primaryImporters.erase(entry_); - } - state_.modulesInFlight.insert(entry_); - state_.modulesPendingReset.erase(entry_); - TNS_DEBUG(Esm, "[resolver][stack] push (%lu) %s", - static_cast(stack_.size()), entry_.c_str()); - } - - ~ResolutionStackGuard() { - if (!active_ || stack_.empty()) return; - auto& g_moduleRegistry = state_.registry; - auto& g_moduleFallbackRegistry = state_.fallbackRegistry; - auto& g_moduleWaiters = state_.moduleWaiters; - auto& g_modulesPendingReset = state_.modulesPendingReset; - TNS_DEBUG(Esm, "[resolver][stack] pop (%lu) %s", - static_cast(stack_.size()), entry_.c_str()); - state_.reentryCounts.erase(entry_); - state_.reentryParents.erase(entry_); - state_.primaryImporters.erase(entry_); - state_.modulesInFlight.erase(entry_); - - v8::Module::Status finalStatus = v8::Module::kErrored; - auto regIt = g_moduleRegistry.find(entry_); - if (regIt != g_moduleRegistry.end()) { - v8::Local m = regIt->second.Get(isolate_); - if (!m.IsEmpty()) finalStatus = m->GetStatus(); - } - bool isError = finalStatus == v8::Module::kErrored; - auto waitIt = g_moduleWaiters.find(entry_); - if (waitIt != g_moduleWaiters.end()) { - v8::Local currentContext = isolate_->GetCurrentContext(); - if (isError || regIt == g_moduleRegistry.end()) { - std::string msg = "Module evaluation failed: " + entry_; - RejectModuleWaiters( - isolate_, currentContext, entry_, - v8::Exception::Error(ArgConverter::ConvertToV8String(isolate_, msg))); - } else { - v8::Local resolvedModule = regIt->second.Get(isolate_); - ResolveModuleWaiters(isolate_, currentContext, entry_, resolvedModule); - } - } - stack_.pop_back(); - auto pendingIt = g_modulesPendingReset.find(entry_); - if (pendingIt != g_modulesPendingReset.end()) { - auto it = g_moduleRegistry.find(entry_); - if (it != g_moduleRegistry.end()) { - v8::Local module = it->second.Get(isolate_); - v8::Module::Status status = - module.IsEmpty() ? v8::Module::kErrored : module->GetStatus(); - if (status != v8::Module::kEvaluated && status != v8::Module::kErrored) { - TNS_DEBUG(Esm, - "[resolver] dropping incomplete module after unwind %s (status=%s)", - entry_.c_str(), ModuleStatusToString(status)); - RemoveModuleFromRegistry(entry_); - } - } - g_modulesPendingReset.erase(pendingIt); - } - - auto activeIt = g_moduleRegistry.find(entry_); - if (activeIt != g_moduleRegistry.end()) { - v8::Local activeModule = activeIt->second.Get(isolate_); - if (!activeModule.IsEmpty() && - activeModule->GetStatus() == v8::Module::kEvaluated) { - g_moduleFallbackRegistry[entry_].Reset(isolate_, activeModule); - TNS_DEBUG(Esm, - "[resolver] updated fallback module for %s after successful evaluation", - entry_.c_str()); - } - } - } - - void Release() { active_ = false; } - - private: - v8::Isolate* isolate_; - ModuleLoaderState& state_; - std::vector& stack_; - std::string entry_; - bool active_; -}; -} // namespace - // ───────────────────────────────────────────────────────────── // JSON module → synthetic ES module @@ -2021,6 +1773,21 @@ static v8::MaybeLocal CompileJsonAsEsModule( return v8::MaybeLocal(); } auto& g_moduleRegistry = moduleState->registry; + + // JSON modules are compiled eagerly to kEvaluated, so a registered entry is + // complete and must be reused — recompiling would mint a second module + // identity (and namespace) for the same file on every resolve. + auto existingIt = g_moduleRegistry.find(registryAbsPath); + if (existingIt != g_moduleRegistry.end()) { + v8::Local existing = existingIt->second.Get(isolate); + if (!existing.IsEmpty() && existing->GetStatus() == v8::Module::kEvaluated) { + return v8::MaybeLocal(existing); + } + UnindexRegistryKey(*moduleState, isolate, registryAbsPath); + existingIt->second.Reset(); + g_moduleRegistry.erase(existingIt); + } + TNS_DEBUG(Esm, "[resolver][json] wrapping %s", absPath.c_str()); std::string jsonText = Runtime::GetRuntime(isolate)->ReadFileText(absPath); @@ -2181,8 +1948,6 @@ v8::MaybeLocal ResolveModuleCallback( return v8::MaybeLocal(); } auto& g_moduleRegistry = moduleState->registry; - auto& g_moduleFallbackRegistry = moduleState->fallbackRegistry; - auto& g_moduleResolutionStack = moduleState->resolutionStack; v8::String::Utf8Value specUtf8(isolate, specifier); const std::string rawSpec = *specUtf8 ? *specUtf8 : ""; @@ -2485,63 +2250,39 @@ v8::MaybeLocal ResolveModuleCallback( return CompileJsonAsEsModule(isolate, context, absPath, registryAbsPath); } - // Cache lookup. + // Reuse any live, non-errored registry entry. The resolver never evaluates, + // so an unfinished entry (kUninstantiated / kInstantiating / kEvaluating) + // simply rejoins the graph V8 is currently linking — that is how import + // cycles terminate, the same way Node/Blink break them with the module-map + // self-insert. auto it = g_moduleRegistry.find(registryAbsPath); if (it != g_moduleRegistry.end()) { v8::Local existing = it->second.Get(isolate); - v8::Module::Status status = - existing.IsEmpty() ? v8::Module::kErrored : existing->GetStatus(); - bool inCurrentStack = - std::find(g_moduleResolutionStack.begin(), - g_moduleResolutionStack.end(), - registryAbsPath) != g_moduleResolutionStack.end(); - bool shouldReuse = !existing.IsEmpty() && status != v8::Module::kErrored; - if (shouldReuse && - (status == v8::Module::kUninstantiated || - status == v8::Module::kInstantiating || - status == v8::Module::kEvaluating)) { - if (!inCurrentStack) shouldReuse = false; - } - if (shouldReuse) { + if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { TNS_DEBUG(Esm, "[resolver] cache hit %s (status=%s)", absPath.c_str(), - ModuleStatusToString(status)); + ModuleStatusToString(existing->GetStatus())); return v8::MaybeLocal(existing); } - if (!existing.IsEmpty() && status == v8::Module::kEvaluated) { - auto fallbackIt = g_moduleFallbackRegistry.find(registryAbsPath); - if (fallbackIt != g_moduleFallbackRegistry.end()) { - fallbackIt->second.Reset(); - } - g_moduleFallbackRegistry[registryAbsPath].Reset(isolate, existing); - } RemoveModuleFromRegistry(absPath); } - // Detect recursive load prior to LoadESModule. - auto cycleIt = std::find(g_moduleResolutionStack.begin(), - g_moduleResolutionStack.end(), registryAbsPath); - if (cycleIt != g_moduleResolutionStack.end()) { - TNS_DEBUG(Esm, "[resolver] Detected recursive load for %s (stack len %lu)", - absPath.c_str(), (unsigned long)g_moduleResolutionStack.size()); - auto existing = g_moduleRegistry.find(registryAbsPath); - if (existing != g_moduleRegistry.end()) { - return v8::MaybeLocal(existing->second.Get(isolate)); - } - if (IsDebuggable()) { - DEBUG_WRITE("[resolver] Debug mode - empty return for recursive load: %s", - absPath.c_str()); + // Compile + register only — never instantiate or evaluate here. V8 is + // instantiating the importer and continues the graph walk by resolving this + // module's own requests next; evaluating inside the resolver would run + // dependencies in resolver order instead of the spec's evaluation order. + TNS_DEBUG(Esm, "[resolver] -> compile-register %s", absPath.c_str()); + try { + v8::Local mod; + if (!tns::ModuleInternal::CompileFileEsModule(isolate, absPath) + .ToLocal(&mod)) { + // The compile exception is pending on the isolate; V8 fails the + // importer's instantiation with it. return v8::MaybeLocal(); } - std::string msg = "Recursive module resolution detected for " + absPath; - isolate->ThrowException( - v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); - } - - ResolutionStackGuard stackGuard(isolate, *moduleState, registryAbsPath); - TNS_DEBUG(Esm, "[resolver] -> LoadESModule %s", absPath.c_str()); - try { - tns::ModuleInternal::LoadESModule(isolate, absPath); + UnindexRegistryKey(*moduleState, isolate, registryAbsPath); + g_moduleRegistry[registryAbsPath].Reset(isolate, mod); + IndexRegisteredModule(*moduleState, registryAbsPath, mod); + return v8::MaybeLocal(mod); } catch (NativeScriptException& ex) { if (isWorker) { DEBUG_WRITE("[resolver] Worker failed to compile '%s' -> '%s'", @@ -2550,11 +2291,6 @@ v8::MaybeLocal ResolveModuleCallback( ex.ReThrowToV8(); return v8::MaybeLocal(); } - auto it2 = g_moduleRegistry.find(registryAbsPath); - if (it2 == g_moduleRegistry.end()) { - return v8::MaybeLocal(); - } - return v8::MaybeLocal(it2->second.Get(isolate)); } // ───────────────────────────────────────────────────────────── @@ -3424,10 +3160,6 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( TNS_DEBUG(Esm, "[dyn-import][resolver-call] raw=%s normalized=%s adjusted=%s", rawSpec.c_str(), normalizedSpec.c_str(), cAdj); } - v8::String::Utf8Value adjustedSpecUtf8(isolate, adjustedSpecifier); - std::string adjustedRegistryKey = - *adjustedSpecUtf8 ? CanonicalizeRegistryKey(*adjustedSpecUtf8) - : std::string(); if (maybeModule.IsEmpty()) { if (resolveTc.HasCaught()) { resolver->Reject(context, resolveTc.Exception()).FromMaybe(false); @@ -3468,20 +3200,10 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } } - if (IsModuleEvaluationInProgress(module->GetStatus())) { - if (QueueModuleWaiterIfInFlight(isolate, adjustedRegistryKey, module, - resolver)) { - return scope.Escape(resolver->GetPromise()); - } - TNS_DEBUG(Esm, - "[dyn-import] avoiding re-entrant Evaluate for %s status=%s", - adjustedRegistryKey.empty() ? rawSpec.c_str() - : adjustedRegistryKey.c_str(), - ModuleStatusToString(module->GetStatus())); - resolver->Resolve(context, module->GetModuleNamespace()).Check(); - return scope.Escape(resolver->GetPromise()); - } - + // A kEvaluating module (TLA in flight, or a cycle re-entry) falls through + // deliberately: Evaluate() on an already-evaluating module returns its + // existing top-level capability promise, so the TLA chain below coalesces + // this import with the in-flight evaluation. if (module->GetStatus() != v8::Module::kEvaluated) { v8::Local evalResult; if (!module->Evaluate(context).ToLocal(&evalResult)) { diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index 2f1dc148b..0849918da 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -16,6 +16,13 @@ namespace tns { using ModuleHandleMap = robin_hood::unordered_map>; +// The registry key for `key`, whatever form it arrives in (filesystem path, +// file:// URL, http(s) URL, blob:, or a custom scheme such as node:). Every +// registry read and write goes through this, so a module reached as a root and +// the same module reached as someone's dependency land on one entry — and one +// identity for import.meta. +std::string CanonicalizeRegistryKey(const std::string& key); + // Per-isolate module registry accessor: map canonical keys → compiled // v8::Module handles for `isolate`. Keyed by v8::Isolate* (not thread) because // v8::Global handles are isolate-bound; see the long-form comment @@ -98,12 +105,6 @@ bool RunAsyncHttpModuleGraphLoadPumped(v8::Isolate* isolate, // outstanding. bool HasPendingAsyncModuleGraphWork(); -// Keep a fallback copy of the last evaluated module so it could be served -// while reloading if needed. -void UpdateModuleFallback(v8::Isolate* isolate, - const std::string& canonicalPath, - v8::Local module); - // Drop exact URL-keyed modules from the registry and clear any in-flight // invalidation bookkeeping tied to those canonical keys. void InvalidateModules(v8::Isolate* isolate, v8::Local context, From 2d7f3fe06cf18bdb2d1a38dcaa3f33ac26bcd9dc Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 13:23:45 -0300 Subject: [PATCH 15/36] feat(runtime): scheme-agnostic module graph discovery The pre-instantiation walk now runs for every ES-module root - boot entries, require(esm) roots, and dynamic import() - with per-edge dispatch: local files are read, compiled and registered inline during discovery; HTTP edges fetch concurrently on the async pipeline; builtins and unmapped bare specifiers stay on the resolver's lazy path. A local root's graph can contain HTTP edges and vice versa. Disk-only graphs complete discovery inline and pay no pump iteration. One shared ResolveSpecifierToPath now makes every resolution decision (import map, HTTP referrer/origin anchoring, filesystem candidates and extension probing, node: polyfill fallback) for both the walk and ResolveModuleCallback - parity by construction. The resolver itself is a thin dispatch over the classified result. Fetch completions are delivered as nestable v8 platform tasks posted through the isolate's own event loop instead of a raw internal-lane hop, so background-thread import() lands on the right loop and pumps can drive completions via RunNestableV8Tasks. Teardown quiesces in-flight loads before the event loop shuts down, so a task the stopped loop rejects holds only already-Reset Globals. The synchronous fetch in LoadHttpModuleForUrl survives only as an anomaly guard: it logs unconditionally in both builds when the walk missed an edge, and is slated for deletion once the dev-server smoke test proves coverage. --- test-app/runtime/src/main/cpp/HttpLoader.h | 4 +- .../runtime/src/main/cpp/ModuleInternal.cpp | 19 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 957 +++++++++++------- .../src/main/cpp/ModuleInternalCallbacks.h | 60 +- test-app/runtime/src/main/cpp/Runtime.cpp | 15 +- 5 files changed, 632 insertions(+), 423 deletions(-) diff --git a/test-app/runtime/src/main/cpp/HttpLoader.h b/test-app/runtime/src/main/cpp/HttpLoader.h index 4f3172175..dc3f2ff74 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.h +++ b/test-app/runtime/src/main/cpp/HttpLoader.h @@ -26,7 +26,7 @@ namespace tns { // fallback path (V8's ResolveModuleCallback is synchronous — still // true as of 14.9.207.39 — so the fallback must be native), // - the async background-thread fetch behind the phase-1 module-graph -// walk (StartAsyncHttpModuleGraphLoad), which is how module bodies +// walk (StartModuleGraphLoad), which is how module bodies // normally arrive, // - eviction plumbing (an eviction-driven fetch nonce that defeats // any HTTP cache layer between the runtime and the origin), @@ -79,7 +79,7 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status); // Asynchronous single-URL module body fetch — the I/O primitive behind the -// phase-1 module-graph walk (see StartAsyncHttpModuleGraphLoad in +// phase-1 module-graph walk (see StartModuleGraphLoad in // ModuleInternalCallbacks.h). Same semantics as HttpFetchText, minus the // JS-thread block: // - security gate (IsRemoteUrlAllowed) checked up front, diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 5c494659a..1639aa2e8 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -676,7 +676,7 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p Local module; if (isHttpModule) { - RunAsyncHttpModuleGraphLoadPumped(isolate, context, requestPath, 60.0); + RunModuleGraphLoadPumped(isolate, context, requestPath, 60.0); MaybeLocal maybeMod = LoadHttpModuleForUrl(isolate, context, requestPath); if (!maybeMod.ToLocal(&module)) { std::string reason = TakeLastHttpFetchErrorReason(); @@ -713,6 +713,23 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p } } + if (module.IsEmpty()) { + // Discovery pre-pass for local roots too: a local graph can reach HTTP + // edges, and without this they hit the resolver cold and fetch serially, + // one blocking request at a time. The walk compiles and registers the + // whole closure up front — including this root — so instantiation + // resolves as pure lookup. A graph with no HTTP edges settles inside the + // call and pays no wait. + RunModuleGraphLoadPumped(isolate, context, canonicalPath, 60.0); + auto walkedIt = g_moduleRegistry.find(canonicalPath); + if (walkedIt != g_moduleRegistry.end()) { + Local walked = walkedIt->second.Get(isolate); + if (!walked.IsEmpty() && walked->GetStatus() != Module::kErrored) { + module = walked; + } + } + } + if (module.IsEmpty()) { TryCatch tcCompile(isolate); if (!CompileFileEsModule(isolate, canonicalPath).ToLocal(&module)) { diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index ac598597c..93594227c 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -20,11 +20,13 @@ #include "ArgConverter.h" #include "Constants.h" +#include "EventLoop.h" #include "HttpLoader.h" #include "JEnv.h" #include "ModuleInternal.h" #include "NativeScriptAssert.h" #include "NativeScriptException.h" +#include "NativeScriptPlatform.h" #include "NsBuiltinModules.h" #include "Runtime.h" #include "RuntimeState.h" @@ -664,6 +666,16 @@ v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, RemoveModuleFromRegistry(registryKey); } + // Reaching this point means the graph walk did not discover this URL, so the + // module is about to be fetched synchronously, blocking the JS thread for a + // whole round trip. That is an invariant violation, not a mode — always + // visible, in every build, so it cannot hide behind a disabled trace + // category. The fallback itself stays: correctness first, diagnosis loud. + DEBUG_WRITE_FORCE( + "NativeScript: module graph walk missed %s — falling back to a blocking " + "synchronous fetch. This should not happen; please report it.", + requestedUrl.c_str()); + std::string body; std::string contentType; int status = 0; @@ -1080,6 +1092,322 @@ static std::string LookupImportMap(const LoaderVocabulary& vocabulary, return ""; } +// ───────────────────────────────────────────────────────────── +// The shared resolution seam +// +// One module specifier resolved to something the loader can act on. Both +// ResolveModuleCallback and the graph walk go through this, so a module gets +// the same registry key whichever of them reaches it first — a divergence here +// mints two identities for one file. +// +// It consults the import map and the filesystem but never compiles, registers, +// fetches or throws. The one V8 touch is the `__NS_HTTP_ORIGIN__` global read +// for root-absolute specifiers, which is why `context` is a parameter: both +// callers must see the same anchor or they would classify the same specifier +// differently. +struct ModuleResolution { + enum class Kind { + kUnresolved, // nothing locatable; the caller decides how to report it + kBuiltin, // ns:/node: — served from the builtin registry + kNodePolyfill, // node: name with no builtin and no file — in-memory shim + kHttp, // absolute http(s) URL + kFile, // absolute filesystem path, confirmed to be a regular file + }; + + Kind kind = Kind::kUnresolved; + std::string url; // kHttp + std::string path; // kFile + std::string specifier; // the specifier after import-map rewriting + std::string attempted; // kUnresolved: the last candidate tried +}; + +// Rebuild an HTTP URL a path join swallowed ('/app/http:/host/x' → +// 'http://host/x'), or empty when the path embeds none. +static std::string HttpUrlEmbeddedInPath(const std::string& p) { + size_t pos1 = p.find("/http:/"); + size_t pos2 = p.find("/https:/"); + size_t pos = std::min(pos1 == std::string::npos ? SIZE_MAX : pos1, + pos2 == std::string::npos ? SIZE_MAX : pos2); + if (pos == SIZE_MAX) return ""; + std::string tail = p.substr(pos + 1); + if (StartsWith(tail, "http:/") && !StartsWith(tail, "http://")) { + tail.insert(5, "/"); + } else if (StartsWith(tail, "https:/") && !StartsWith(tail, "https://")) { + tail.insert(6, "/"); + } + if (!(StartsWith(tail, "http://") || StartsWith(tail, "https://"))) return ""; + return tail; +} + +// The origin the dev client is serving from, or empty. Anchors root-absolute +// specifiers imported by a module that itself came off disk. +static std::string HttpOriginAnchor(v8::Isolate* isolate, + v8::Local context) { + if (context.IsEmpty()) return std::string(); + // Reading a JS global can run a getter; resolution must stay side-effect + // free from the caller's point of view, so an exception here is swallowed + // rather than left pending on a resolver or walk frame. + v8::TryCatch tc(isolate); + v8::Local originVal; + if (!context->Global() + ->Get(context, + ArgConverter::ConvertToV8String(isolate, "__NS_HTTP_ORIGIN__")) + .ToLocal(&originVal) || + !originVal->IsString()) { + return std::string(); + } + v8::String::Utf8Value o8(isolate, originVal); + std::string origin = *o8 ? *o8 : ""; + if (origin.empty() || + !(StartsWith(origin, "http://") || StartsWith(origin, "https://"))) { + return std::string(); + } + if (origin.back() != '/') origin += '/'; + return origin; +} + +// `referrerKey` is the registry key of the importing module — empty when the +// importer is unknown (a dynamic import with no compiled referrer). +static ModuleResolution ResolveSpecifierToPath(v8::Isolate* isolate, + v8::Local context, + const std::string& rawSpec, + const std::string& referrerKey) { + ModuleResolution result; + if (rawSpec.empty()) return result; + + // Builtins resolve before any path handling, so a file can never shadow one. + if (NsBuiltinModules::IsRegistered(rawSpec) || + NsBuiltinModules::IsNsScheme(rawSpec)) { + result.kind = ModuleResolution::Kind::kBuiltin; + result.specifier = rawSpec; + return result; + } + + std::string spec = rawSpec; + // Repair 'http:/host' (single slash) left by upstream path joins, so the URL + // takes the HTTP path instead of becoming '/app/http:/host'. + if (spec.rfind("http:/", 0) == 0 && spec.rfind("http://", 0) != 0) { + spec.insert(5, "/"); + } else if (spec.rfind("https:/", 0) == 0 && spec.rfind("https://", 0) != 0) { + spec.insert(6, "/"); + } + + TNS_DEBUG(Esm, "[resolver][spec] %s", spec.c_str()); + + // A bare '@' is never a module; some dev toolchains emit it during bootstrap. + if (spec == "@") return result; + + // The import map is consulted before any other resolution: bare specifiers + // resolve through it to vendor or HTTP URLs. A client that rewrites + // specifiers must map every form it emits — keys are matched literally. + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState != nullptr && !moduleState->vocabulary.importMap.empty()) { + const LoaderVocabulary& vocabulary = moduleState->vocabulary; + std::string mapped = LookupImportMap(vocabulary, spec); + if (mapped.empty()) { + std::string normalized = NormalizeViteSpecifier(spec); + if (!normalized.empty()) { + mapped = LookupImportMap(vocabulary, normalized); + if (!mapped.empty()) { + TNS_DEBUG(Esm, "[resolver][import-map] normalized: %s -> %s -> %s", + spec.c_str(), normalized.c_str(), mapped.c_str()); + } + } + } + if (!mapped.empty()) { + TNS_DEBUG(Esm, "[resolver][import-map] rewrite: %s -> %s", spec.c_str(), + mapped.c_str()); + spec = mapped; + } else { + // A bare-looking specifier the map didn't match is about to fall back to + // filesystem resolution and almost certainly fail; surface the missing + // entry before the more cryptic `Cannot find module` follow-on. + bool looksBare = spec[0] != '/' && spec[0] != '.' && + spec.find("://") == std::string::npos && + spec.find('\\') == std::string::npos; + if (looksBare) { + TNS_DEBUG(Esm, "[resolver][import-map][miss] bare='%s' importMap.size=%lu", + spec.c_str(), (unsigned long)vocabulary.importMap.size()); + } + } + } + + result.specifier = spec; + + if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) { + result.kind = ModuleResolution::Kind::kHttp; + result.url = spec; + return result; + } + + TNS_DEBUG(Esm, "[resolver] resolving '%s'", spec.c_str()); + + const bool specIsRelative = spec[0] == '.'; + const bool specIsRootAbs = spec[0] == '/'; + std::string referrer = referrerKey; + if (referrer.empty() && specIsRelative) { + TNS_DEBUG(Esm, "[resolver] No referrer for relative '%s' - assuming app root", + spec.c_str()); + referrer = GetApplicationPath() + "/index.mjs"; + } + size_t slash = referrer.find_last_of("/\\"); + const std::string baseDir = + slash == std::string::npos ? "" : referrer.substr(0, slash + 1); + + // A referrer fetched over HTTP makes its relative and root-absolute imports + // HTTP too, the way a browser resolves them. + const bool referrerIsHttp = StartsWith(referrer, "http://") || + StartsWith(referrer, "https://"); + if (referrerIsHttp && (specIsRelative || specIsRootAbs)) { + std::string resolvedHttp = ResolveHttpRelative(referrer, spec); + if (StartsWith(resolvedHttp, "http://") || + StartsWith(resolvedHttp, "https://")) { + TNS_DEBUG(Esm, "[resolver][http-rel] base=%s spec=%s -> %s", + referrer.c_str(), spec.c_str(), resolvedHttp.c_str()); + result.kind = ModuleResolution::Kind::kHttp; + result.url = resolvedHttp; + return result; + } + } else if (!referrerIsHttp && specIsRootAbs) { + std::string origin = HttpOriginAnchor(isolate, context); + if (!origin.empty()) { + std::string resolved = ResolveHttpRelative(origin, spec); + if (StartsWith(resolved, "http://") || StartsWith(resolved, "https://")) { + TNS_DEBUG(Esm, "[resolver][http-origin][fallback] origin=%s spec=%s -> %s", + origin.c_str(), spec.c_str(), resolved.c_str()); + result.kind = ModuleResolution::Kind::kHttp; + result.url = resolved; + return result; + } + } + } + + // Build the filesystem candidates for this specifier shape. The specifier may + // omit its extension or name a directory, so each candidate is probed with + // Node-style extension and index fallbacks below. + const std::string appPath = GetApplicationPath(); + std::vector candidateBases; + + if (specIsRelative) { + std::string cleanSpec = spec.rfind("./", 0) == 0 ? spec.substr(2) : spec; + std::string candidate = NormalizePath(baseDir + cleanSpec); + candidateBases.push_back(candidate); + TNS_DEBUG(Esm, "[resolver][normalize-rel] %s + %s -> %s", baseDir.c_str(), + cleanSpec.c_str(), candidate.c_str()); + } else if (StartsWith(spec, "file://")) { + // Absolute file URL. Handle the two virtual roots the runtime emits. + std::string tail = spec.substr(7); + if (tail.empty() || tail[0] != '/') tail = "/" + tail; + + const std::string appVirtualRoot = "/app/"; + const std::string androidAssetAppRoot = "/android_asset/app/"; + std::string candidate; + if (tail.rfind(appVirtualRoot, 0) == 0) { + candidate = appPath + "/" + tail.substr(appVirtualRoot.size()); + } else if (tail.rfind(androidAssetAppRoot, 0) == 0) { + candidate = appPath + "/" + tail.substr(androidAssetAppRoot.size()); + } else { + candidate = tail; + } + candidateBases.push_back(NormalizePath(candidate)); + TNS_DEBUG(Esm, "[resolver][file-url] tail=%s -> %s", tail.c_str(), + candidateBases.back().c_str()); + } else if (spec[0] == '~') { + std::string tail = spec.size() >= 2 && spec[1] == '/' ? spec.substr(2) + : spec.substr(1); + std::string base = NormalizePath(appPath + "/" + tail); + candidateBases.push_back(base); + // Also try appPath/app for projects that bundle JS under an app folder. + std::string baseApp = NormalizePath(appPath + "/app/" + tail); + if (baseApp != base) candidateBases.push_back(baseApp); + TNS_DEBUG(Esm, "[resolver][tilde] spec=%s base=%s appBase=%s", spec.c_str(), + base.c_str(), baseApp.c_str()); + } else if (specIsRootAbs) { + // Dynamic import may already have resolved a relative specifier to a real + // filesystem path under the application root; use that as-is so we don't + // prefix ApplicationPath twice. Bundle-relative paths like /app/... or + // /src/... still resolve against appPath. + if (!appPath.empty() && spec.rfind(appPath, 0) == 0) { + candidateBases.push_back(NormalizePath(spec)); + TNS_DEBUG(Esm, "[resolver][abs-fs] spec=%s", spec.c_str()); + } else { + std::string base = NormalizePath(appPath + spec); + candidateBases.push_back(base); + const std::string appPrefix = "/app/"; + if (spec.rfind(appPrefix, 0) == 0) { + std::string tailNoApp = spec.substr(appPrefix.size() - 1); + std::string baseNoApp = NormalizePath(appPath + tailNoApp); + if (baseNoApp != base) candidateBases.push_back(baseNoApp); + } + TNS_DEBUG(Esm, "[resolver][abs] spec=%s base=%s", spec.c_str(), + base.c_str()); + } + } else { + // Bare specifier — resolve relative to the application root. + std::string base = NormalizePath(appPath + "/" + spec); + candidateBases.push_back(base); + // Underscore-separated bundler chunk heuristic. + std::string withSlashes = spec; + std::replace(withSlashes.begin(), withSlashes.end(), '_', '/'); + std::string baseSlashes = NormalizePath(appPath + "/" + withSlashes); + if (baseSlashes != base) candidateBases.push_back(baseSlashes); + } + + std::string absPath; + bool found = false; + for (const std::string& baseCandidate : candidateBases) { + absPath = baseCandidate; + + std::string embedded = HttpUrlEmbeddedInPath(absPath); + if (!embedded.empty()) { + TNS_DEBUG(Esm, "[resolver][http-embedded] %s -> %s", absPath.c_str(), + embedded.c_str()); + result.kind = ModuleResolution::Kind::kHttp; + result.url = embedded; + return result; + } + + if (IsFile(absPath)) { + found = true; + break; + } + for (const char* e : {".mjs", ".js"}) { + std::string cand = NormalizePath(WithExtension(absPath, e)); + if (IsFile(cand)) { + absPath = cand; + found = true; + break; + } + } + if (found) break; + for (const char* idx : {"/index.mjs", "/index.js"}) { + std::string cand = NormalizePath(absPath + idx); + if (IsFile(cand)) { + absPath = cand; + found = true; + break; + } + } + if (found) break; + } + + if (found) { + result.kind = ModuleResolution::Kind::kFile; + result.path = NormalizePath(absPath); + return result; + } + + // node: names with no registered builtin and no file on disk get an + // in-memory polyfill module rather than a resolution failure. + if (IsNodeBuiltinModule(spec)) { + result.kind = ModuleResolution::Kind::kNodePolyfill; + return result; + } + + result.attempted = absPath; + return result; +} + // ───────────────────────────────────────────────────────────── // Worker isolate detection: iOS keys off Caches::Get(isolate)->isWorker. // Android encodes the same signal by installing a WORKER_WRAPPER pointer in @@ -1100,16 +1428,18 @@ static uint64_t MonotonicUs() { } // ───────────────────────────────────────────────────────────── -// Async HTTP module-graph pipeline +// The module-graph walk // -// See the contract comment in ModuleInternalCallbacks.h. Mechanically: +// See the contract comment in ModuleInternalCallbacks.h. Mechanically, per +// edge — local edges never leave the JS thread: // -// EnqueueUrl(root) -// → FetchModuleBodyAsync (background thread — see HttpLoader.cpp) -// → hop to the isolate's JS thread via EventLoop::PostInternal -// → CompileModuleForResolveRegisterOnly (registers under the canonical -// URL key — the exact entry ResolveModuleCallback will look up) -// → GetModuleRequests() → ResolveModuleRequestForWalk → EnqueueUrl(…) +// Enqueue(root) +// → local: CompileFileEsModule + register under the canonical key +// → http: FetchModuleBodyAsync (background thread — see HttpLoader.cpp) +// → back to the isolate's own event loop as a nestable v8 task +// → CompileModuleForResolveRegisterOnly (registers under the +// canonical URL key — the exact entry the resolver looks up) +// → GetModuleRequests() → ResolveSpecifierToPath → Enqueue(…) // → when pendingFetches drains, onComplete fires on the JS thread. // // Thread discipline: `visited`, `pendingFetches`, `failed`, `completed` are @@ -1122,8 +1452,7 @@ namespace { struct AsyncGraphLoad { v8::Isolate* isolate = nullptr; v8::Global context; - std::shared_ptr jsTasks; // isolate's JS thread queue - std::string rootKey; // canonical registry key of the root URL + std::string rootKey; // canonical registry key of the root robin_hood::unordered_set visited; // canonical keys (JS thread only) int pendingFetches = 0; // JS thread only bool failed = false; // JS thread only (root failure) @@ -1147,6 +1476,17 @@ struct AsyncGraphLoad { } }; +// Adapter so fetch completions ride the isolate's foreground task queue +// (EventLoop::PostV8Task) like any other v8 platform task. +class FetchCompletionTask : public v8::Task { + public: + explicit FetchCompletionTask(std::function fn) : fn_(std::move(fn)) {} + void Run() override { fn_(); } + + private: + std::function fn_; +}; + // Registration and quiesce both run on the isolate's thread (the slot // contract); background fetch completions only ever touch the AsyncGraphLoad // they retain, never this list, so no lock is needed. @@ -1188,60 +1528,15 @@ static void KillAsyncGraphLoadsForIsolate(v8::Isolate* isolate) { state->asyncGraphLoads.clear(); } -// Resolve one static module request to an absolute HTTP(S) URL using the -// SAME logic ResolveModuleCallback applies, in the same order: malformed -// scheme repair → import map (direct, then Vite-normalized) → absolute -// HTTP passthrough → relative/root-absolute resolution against an HTTP -// referrer. Returns empty for everything the walk should NOT touch. -static std::string ResolveModuleRequestForWalk(const std::string& rawSpec, - const std::string& referrerUrl) { - if (rawSpec.empty() || rawSpec == "@") return ""; - std::string spec = rawSpec; - if (spec.rfind("http:/", 0) == 0 && spec.rfind("http://", 0) != 0) { - spec.insert(5, "/"); - } else if (spec.rfind("https:/", 0) == 0 && spec.rfind("https://", 0) != 0) { - spec.insert(6, "/"); - } - - const LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); - if (vocabulary != nullptr && !vocabulary->importMap.empty()) { - std::string mapped = LookupImportMap(*vocabulary, spec); - if (mapped.empty()) { - std::string normalized = NormalizeViteSpecifier(spec); - if (!normalized.empty()) { - mapped = LookupImportMap(*vocabulary, normalized); - } - } - if (!mapped.empty()) spec = mapped; - } +static void AsyncGraphEnqueue(const std::shared_ptr& load, + const ModuleResolution& resolution); - if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) { - return spec; - } - - const bool specIsRelative = !spec.empty() && spec[0] == '.'; - const bool specIsRootAbs = !spec.empty() && spec[0] == '/'; - const bool referrerIsHttp = StartsWith(referrerUrl, "http://") || - StartsWith(referrerUrl, "https://"); - if ((specIsRelative || specIsRootAbs) && referrerIsHttp) { - std::string resolved = ResolveHttpRelative(referrerUrl, spec); - if (StartsWith(resolved, "http://") || StartsWith(resolved, "https://")) { - return resolved; - } - } - return ""; -} - -static void AsyncGraphEnqueueUrl(const std::shared_ptr& load, - const std::string& url); - -// Walk `mod`'s static module requests and enqueue every HTTP-resolvable -// dependency. JS thread only; `moduleUrl` is the canonical URL the module -// was registered under (the referrer for relative resolution). +// Walk `mod`'s static module requests and enqueue every edge the walk can +// resolve. JS thread only; `moduleKey` is the registry key the module was +// registered under, which is also the referrer for relative resolution. static void AsyncGraphWalkModuleRequests( - const std::shared_ptr& load, - v8::Local /*context*/, v8::Local mod, - const std::string& moduleUrl) { + const std::shared_ptr& load, v8::Local context, + v8::Local mod, const std::string& moduleKey) { v8::Isolate* isolate = load->isolate; v8::Local requests = mod->GetModuleRequests(); const int length = requests->Length(); @@ -1252,9 +1547,19 @@ static void AsyncGraphWalkModuleRequests( v8::Local specV8 = request->GetSpecifier(); v8::String::Utf8Value specUtf8(isolate, specV8); if (!*specUtf8) continue; - std::string resolved = ResolveModuleRequestForWalk(*specUtf8, moduleUrl); - if (resolved.empty()) continue; - AsyncGraphEnqueueUrl(load, resolved); + // Builtins are served by the resolver from the builtin registry, and an + // unresolved specifier (typically a bare name with no import-map entry) + // stays on the resolver's lazy path — where it either resolves later or + // fails with the resolver's own message. An unmapped bare specifier's + // subtree is therefore not discovered here; any HTTP edge inside it is + // pathological and lands on the synchronous anomaly guard. + const ModuleResolution resolution = + ResolveSpecifierToPath(isolate, context, *specUtf8, moduleKey); + if (resolution.kind != ModuleResolution::Kind::kHttp && + resolution.kind != ModuleResolution::Kind::kFile) { + continue; + } + AsyncGraphEnqueue(load, resolution); } } @@ -1268,7 +1573,7 @@ static void AsyncGraphMaybeComplete(const std::shared_ptr& load, const uint64_t ms = endUs > load->startUs ? (endUs - load->startUs) / 1000ull : 0ull; TNS_DEBUG( Esm, - "[async-graph][done] root=%s urls=%lu fetched=%lu compiled=%lu ms=%llu ok=%d", + "[graph][done] root=%s urls=%lu fetched=%lu compiled=%lu ms=%llu ok=%d", load->rootKey.c_str(), (unsigned long)load->visited.size(), (unsigned long)load->fetchedCount, (unsigned long)load->compiledCount, (unsigned long long)ms, load->failed ? 0 : 1); @@ -1301,7 +1606,7 @@ static void AsyncGraphOnFetchCompleted( load->pendingFetches--; - const std::string key = CanonicalizeHttpUrlKey(url); + const std::string key = CanonicalizeRegistryKey(url); const bool isRoot = (key == load->rootKey); if (!load->failed) { @@ -1311,7 +1616,7 @@ static void AsyncGraphOnFetchCompleted( load->failureMessage = "HTTP import failed: " + url + " (status=" + std::to_string(status) + ")"; } else { - TNS_DEBUG(Esm, "[async-graph][dep-fetch-fail] %s status=%d (left to sync resolver)", + TNS_DEBUG(Esm, "[graph][dep-fetch-fail] %s status=%d (left to sync resolver)", url.c_str(), status); } } else { @@ -1324,7 +1629,7 @@ static void AsyncGraphOnFetchCompleted( load->failed = true; load->failureMessage = "HTTP import compile failed: " + url; } else { - TNS_DEBUG(Esm, "[async-graph][dep-compile-fail] %s (left to sync resolver)", + TNS_DEBUG(Esm, "[graph][dep-compile-fail] %s (left to sync resolver)", url.c_str()); } } else { @@ -1338,10 +1643,54 @@ static void AsyncGraphOnFetchCompleted( isolate->PerformMicrotaskCheckpoint(); } -// Enqueue one URL into the walk frontier. JS thread only. -static void AsyncGraphEnqueueUrl(const std::shared_ptr& load, - const std::string& url) { - const std::string key = CanonicalizeHttpUrlKey(url); +// A local edge: read + compile + register it inline, then keep walking. No +// thread hop — the bytes are already on disk, and a hop would only reorder +// discovery. A compile failure is deliberately swallowed here: the walk is a +// discovery optimization, and the resolver (or LoadESModule, for the root) +// owns the error message for a module that will not compile. Leaving it +// unregistered is exactly what makes those paths run and report. +static void AsyncGraphCompileLocalModule( + const std::shared_ptr& load, v8::Local context, + const std::string& path, const std::string& key) { + v8::Isolate* isolate = load->isolate; + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) return; + + v8::Local mod; + { + v8::TryCatch tcCompile(isolate); + bool compiled = false; + try { + compiled = + tns::ModuleInternal::CompileFileEsModule(isolate, path).ToLocal(&mod); + } catch (NativeScriptException& ex) { + TNS_DEBUG(Esm, "[graph][local-compile-fail] %s %s (left to the resolver)", + path.c_str(), ex.GetErrorMessage().c_str()); + return; + } + if (!compiled) { + TNS_DEBUG(Esm, "[graph][local-compile-fail] %s (left to the resolver)", + path.c_str()); + return; + } + } + + UnindexRegistryKey(*moduleState, isolate, key); + moduleState->registry[key].Reset(isolate, mod); + IndexRegisteredModule(*moduleState, key, mod); + load->compiledCount++; + AsyncGraphWalkModuleRequests(load, context, mod, key); +} + +// Enqueue one resolved edge into the walk frontier. JS thread only. +static void AsyncGraphEnqueue(const std::shared_ptr& load, + const ModuleResolution& resolution) { + const bool isHttp = resolution.kind == ModuleResolution::Kind::kHttp; + const std::string& target = isHttp ? resolution.url : resolution.path; + // One keying function for both schemes: it dispatches to the HTTP canonical + // key for URLs and to the normalized path otherwise, so the walk registers + // every module under the exact key the resolver will look up. + const std::string key = CanonicalizeRegistryKey(target); if (!load->visited.insert(key).second) return; v8::Isolate* isolate = load->isolate; @@ -1358,84 +1707,130 @@ static void AsyncGraphEnqueueUrl(const std::shared_ptr& load, AsyncGraphWalkModuleRequests(load, context, existing, key); } } - return; + return; // instantiated/evaluated → its closure is already resolved } + // Errored entry: drop and reload, mirroring LoadHttpModuleForUrl. RemoveModuleFromRegistry(key); } + if (!isHttp) { + // JSON carries no module requests, and it compiles through a different + // path; there is nothing for the walk to discover in it. + if (EndsWith(target, ".json")) return; + v8::Local context = load->context.Get(isolate); + if (!context.IsEmpty()) { + AsyncGraphCompileLocalModule(load, context, target, key); + } + return; + } + load->pendingFetches++; - std::shared_ptr jsTasks = load->jsTasks; std::shared_ptr loadRef = load; - FetchModuleBodyAsync(url, [loadRef, url, jsTasks](bool ok, int status, - std::string body) { - // Arbitrary thread. Hop to the isolate's JS thread before touching any - // walk state or V8. If the isolate died in between, drop everything — - // the context Global was already Reset by the teardown hook. - if (loadRef->dead.load(std::memory_order_acquire) || jsTasks == nullptr) { - return; - } + const std::string url = target; + FetchModuleBodyAsync(url, [loadRef, url](bool ok, int status, + std::string body) { + // Arbitrary thread. Hop to the isolate's home thread as a nestable v8 + // foreground task — delivery is a property of the isolate, not of the + // thread that started the fetch, and the pumped walk's + // RunNestableV8Tasks can drain it with JS frames on the stack. A null + // lookup means the isolate is gone; drop everything, and since teardown + // quiesces the loads before shutting the loop down, a dropped post holds + // only already-Reset state. + if (loadRef->dead.load(std::memory_order_acquire)) return; + auto* platform = NativeScriptPlatform::Instance(); + std::shared_ptr loop = + platform != nullptr ? platform->LookupEventLoop(loadRef->isolate) + : nullptr; + if (loop == nullptr) return; auto bodyPtr = std::make_shared(std::move(body)); - jsTasks->PostInternal([loadRef, url, ok, status, bodyPtr]() { - AsyncGraphOnFetchCompleted(loadRef, url, ok, status, bodyPtr); - }); + loop->PostV8Task( + std::make_unique([loadRef, url, ok, status, + bodyPtr]() { + AsyncGraphOnFetchCompleted(loadRef, url, ok, status, bodyPtr); + }), + /*nestable=*/true, /*delaySeconds=*/0); }); } -void StartAsyncHttpModuleGraphLoad( +// Classify a walk root. The root arrives already resolved — an absolute URL +// from the HTTP loader, or a canonical path from LoadESModule — so it needs +// only scheme dispatch, not the full specifier resolution. +static ModuleResolution ResolutionForRoot(const std::string& root) { + ModuleResolution resolution; + resolution.specifier = root; + if (StartsWith(root, "http://") || StartsWith(root, "https://")) { + resolution.kind = ModuleResolution::Kind::kHttp; + resolution.url = root; + } else if (IsFile(root)) { + resolution.kind = ModuleResolution::Kind::kFile; + resolution.path = root; + } + // Anything else stays kUnresolved: there is nothing to walk, and the + // caller's own load path reports why. + return resolution; +} + +void StartModuleGraphLoad( v8::Isolate* isolate, v8::Local context, - const std::string& rootUrl, + const std::string& root, std::function context)> onComplete) { auto load = std::make_shared(); load->isolate = isolate; load->context.Reset(isolate, context); - load->rootKey = CanonicalizeHttpUrlKey(rootUrl); + load->rootKey = CanonicalizeRegistryKey(root); load->startUs = MonotonicUs(); load->onComplete = std::move(onComplete); - Runtime* runtime = Runtime::GetRuntime(isolate); - load->jsTasks = runtime != nullptr ? runtime->GetEventLoop() : nullptr; - AsyncGraphLoad::g_asyncGraphLoadsInFlightCounter().fetch_add( 1, std::memory_order_acq_rel); RegisterAsyncGraphLoad(isolate, load); - TNS_DEBUG(Esm, "[async-graph][start] root=%s key=%s", rootUrl.c_str(), + TNS_DEBUG(Esm, "[graph][start] root=%s key=%s", root.c_str(), load->rootKey.c_str()); - AsyncGraphEnqueueUrl(load, rootUrl); - // Root already registered (or nothing fetchable): complete inline. + const ModuleResolution rootResolution = ResolutionForRoot(root); + if (rootResolution.kind != ModuleResolution::Kind::kUnresolved) { + AsyncGraphEnqueue(load, rootResolution); + } + // Nothing left pending (a disk-only graph finishes entirely here): complete + // inline, so the pumped runner below never enters its wait loop. AsyncGraphMaybeComplete(load, context); } -bool RunAsyncHttpModuleGraphLoadPumped(v8::Isolate* isolate, - v8::Local context, - const std::string& rootUrl, - double timeoutSeconds) { +bool RunModuleGraphLoadPumped(v8::Isolate* isolate, + v8::Local context, + const std::string& root, double timeoutSeconds) { if (timeoutSeconds <= 0.0) timeoutSeconds = 60.0; auto done = std::make_shared(false); - StartAsyncHttpModuleGraphLoad( - isolate, context, rootUrl, - [done](bool /*ok*/, const std::string& /*errorMessage*/, - v8::Local) { *done = true; }); - - // Manual looper pump ("until either all is settled or the app takes - // over"): the walk's completion tasks are posted to this thread's - // EventLoop and dispatched via ALooper — polling the looper here - // services them. ALooper_pollOnce with a small timeout keeps the pump - // responsive without spinning. + StartModuleGraphLoad(isolate, context, root, + [done](bool /*ok*/, const std::string& /*errorMessage*/, + v8::Local) { *done = true; }); + + // Manual pump ("until either all is settled or the app takes over"). Fetch + // completions are nestable v8 foreground tasks on the isolate's event loop, + // drained directly; the short ALooper slice stays as the idle-wait and still + // services the other looper-delivered work the walk indirectly depends on. A + // graph with no HTTP edges is already done here, so the loop body never runs. + Runtime* runtime = Runtime::GetRuntime(isolate); + std::shared_ptr eventLoop = + runtime != nullptr ? runtime->GetEventLoop() : nullptr; const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(static_cast(timeoutSeconds * 1000.0)); while (!*done && std::chrono::steady_clock::now() < deadline) { + if (eventLoop != nullptr) { + eventLoop->RunNestableV8Tasks(); + } + if (*done) break; ALooper_pollOnce(10 /* ms */, nullptr, nullptr, nullptr); } if (!*done) { TNS_DEBUG( Esm, - "[async-graph][pumped][timeout] root=%s after %.1fs (sync loader takes over)", - rootUrl.c_str(), timeoutSeconds); + "[graph][pumped][timeout] root=%s after %.1fs (sync loader takes over)", + root.c_str(), timeoutSeconds); } return *done; } @@ -1934,9 +2329,9 @@ static v8::MaybeLocal CompileNodeBuiltinPolyfill( // ───────────────────────────────────────────────────────────── // ResolveModuleCallback — invoked by V8 to resolve `import X from ''`. // -// Structure mirrors iOS: import-map first, then HTTP fast path, then -// filesystem resolution against the application root using the Android -// virtual-root mappings (file:///app/ and file:///android_asset/app/). +// Every resolution decision belongs to ResolveSpecifierToPath, shared with the +// graph walk; what stays here is the V8-facing half — serving builtins, +// delegating HTTP, and compiling + registering a file. v8::MaybeLocal ResolveModuleCallback( v8::Local context, v8::Local specifier, @@ -1953,298 +2348,66 @@ v8::MaybeLocal ResolveModuleCallback( const std::string rawSpec = *specUtf8 ? *specUtf8 : ""; if (rawSpec.empty()) return v8::MaybeLocal(); - // Builtins resolve before any path handling. - if (NsBuiltinModules::IsRegistered(rawSpec) || - NsBuiltinModules::IsNsScheme(rawSpec)) { - v8::Local builtin; - if (NsBuiltinModules::GetModule(context, rawSpec).ToLocal(&builtin)) { - return v8::MaybeLocal(builtin); - } - if (!NsBuiltinModules::IsRegistered(rawSpec)) { - isolate->ThrowException( - v8::Exception::Error(ArgConverter::ConvertToV8String( - isolate, NsBuiltinModules::NotFoundMessage(rawSpec)))); - } - return v8::MaybeLocal(); - } - - std::string normalizedSpec = rawSpec; - // Repair malformed http:/ or https:/ prefixes so the HTTP fast path fires. - if (normalizedSpec.rfind("http:/", 0) == 0 && - normalizedSpec.rfind("http://", 0) != 0) { - normalizedSpec.insert(5, "/"); - } else if (normalizedSpec.rfind("https:/", 0) == 0 && - normalizedSpec.rfind("https://", 0) != 0) { - normalizedSpec.insert(6, "/"); - } - - TNS_DEBUG(Esm, "[resolver][spec] %s", normalizedSpec.c_str()); - - // Guard against a bare '@' spec — invalid; refuse to poison the registry. - if (normalizedSpec == "@") { + // A bare '@' is invalid; refuse to poison the registry, and stay silent + // rather than throwing — some dev toolchains emit one during bootstrap. + if (rawSpec == "@") { TNS_DEBUG(Esm, "[resolver][normalize] ignoring invalid '@' static spec"); return v8::MaybeLocal(); } - // Import map resolution (bare specifiers → resolved URLs). - const LoaderVocabulary& vocabulary = moduleState->vocabulary; - if (!vocabulary.importMap.empty()) { - std::string mapped = LookupImportMap(vocabulary, normalizedSpec); - if (mapped.empty()) { - std::string normalized = NormalizeViteSpecifier(normalizedSpec); - if (!normalized.empty()) { - mapped = LookupImportMap(vocabulary, normalized); - if (!mapped.empty()) { - TNS_DEBUG(Esm, "[resolver][import-map] normalized: %s -> %s -> %s", - normalizedSpec.c_str(), normalized.c_str(), mapped.c_str()); - } - } - } - if (!mapped.empty()) { - TNS_DEBUG(Esm, "[resolver][import-map] rewrite: %s -> %s", - normalizedSpec.c_str(), mapped.c_str()); - normalizedSpec = mapped; - } else { - bool looksBare = !normalizedSpec.empty() && normalizedSpec[0] != '/' && - normalizedSpec[0] != '.' && - normalizedSpec.find("://") == std::string::npos && - normalizedSpec.find('\\') == std::string::npos; - if (looksBare) { - TNS_DEBUG( - Esm, "[resolver][import-map][miss] bare='%s' importMap.size=%lu", - normalizedSpec.c_str(), (unsigned long)vocabulary.importMap.size()); - } - } - } - - const std::string& spec = normalizedSpec; - - // Early absolute-HTTP fast path. - if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) { - return LoadHttpModuleForUrl(isolate, context, spec); - } - const bool isWorker = IsCurrentIsolateWorker(isolate); - TNS_DEBUG(Esm, "[resolver] resolving '%s'", spec.c_str()); - - // Find the referrer's registered path so we can resolve relative specs - // against its directory. - std::string referrerPath = FindKeyForModule(*moduleState, isolate, referrer); - bool specIsRelative = !spec.empty() && spec[0] == '.'; - if (referrerPath.empty() && specIsRelative) { - TNS_DEBUG(Esm, "[resolver] No referrer for relative '%s' - assuming app root", - spec.c_str()); - referrerPath = GetApplicationPath() + "/index.mjs"; - } - - size_t slash = referrerPath.find_last_of("/\\"); - std::string baseDir = - slash == std::string::npos ? "" : referrerPath.substr(0, slash + 1); - - // Relative or root-absolute against an HTTP referrer resolves via HTTP. - bool referrerIsHttp = !referrerPath.empty() && - (StartsWith(referrerPath, "http://") || - StartsWith(referrerPath, "https://")); - bool specIsRootAbs = !spec.empty() && spec[0] == '/'; - if (referrerIsHttp && (specIsRelative || specIsRootAbs)) { - std::string resolvedHttp = ResolveHttpRelative(referrerPath, spec); - if (!resolvedHttp.empty() && - (StartsWith(resolvedHttp, "http://") || - StartsWith(resolvedHttp, "https://"))) { - TNS_DEBUG(Esm, "[resolver][http-rel] base=%s spec=%s -> %s", - referrerPath.c_str(), spec.c_str(), resolvedHttp.c_str()); - return LoadHttpModuleForUrl(isolate, context, resolvedHttp); - } - } else if (!referrerIsHttp && specIsRootAbs) { - // Fallback: use __NS_HTTP_ORIGIN__ if present to anchor bare root-absolute - // specs (matches historical Android behavior). - v8::Local key = - ArgConverter::ConvertToV8String(isolate, "__NS_HTTP_ORIGIN__"); - v8::Local global = context->Global(); - v8::MaybeLocal maybeOriginVal = global->Get(context, key); - v8::Local originVal; - if (!maybeOriginVal.IsEmpty() && maybeOriginVal.ToLocal(&originVal) && - originVal->IsString()) { - v8::String::Utf8Value o8(isolate, originVal); - std::string origin = *o8 ? *o8 : ""; - if (!origin.empty() && (StartsWith(origin, "http://") || - StartsWith(origin, "https://"))) { - std::string refBase = origin; - if (refBase.back() != '/') refBase += '/'; - std::string resolved = ResolveHttpRelative(refBase, spec); - if (StartsWith(resolved, "http://") || - StartsWith(resolved, "https://")) { - TNS_DEBUG(Esm, "[resolver][http-origin][fallback] origin=%s spec=%s -> %s", - refBase.c_str(), spec.c_str(), resolved.c_str()); - return LoadHttpModuleForUrl(isolate, context, resolved); - } - } - } - } - - // ── Build filesystem candidate paths ── - const std::string appPath = GetApplicationPath(); - std::vector candidateBases; - - if (!spec.empty() && spec[0] == '.') { - std::string cleanSpec = spec.rfind("./", 0) == 0 ? spec.substr(2) : spec; - std::string candidate = NormalizePath(baseDir + cleanSpec); - candidateBases.push_back(candidate); - TNS_DEBUG(Esm, "[resolver][normalize-rel] %s + %s -> %s", baseDir.c_str(), - cleanSpec.c_str(), candidate.c_str()); - } else if (StartsWith(spec, "file://")) { - // Absolute file URL. Handle the two virtual roots the runtime emits. - std::string tail = spec.substr(7); - if (tail.empty() || tail[0] != '/') tail = "/" + tail; - - const std::string appVirtualRoot = "/app/"; - const std::string androidAssetAppRoot = "/android_asset/app/"; - std::string candidate; - if (tail.rfind(appVirtualRoot, 0) == 0) { - candidate = appPath + "/" + tail.substr(appVirtualRoot.size()); - } else if (tail.rfind(androidAssetAppRoot, 0) == 0) { - candidate = appPath + "/" + tail.substr(androidAssetAppRoot.size()); - } else if (tail.rfind(appPath, 0) == 0) { - candidate = tail; - } else { - candidate = tail; - } - candidateBases.push_back(NormalizePath(candidate)); - TNS_DEBUG(Esm, "[resolver][file-url] tail=%s -> %s", tail.c_str(), - candidateBases.back().c_str()); - } else if (!spec.empty() && spec[0] == '~') { - std::string tail = spec.size() >= 2 && spec[1] == '/' ? spec.substr(2) - : spec.substr(1); - std::string base = NormalizePath(appPath + "/" + tail); - candidateBases.push_back(base); - // Also try appPath/app for projects that bundle JS under an app folder. - std::string baseApp = NormalizePath(appPath + "/app/" + tail); - if (baseApp != base) candidateBases.push_back(baseApp); - TNS_DEBUG(Esm, "[resolver][tilde] spec=%s base=%s appBase=%s", spec.c_str(), - base.c_str(), baseApp.c_str()); - } else if (!spec.empty() && spec[0] == '/') { - // Absolute path. Dynamic import may already have resolved a relative - // specifier to a real filesystem path under the application root; use - // that as-is so we don't prefix ApplicationPath twice. Bundle-relative - // paths like /app/... or /src/... still resolve against appPath. - if (!appPath.empty() && spec.rfind(appPath, 0) == 0) { - candidateBases.push_back(NormalizePath(spec)); - TNS_DEBUG(Esm, "[resolver][abs-fs] spec=%s", spec.c_str()); - } else { - std::string base = NormalizePath(appPath + spec); - candidateBases.push_back(base); - const std::string appPrefix = "/app/"; - if (spec.rfind(appPrefix, 0) == 0) { - std::string tailNoApp = spec.substr(appPrefix.size() - 1); - std::string baseNoApp = NormalizePath(appPath + tailNoApp); - if (baseNoApp != base) candidateBases.push_back(baseNoApp); + const std::string referrerPath = + FindKeyForModule(*moduleState, isolate, referrer); + const ModuleResolution resolution = + ResolveSpecifierToPath(isolate, context, rawSpec, referrerPath); + + switch (resolution.kind) { + case ModuleResolution::Kind::kBuiltin: { + v8::Local builtin; + if (NsBuiltinModules::GetModule(context, rawSpec).ToLocal(&builtin)) { + return v8::MaybeLocal(builtin); } - TNS_DEBUG(Esm, "[resolver][abs] spec=%s base=%s", spec.c_str(), - base.c_str()); - } - } else { - // Bare specifier — resolve relative to the application root. - std::string base = NormalizePath(appPath + "/" + spec); - candidateBases.push_back(base); - // Underscore-separated bundler chunk heuristic. - std::string withSlashes = spec; - std::replace(withSlashes.begin(), withSlashes.end(), '_', '/'); - std::string baseSlashes = NormalizePath(appPath + "/" + withSlashes); - if (baseSlashes != base) candidateBases.push_back(baseSlashes); - } - - // Reroute a candidate that accidentally embeds a collapsed HTTP URL. - auto rerouteHttpIfEmbedded = [&](const std::string& p, - v8::MaybeLocal* moduleOut) -> bool { - size_t pos1 = p.find("/http:/"); - size_t pos2 = p.find("/https:/"); - size_t pos = std::min(pos1 == std::string::npos ? SIZE_MAX : pos1, - pos2 == std::string::npos ? SIZE_MAX : pos2); - if (pos == SIZE_MAX) return false; - std::string tail = p.substr(pos + 1); - if (StartsWith(tail, "http:/") && !StartsWith(tail, "http://")) { - tail.insert(5, "/"); - } else if (StartsWith(tail, "https:/") && !StartsWith(tail, "https://")) { - tail.insert(6, "/"); - } - if (!(StartsWith(tail, "http://") || StartsWith(tail, "https://"))) - return false; - TNS_DEBUG(Esm, "[resolver][http-embedded] %s -> %s", p.c_str(), tail.c_str()); - if (moduleOut != nullptr) { - *moduleOut = LoadHttpModuleForUrl(isolate, context, tail); - } - return true; - }; - - // ── Resolve on disk ── - std::string absPath; - bool found = false; - - for (const std::string& baseCandidate : candidateBases) { - absPath = baseCandidate; - - v8::MaybeLocal embeddedHttpModule; - if (rerouteHttpIfEmbedded(absPath, &embeddedHttpModule)) { - return embeddedHttpModule; - } - - if (IsFile(absPath)) { - found = true; - break; - } - const char* exts[] = {".mjs", ".js"}; - for (const char* e : exts) { - std::string cand = NormalizePath(WithExtension(absPath, e)); - if (IsFile(cand)) { - absPath = cand; - found = true; - break; - } - } - if (found) break; - const char* idxExts[] = {"/index.mjs", "/index.js"}; - for (const char* idx : idxExts) { - std::string cand = NormalizePath(absPath + idx); - if (IsFile(cand)) { - absPath = cand; - found = true; - break; + if (!NsBuiltinModules::IsRegistered(rawSpec)) { + isolate->ThrowException( + v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, NsBuiltinModules::NotFoundMessage(rawSpec)))); } + return v8::MaybeLocal(); } - if (found) break; - } - - if (found) absPath = NormalizePath(absPath); - const std::string registryAbsPath = CanonicalizeRegistryKey(absPath); - - if (!found) { - // node: builtins that don't exist on disk get an in-memory polyfill - // module. Anything else throws Cannot find module (matches iOS HEAD; - // no optional-module empty-return placeholder). - if (IsNodeBuiltinModule(spec)) { - std::string key = spec; // e.g. "node:url" + case ModuleResolution::Kind::kHttp: + // Security: HttpFetchText gates remote module access centrally. + return LoadHttpModuleForUrl(isolate, context, resolution.url); + case ModuleResolution::Kind::kNodePolyfill: { + const std::string& key = resolution.specifier; // e.g. "node:url" auto itExisting = g_moduleRegistry.find(key); if (itExisting != g_moduleRegistry.end()) { v8::Local existing = itExisting->second.Get(isolate); - if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { + if (!existing.IsEmpty() && + existing->GetStatus() != v8::Module::kErrored) { return v8::MaybeLocal(existing); } RemoveModuleFromRegistry(key); } - v8::MaybeLocal m = - CompileNodeBuiltinPolyfill(isolate, context, spec, key); - v8::Local mod; - if (m.ToLocal(&mod)) return m; - // CompileNodeBuiltinPolyfill already threw (unknown builtin, or - // compile failure). Do not overwrite that exception. + // On failure CompileNodeBuiltinPolyfill has already thrown (unknown + // builtin, or compile failure); do not overwrite that exception. + return CompileNodeBuiltinPolyfill(isolate, context, key, key); + } + case ModuleResolution::Kind::kUnresolved: { + // Surfaced as an exception rather than left to ReadFileText, which would + // abort trying to open a directory. + std::string msg = "Cannot find module '" + resolution.specifier + + "' (tried " + resolution.attempted + ")"; + isolate->ThrowException( + v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); return v8::MaybeLocal(); } - std::string msg = "Cannot find module '" + spec + "' (tried " + absPath + ")"; - isolate->ThrowException( - v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); + case ModuleResolution::Kind::kFile: + break; } + const std::string& absPath = resolution.path; + const std::string registryAbsPath = CanonicalizeRegistryKey(absPath); + // JSON module: compile a synthetic ESM. if (EndsWith(absPath, ".json")) { return CompileJsonAsEsModule(isolate, context, absPath, registryAbsPath); @@ -2286,7 +2449,7 @@ v8::MaybeLocal ResolveModuleCallback( } catch (NativeScriptException& ex) { if (isWorker) { DEBUG_WRITE("[resolver] Worker failed to compile '%s' -> '%s'", - spec.c_str(), absPath.c_str()); + resolution.specifier.c_str(), absPath.c_str()); } ex.ReThrowToV8(); return v8::MaybeLocal(); @@ -2309,7 +2472,7 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState != nullptr && moduleState->registry.find(key) == moduleState->registry.end()) { - TNS_DEBUG(Esm, "[async-graph][fallback-sync-load] root missed walk: %s", + TNS_DEBUG(Esm, "[graph][fallback-sync-load] root missed walk: %s", key.c_str()); } } @@ -3097,7 +3260,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( g_modulesInFlight.insert(key); g_httpDynamicWaiters[key].emplace_back(isolate, resolver); const std::string requestUrl = normalizedSpec; - StartAsyncHttpModuleGraphLoad( + StartModuleGraphLoad( isolate, context, requestUrl, [key, requestUrl, isolate](bool ok, const std::string& errorMessage, v8::Local completionContext) { @@ -3151,6 +3314,20 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } } + // Discovery pre-pass, the same one the static path runs: a local graph can + // reach HTTP edges, and without the walk those meet the resolver cold and + // fetch serially, one blocking round trip each. A graph with no HTTP edges + // settles inside the call, so a local-only dynamic import is unchanged — + // it neither waits nor touches the looper. + { + v8::String::Utf8Value adjustedUtf8(isolate, adjustedSpecifier); + const ModuleResolution rootResolution = ResolveSpecifierToPath( + isolate, context, *adjustedUtf8 ? *adjustedUtf8 : "", std::string()); + if (rootResolution.kind == ModuleResolution::Kind::kFile) { + RunModuleGraphLoadPumped(isolate, context, rootResolution.path, 60.0); + } + } + v8::TryCatch resolveTc(isolate); v8::MaybeLocal maybeModule = ResolveModuleCallback( context, adjustedSpecifier, import_assertions, refMod); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index 0849918da..f74d36edc 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -64,42 +64,54 @@ v8::MaybeLocal LoadHttpModuleForUrl( v8::Isolate* isolate, v8::Local context, const std::string& requestedUrl); -// ── Async HTTP module-graph pipeline ───────────── +// ── The module-graph walk ──────────────────────── // // Standard three-phase module-map pipeline (the Node/Blink shape) under V8's // synchronous ResolveModuleCallback: the sync constraint applies to -// *resolution*, not *fetching*. Starting from `rootUrl`, the walk fetches -// bodies concurrently off-thread (FetchModuleBodyAsync), compiles each on the -// isolate's JS thread (ScriptCompiler::CompileModule parses without -// resolving), resolves every static module request with the same import-map + -// relative-URL logic ResolveModuleCallback uses, and recurses until the -// transitive closure is compiled + registered. By InstantiateModule time the -// resolver is a pure registry lookup for the walked graph; anything the walk -// missed falls back to the legacy synchronous fetch inside the resolver. +// *resolution*, not *fetching*. Starting from `root` (an absolute http(s) URL +// or a canonical filesystem path), the walk discovers the transitive closure +// and compiles + registers every module in it, so that by InstantiateModule +// time the resolver is a pure registry lookup. +// +// Discovery is scheme-agnostic; only the fetch is per-scheme. Every edge goes +// through the same resolution the resolver uses (ResolveSpecifierToPath), so +// both agree on a module's registry key: +// - http(s) edges are fetched concurrently off-thread +// (FetchModuleBodyAsync) and compiled on the isolate's JS thread; +// - local edges are read and compiled inline during the walk — the bytes +// are already on disk, and a thread hop would only reorder discovery; +// - builtins are left to the resolver, which serves them from the builtin +// registry; +// - specifiers the walk cannot resolve (typically a bare name with no +// import-map entry) stay on the resolver's lazy path. +// +// Compilation runs no user code, so pre-compiling the closure cannot change +// evaluation order: V8 still evaluates in spec order from the root. // // `onComplete(ok, errorMessage, context)` runs exactly once on the isolate's // JS thread with the isolate entered and `context` (the context captured at -// start) already scoped. `ok` is false only when the ROOT fetch/compile -// failed — dependency failures are logged and left to surface through the -// resolver during instantiation, so the walk itself introduces no new -// failure modes. -void StartAsyncHttpModuleGraphLoad( +// start) already scoped. `ok` is false only when an HTTP ROOT fetch/compile +// failed. Every other failure — a dependency, or anything local including the +// root — is left unregistered for the resolver (or the caller's own load +// path) to report with its own message, so the walk introduces no new failure +// modes and steals no error text. +void StartModuleGraphLoad( v8::Isolate* isolate, v8::Local context, - const std::string& rootUrl, + const std::string& root, std::function context)> onComplete); // Synchronous wrapper for callers that need the graph ready before -// continuing (static HTTP entry loads): starts the walk, then pumps the -// current thread's Android Looper until it settles or `timeoutSeconds` -// elapses. Returns true when the walk completed (regardless of root success -// — the caller's own load path reports root failures). This is the "manual -// run loop until settled" boot handoff. -bool RunAsyncHttpModuleGraphLoadPumped(v8::Isolate* isolate, - v8::Local context, - const std::string& rootUrl, - double timeoutSeconds); +// continuing: starts the walk, then pumps the current thread's Android Looper +// until it settles or `timeoutSeconds` elapses. A graph with no http(s) edges +// completes entirely inside StartModuleGraphLoad, so this returns without +// entering the wait loop at all — a disk-only load pays no looper slice. +// Returns true when the walk completed (regardless of root success — the +// caller's own load path reports root failures). +bool RunModuleGraphLoadPumped(v8::Isolate* isolate, + v8::Local context, + const std::string& root, double timeoutSeconds); // True while any async graph load (any isolate) has fetches or compiles // outstanding. diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index f009241ae..8f022dfaa 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -1039,6 +1039,15 @@ void Runtime::DestroyRuntime() { s_id2RuntimeCache.erase(m_id); s_isolate2RuntimesCache.erase(m_isolate); } + // Flag this isolate's in-flight async graph loads dead and Reset their + // context Globals while the isolate is still alive, so fetch completions + // still queued on background threads become no-ops. This MUST precede the + // event-loop Shutdown: a post the stopped loop rejects is destroyed on the + // POSTING (background) thread, and quiescing first guarantees such a task + // holds only already-Reset Globals by then. The rest of the loader state + // (registries, waiters, loader vocabulary) lives in a RuntimeState slot and + // is destroyed with it below. Worker isolates quiesce the same way. + tns::QuiesceModuleLoadsForIsolate(m_isolate); if (m_eventLoop != nullptr) { // runs on this runtime's own thread; children still holding a weak_ptr // and v8 teardown posts have their work dropped from now on @@ -1073,12 +1082,6 @@ void Runtime::DestroyRuntime() { CallbackHandlers::RemoveIsolateEntries(m_isolate); FrameCallbacks::RemoveIsolateEntries(m_isolate); - // Flag this isolate's in-flight async graph loads dead and Reset their - // context Globals while the isolate is still alive, so fetch completions - // still queued on background threads become no-ops. The rest of the loader - // state (registries, waiters, loader vocabulary) lives in a RuntimeState - // slot and is destroyed with it below. Worker isolates quiesce the same way. - tns::QuiesceModuleLoadsForIsolate(m_isolate); // The transport's process-wide state (cache-bust marks, dev-boot flag) is // shared across isolates; only the main isolate may clear it (worker // teardown must not wipe the main isolate's session). From d56e86f387cfc66ffbd7665c38fc34a1df140831 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 13:35:50 -0300 Subject: [PATCH 16/36] feat(runtime): one response classifier and a web-strict MIME gate for module fetches Both module-fetch transports (the resolver's synchronous fallback and the graph walk's async pipeline) now hand their response to one ClassifyModuleResponse: transport error, then 204/205 (no content is a network error for a module script), then non-2xx, then missing MIME, then JSON MIME (application/json, text/json, any +json suffix - the response is a JSON module), then the HTML-spec JavaScript MIME essence list, then foreign-MIME failure naming the received type. Every failure carries the URL and the real cause to the importer's rejection, with strings identical across both paths and both platforms. A served JSON module now compiles as a JSON module - both HTTP paths had been compiling JSON bodies as JavaScript, failing with an opaque SyntaxError. An empty 2xx body with a JS MIME is a valid empty module (type-only TS modules); an empty JSON body is a failure. Transport truth: a bare 404 is an answer, not a connection failure - the single retry now fires on transport error only. HttpURLConnection throws on 4xx/5xx and may have no error stream, so a status line, once read, marks the transport turn as answered even without a body (a truncated 2xx body still counts as transport failure). A failed response keeps a pending cache-bust mark. LoadHttpModuleForUrl throws the classifier's reason unconditionally in every build - an empty resolve without a scheduled exception violated the callback contract in release builds. --- test-app/runtime/src/main/cpp/HttpLoader.cpp | 336 +++++++++++++----- test-app/runtime/src/main/cpp/HttpLoader.h | 59 +-- .../runtime/src/main/cpp/ModuleInternal.cpp | 9 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 138 ++++--- 4 files changed, 383 insertions(+), 159 deletions(-) diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp index 83bb37944..baead956b 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.cpp +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -431,14 +432,144 @@ static void PermitAllStrictMode(JEnv& env) { } } -bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status) { - out.clear(); - contentType.clear(); - status = 0; +// ── The module response policy ─────────────────────────────── +// +// Module scripts are strict about MIME: the HTML spec's "fetch a single module +// script" fails the fetch outright for anything that is not a JavaScript or +// JSON MIME type, where a classic script would sniff and run it anyway. That +// strictness is the whole point — an SPA dev server answering an unknown path +// with `200 text/html` should say so, not hand HTML to the parser and produce +// `Unexpected token '<'` from somewhere deep in the graph. +// +// Both transports classify here, so the synchronous fallback and the async +// walk cannot disagree about what a response means. + +// "text/javascript; charset=utf-8" → "text/javascript": parameters stripped, +// trimmed, lowercased. +static std::string MimeEssence(const std::string& contentType) { + size_t semi = contentType.find(';'); + std::string essence = + semi == std::string::npos ? contentType : contentType.substr(0, semi); + size_t begin = essence.find_first_not_of(" \t"); + if (begin == std::string::npos) { + return ""; + } + size_t end = essence.find_last_not_of(" \t"); + essence = essence.substr(begin, end - begin + 1); + for (char& c : essence) { + c = (char)tolower((unsigned char)c); + } + return essence; +} + +// The HTML spec's JavaScript MIME type essence list, verbatim. +static bool IsJavaScriptMimeEssence(const std::string& essence) { + static const char* const kJavaScriptEssences[] = {"application/ecmascript", + "application/javascript", + "application/x-ecmascript", + "application/x-javascript", + "text/ecmascript", + "text/javascript", + "text/javascript1.0", + "text/javascript1.1", + "text/javascript1.2", + "text/javascript1.3", + "text/javascript1.4", + "text/javascript1.5", + "text/jscript", + "text/livescript", + "text/x-ecmascript", + "text/x-javascript"}; + for (const char* candidate : kJavaScriptEssences) { + if (essence == candidate) { + return true; + } + } + return false; +} + +// A JSON MIME type is application/json, text/json, or any `+json` subtype. +static bool IsJsonMimeEssence(const std::string& essence) { + if (essence == "application/json" || essence == "text/json") { + return true; + } + const std::string suffix = "+json"; + return essence.size() > suffix.size() && + essence.compare(essence.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +// `transportOk` means a response arrived at all; everything else about it — +// status, MIME, emptiness — is policy decided here. `body` is moved into the +// result on success. +static void ClassifyModuleResponse(const std::string& url, bool transportOk, int status, + const std::string& contentType, std::string& body, + ModuleFetchResult& result) { + result.status = status; + result.contentType = contentType; + + if (!transportOk) { + result.failureReason = "HTTP import failed: " + url + " (network error)"; + return; + } + if (status == 204 || status == 205) { + // "No content" carries no module, which the web treats as a network + // error for a module script rather than as an empty module. + result.failureReason = + "HTTP import failed: " + url + " (status=" + std::to_string(status) + + ", no content)"; + return; + } + if (status < 200 || status >= 300) { + result.failureReason = + "HTTP import failed: " + url + " (status=" + std::to_string(status) + ")"; + return; + } + + const std::string essence = MimeEssence(contentType); + if (essence.empty()) { + result.failureReason = + "Expected a JavaScript module but '" + url + "' responded with no MIME type"; + return; + } + + if (IsJsonMimeEssence(essence)) { + if (body.empty()) { + result.failureReason = + "Expected a JSON module but '" + url + "' responded with an empty body"; + return; + } + result.kind = ModuleResponseKind::kJson; + } else if (IsJavaScriptMimeEssence(essence)) { + result.kind = ModuleResponseKind::kJavaScript; + // An empty 2xx JavaScript body is a valid module: type-only TypeScript + // modules transform to zero runtime code and dev servers serve them as + // empty 200s. Failing here would kill the whole graph with a misleading + // "status=200". + if (body.empty()) { + body = "export {};\n"; + TNS_DEBUG(Esm, "[http-loader] empty 2xx body for %s — serving canonical empty module", + url.c_str()); + } + } else { + result.failureReason = "Expected a JavaScript module but '" + url + + "' responded with MIME type '" + essence + "'"; + return; + } + + result.ok = true; + result.body = std::move(body); +} + +bool HttpFetchModule(const std::string& url, ModuleFetchResult& result) { + result = ModuleFetchResult(); ClearLastHttpFetchErrorReason(); + // Security gate: the single point of enforcement for all HTTP module + // loading, checked before any network turn. if (!IsRemoteUrlAllowed(url)) { - status = 403; + result.status = 403; + result.failureReason = + "HTTP import blocked: remote module loading is not allowed for " + url; TNS_DEBUG(Esm, "[http-esm][security][blocked] %s", url.c_str()); return false; } @@ -452,29 +583,35 @@ bool HttpFetchText(const std::string& url, std::string& out, std::string& conten // never reach for it. const std::string canonicalKey = CanonicalizeHttpUrlKey(url); - bool ok = PerformHttpFetchOnceSync(url, canonicalKey, out, contentType, status); - if (!ok) { + std::string body; + std::string contentType; + int status = 0; + bool transportOk = PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status); + if (!transportOk) { + // One retry, and only for a transport error: an HTTP status is an + // answer, not a failure to communicate, so asking again would just + // repeat it. TNS_DEBUG(Esm, "[http-loader] retrying %s after initial fetch error", url.c_str()); usleep(120 * 1000); - ok = PerformHttpFetchOnceSync(url, canonicalKey, out, contentType, status); + transportOk = PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status); } - if (!ok || status < 200 || status >= 300) { + + ClassifyModuleResponse(url, transportOk, status, contentType, body, result); + + if (!result.ok) { + TNS_DEBUG(Esm, "[http-loader][fetch-sync][reject] %s", result.failureReason.c_str()); return false; } - if (out.empty()) { - out = "export {};\n"; - TNS_DEBUG(Esm, "[http-loader] empty 2xx body for %s — serving canonical empty module", - url.c_str()); - } - TNS_DEBUG(Esm, "[http-loader] fetched status=%d content-type=%s bytes=%llu", status, - contentType.empty() ? "" : contentType.c_str(), - (unsigned long long)out.size()); + + TNS_DEBUG(Esm, "[http-loader] fetched status=%d content-type=%s bytes=%llu", result.status, + result.contentType.empty() ? "" : result.contentType.c_str(), + (unsigned long long)result.body.size()); if (urlLogEnabled) { const auto netMs = std::chrono::duration_cast( std::chrono::steady_clock::now() - netStart) .count(); TNS_DEBUG(Fetch, "[http-loader][fetch][network] %s bytes=%lu ms=%lld", url.c_str(), - (unsigned long)out.size(), (long long)netMs); + (unsigned long)result.body.size(), (long long)netMs); } InvokeHttpFetchYield(); @@ -565,6 +702,11 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& jmethodID getErrorStream = isHttp ? env.GetMethodID(clsHttp, "getErrorStream", "()Ljava/io/InputStream;") : nullptr; + // Once a status line has been read the server has answered, and every + // body-side failure below stops being a transport error: an empty 404 + // is an answer, and reporting it as "network error" would both hide + // the status and earn a pointless retry. + bool haveStatus = false; if (isHttp && getResponseCode) { status = env.CallIntMethod(conn, getResponseCode); std::string excClass, excMsg; @@ -576,6 +718,7 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& url.c_str(), excClass.c_str(), excMsg.c_str()); return false; } + haveStatus = status > 0; } jmethodID getInputStream = @@ -583,64 +726,78 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& jobject inStream = nullptr; if (isHttp && status >= 400 && getErrorStream) { inStream = env.CallObjectMethod(conn, getErrorStream); + env.ExceptionClear(); } if (!inStream) { + // On an error status with no error body, getInputStream throws + // FileNotFoundException rather than returning null. inStream = env.CallObjectMethod(conn, getInputStream); - } - { std::string excClass, excMsg; if (DrainPendingJniException(env, excClass, excMsg)) { - RecordLastHttpFetchError("get-input-stream", excClass, excMsg); - TNS_DEBUG(Esm, - "[http-esm][fetch][exception] stage=get-input-stream url=%s class=%s " - "msg=%s", - url.c_str(), excClass.c_str(), excMsg.c_str()); - return false; + if (!haveStatus) { + RecordLastHttpFetchError("get-input-stream", excClass, excMsg); + TNS_DEBUG(Esm, + "[http-esm][fetch][exception] stage=get-input-stream url=%s class=%s " + "msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + return false; + } + inStream = nullptr; } } - if (!inStream) return false; + if (!inStream && !haveStatus) return false; - jclass clsIS = env.GetObjectClass(inStream); - jmethodID readMethod = env.GetMethodID(clsIS, "read", "([B)I"); - jmethodID closeIS = env.GetMethodID(clsIS, "close", "()V"); - - jclass clsBAOS = env.FindClass("java/io/ByteArrayOutputStream"); - jmethodID baosCtor = env.GetMethodID(clsBAOS, "", "()V"); - jmethodID baosWrite = env.GetMethodID(clsBAOS, "write", "([BII)V"); - jmethodID baosToByteArray = env.GetMethodID(clsBAOS, "toByteArray", "()[B"); - jmethodID baosClose = env.GetMethodID(clsBAOS, "close", "()V"); - jobject baos = env.NewObject(clsBAOS, baosCtor); - - jbyteArray buffer = env.NewByteArray(8192); bool readFailed = false; - while (true) { - jint n = env.CallIntMethod(inStream, readMethod, buffer); - std::string excClass, excMsg; - if (DrainPendingJniException(env, excClass, excMsg)) { - RecordLastHttpFetchError("read-body", excClass, excMsg); - TNS_DEBUG(Esm, - "[http-esm][fetch][exception] stage=read-body url=%s class=%s msg=%s", - url.c_str(), excClass.c_str(), excMsg.c_str()); - readFailed = true; - break; + if (inStream) { + jclass clsIS = env.GetObjectClass(inStream); + jmethodID readMethod = env.GetMethodID(clsIS, "read", "([B)I"); + jmethodID closeIS = env.GetMethodID(clsIS, "close", "()V"); + + jclass clsBAOS = env.FindClass("java/io/ByteArrayOutputStream"); + jmethodID baosCtor = env.GetMethodID(clsBAOS, "", "()V"); + jmethodID baosWrite = env.GetMethodID(clsBAOS, "write", "([BII)V"); + jmethodID baosToByteArray = env.GetMethodID(clsBAOS, "toByteArray", "()[B"); + jmethodID baosClose = env.GetMethodID(clsBAOS, "close", "()V"); + jobject baos = env.NewObject(clsBAOS, baosCtor); + + jbyteArray buffer = env.NewByteArray(8192); + while (true) { + jint n = env.CallIntMethod(inStream, readMethod, buffer); + std::string excClass, excMsg; + if (DrainPendingJniException(env, excClass, excMsg)) { + RecordLastHttpFetchError("read-body", excClass, excMsg); + TNS_DEBUG(Esm, + "[http-esm][fetch][exception] stage=read-body url=%s class=%s msg=%s", + url.c_str(), excClass.c_str(), excMsg.c_str()); + readFailed = true; + break; + } + if (n < 0) break; + if (n == 0) continue; + env.CallVoidMethod(baos, baosWrite, buffer, 0, n); } - if (n < 0) break; - if (n == 0) continue; - env.CallVoidMethod(baos, baosWrite, buffer, 0, n); - } - env.CallVoidMethod(inStream, closeIS); - if (readFailed) { - return false; + env.CallVoidMethod(inStream, closeIS); + if (!readFailed) { + jbyteArray bytes = + static_cast(env.CallObjectMethod(baos, baosToByteArray)); + env.CallVoidMethod(baos, baosClose); + if (bytes) { + jsize len = env.GetArrayLength(bytes); + out.resize(static_cast(len)); + if (len > 0) { + env.GetByteArrayRegion(bytes, 0, len, reinterpret_cast(&out[0])); + } + } else { + readFailed = true; + } + } } - jbyteArray bytes = static_cast(env.CallObjectMethod(baos, baosToByteArray)); - env.CallVoidMethod(baos, baosClose); - - if (!bytes) return false; - jsize len = env.GetArrayLength(bytes); - out.resize(static_cast(len)); - if (len > 0) { - env.GetByteArrayRegion(bytes, 0, len, reinterpret_cast(&out[0])); + // A truncated read only matters when the body is what the caller + // needs: on a non-2xx the status alone decides the outcome, so keep + // the answer rather than turning it into a retryable network error. + if (readFailed && (!haveStatus || (status >= 200 && status < 300))) { + return false; } jmethodID getContentType = @@ -651,14 +808,16 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& } if (status == 0) status = 200; - const bool emptyNon2xx = out.empty() && (status < 200 || status >= 300); - if (emptyNon2xx) { - return false; - } + // A cache-bust mark is only satisfied by a response that actually + // carried the new body; a 404 leaves it armed for the next attempt. if (status >= 200 && status < 300 && bustRequested) { ClearCacheBustForUrl(canonicalKey); } - return status >= 200 && status < 300; + // Pure transport: true means a response arrived. Whether that response + // is a usable module — status, MIME, emptiness — is + // ClassifyModuleResponse's call, so both fetch paths answer it the + // same way. + return true; } catch (NativeScriptException& nse) { std::string what = nse.what() ? nse.what() : ""; if (what.empty()) { @@ -683,10 +842,15 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& } void FetchModuleBodyAsync(const std::string& url, - std::function completion) { + std::function completion) { + // Security gate: single point of enforcement, same as HttpFetchModule. if (!IsRemoteUrlAllowed(url)) { TNS_DEBUG(Esm, "[http-esm][security][blocked] %s", url.c_str()); - completion(false, 403, std::string()); + ModuleFetchResult blocked; + blocked.status = 403; + blocked.failureReason = + "HTTP import blocked: remote module loading is not allowed for " + url; + completion(std::move(blocked)); return; } @@ -715,33 +879,33 @@ void FetchModuleBodyAsync(const std::string& url, } } detachGuard{jvm, attachedHere}; - std::string out; + std::string body; std::string contentType; int status = 0; const auto start = std::chrono::steady_clock::now(); - bool ok = PerformHttpFetchOnceSync(url, canonicalKey, out, contentType, status); - if (!ok) { + bool transportOk = PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status); + if (!transportOk) { + // Transport error → one retry, the same single-retry policy the + // sync path applies. TNS_DEBUG(Esm, "[http-loader][fetch-async] retrying %s after transport error", url.c_str()); usleep(120 * 1000); - ok = PerformHttpFetchOnceSync(url, canonicalKey, out, contentType, status); + transportOk = PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status); } - ok = ok && status >= 200 && status < 300; - if (ok && out.empty()) { - out = "export {};\n"; - } - if (!ok) { - TNS_DEBUG(Esm, "[http-loader][fetch-async][error] url=%s status=%d", url.c_str(), - status); - } - if (ok && LogCategoryEnabled(LogCategory::Fetch)) { + + ModuleFetchResult result; + ClassifyModuleResponse(url, transportOk, status, contentType, body, result); + + if (!result.ok) { + TNS_DEBUG(Esm, "[http-loader][fetch-async][reject] %s", result.failureReason.c_str()); + } else if (LogCategoryEnabled(LogCategory::Fetch)) { const auto ms = std::chrono::duration_cast( std::chrono::steady_clock::now() - start) .count(); TNS_DEBUG(Fetch, "[http-loader][fetch][async] %s bytes=%lu ms=%lld", url.c_str(), - (unsigned long)out.size(), (long long)ms); + (unsigned long)result.body.size(), (long long)ms); } - completion(ok, status, std::move(out)); + completion(std::move(result)); }).detach(); } diff --git a/test-app/runtime/src/main/cpp/HttpLoader.h b/test-app/runtime/src/main/cpp/HttpLoader.h index dc3f2ff74..fd2909d7b 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.h +++ b/test-app/runtime/src/main/cpp/HttpLoader.h @@ -67,40 +67,55 @@ struct CanonicalizationConfig { // module under exactly one URL and never varies it for freshness. std::string CanonicalizeHttpUrlKey(const std::string& url); -// Minimal text fetch for HTTP ESM loader. Returns true on 2xx. -// - out: response body -// - contentType: Content-Type header if present -// - status: HTTP status code -// -// Synchronous fetch with one retry — this is the fallback path for -// anything the async module-graph walk missed. Empty 2xx bodies are -// normalized to the canonical empty module (`export {};\n`). -bool HttpFetchText(const std::string& url, std::string& out, - std::string& contentType, int& status); - -// Asynchronous single-URL module body fetch — the I/O primitive behind the -// phase-1 module-graph walk (see StartModuleGraphLoad in -// ModuleInternalCallbacks.h). Same semantics as HttpFetchText, minus the -// JS-thread block: +// What a module response turned out to be. Decided once, by the shared +// classifier, for whichever transport produced the response. +enum class ModuleResponseKind { + kJavaScript, + kJson, +}; + +// The outcome of fetching one module over HTTP. Both transports produce this +// same verdict, so the synchronous fallback and the async graph walk cannot +// drift apart on what counts as a usable module. +struct ModuleFetchResult { + bool ok = false; + int status = 0; + ModuleResponseKind kind = ModuleResponseKind::kJavaScript; + // Normalized: an empty 2xx JavaScript body becomes the canonical empty + // module. Meaningful only when `ok`. + std::string body; + std::string contentType; // as received, parameters included + // Reader-facing explanation, non-empty exactly when `!ok`. This is the text + // that reaches the importer's rejection, so it names the URL and the cause. + std::string failureReason; +}; + +// Synchronous module fetch with one retry on transport error — the fallback +// path for anything the module-graph walk missed. Blocks the calling thread. +// Returns `result.ok`. +bool HttpFetchModule(const std::string& url, ModuleFetchResult& result); + +// Asynchronous single-URL module fetch — the I/O primitive behind the +// module-graph walk (see StartModuleGraphLoad in ModuleInternalCallbacks.h). +// Same response policy as HttpFetchModule, minus the JS-thread block: // - security gate (IsRemoteUrlAllowed) checked up front, // - a JNI HttpURLConnection GET on a background thread with the same // request shape as the sync path (cache-bust nonce, zero-cache headers, -// no cookies) and one retry on transport error, -// - empty 2xx bodies normalize to the canonical empty module. -// `completion(ok, status, body)` is invoked exactly once, on an arbitrary -// thread — callers must hop to their JS thread before touching V8. +// no cookies) and one retry on transport error. +// `completion(result)` is invoked exactly once, on an arbitrary thread — +// callers must hop to their JS thread before touching V8. void FetchModuleBodyAsync( const std::string& url, - std::function completion); + std::function completion); // Return the most recent low-level fetch error reason for the calling // thread, or an empty string if the last fetch succeeded (or no fetch // has run on this thread yet). Take semantics — the slot is cleared on // read. Android-only diagnostic for splicing JNI exceptions into JS -// errors when HttpFetchText returns status=0. +// errors when the transport never reached an HTTP status. std::string TakeLastHttpFetchErrorReason(); -// Register a "yield" callback that `HttpFetchText` should invoke around its +// Register a "yield" callback that `HttpFetchModule` should invoke around its // synchronous network turn so the caller can pump its own runloop (e.g. the // JS-thread looper so a placeholder UI can repaint during cold-boot). // diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 1639aa2e8..242f75670 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -677,10 +677,17 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p if (isHttpModule) { RunModuleGraphLoadPumped(isolate, context, requestPath, 60.0); + // The loader throws the classifier's reason (status, MIME or + // transport); catch it so it lands in the message instead of staying + // pending on the isolate behind a C++ throw. + TryCatch tcLoad(isolate); MaybeLocal maybeMod = LoadHttpModuleForUrl(isolate, context, requestPath); if (!maybeMod.ToLocal(&module)) { - std::string reason = TakeLastHttpFetchErrorReason(); std::string message = "Cannot load ES module " + requestPath; + if (tcLoad.HasCaught()) { + throw NativeScriptException(tcLoad, message); + } + std::string reason = TakeLastHttpFetchErrorReason(); if (!reason.empty()) { message.append(" — "); message.append(reason); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 93594227c..479bc85f7 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -228,6 +228,10 @@ static bool ShouldTraceRegistryKey(const std::string& rawKey, static const char* ModuleStatusToString(v8::Module::Status status); static void KillAsyncGraphLoadsForIsolate(v8::Isolate* isolate); static bool IsCurrentIsolateWorker(v8::Isolate* isolate); +static v8::MaybeLocal CompileJsonTextAsEsModule( + v8::Isolate* isolate, v8::Local context, + const std::string& jsonText, const std::string& registryAbsPath, + const std::string& displayUrl); static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, v8::Local context, const std::string& registryKey); @@ -676,37 +680,38 @@ v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, "synchronous fetch. This should not happen; please report it.", requestedUrl.c_str()); - std::string body; - std::string contentType; - int status = 0; - if (!HttpFetchText(requestedUrl, body, contentType, status) || body.empty()) { + ModuleFetchResult fetched; + if (!HttpFetchModule(requestedUrl, fetched)) { TNS_DEBUG(Esm, "[http-esm][load][fetch-fail] request=%s key=%s status=%d", - requestedUrl.c_str(), registryKey.c_str(), status); - if (IsDebuggable()) { - std::string msg = "HTTP import failed: " + requestedUrl + - " (status=" + std::to_string(status) + ")"; - isolate->ThrowException(v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, msg))); - } + requestedUrl.c_str(), registryKey.c_str(), fetched.status); + // The classifier's reason names the URL and the cause (status, MIME or + // transport); a generic message here would lose all of it. V8 requires an + // exception whenever a resolve callback returns empty, so this is thrown + // in every build. + isolate->ThrowException(v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, fetched.failureReason))); return v8::MaybeLocal(); } - v8::MaybeLocal loaded = - CompileModuleForResolveRegisterOnly(isolate, context, body, registryKey); + if (fetched.kind == ModuleResponseKind::kJson) { + return CompileJsonTextAsEsModule(isolate, context, fetched.body, registryKey, + requestedUrl); + } + + v8::MaybeLocal loaded = CompileModuleForResolveRegisterOnly( + isolate, context, fetched.body, registryKey); if (loaded.IsEmpty()) { TNS_DEBUG(Esm, "[http-esm][load][compile-fail] request=%s key=%s bytes=%zu", - requestedUrl.c_str(), registryKey.c_str(), body.size()); - if (IsDebuggable()) { - std::string msg = "HTTP import compile failed: " + requestedUrl; - isolate->ThrowException(v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, msg))); - } + requestedUrl.c_str(), registryKey.c_str(), fetched.body.size()); + std::string msg = "HTTP import compile failed: " + requestedUrl; + isolate->ThrowException(v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))); return v8::MaybeLocal(); } TNS_DEBUG(Esm, "[http-esm][load][ok] request=%s key=%s type=%s bytes=%zu", requestedUrl.c_str(), registryKey.c_str(), - contentType.c_str(), body.size()); + fetched.contentType.c_str(), fetched.body.size()); return loaded; } @@ -1587,12 +1592,12 @@ static void AsyncGraphMaybeComplete(const std::shared_ptr& load, } } -// A fetched body arrived on the isolate's JS thread: compile + register it, -// then walk its requests. Runs outside any V8 scope, so it enters the isolate -// the same way other cross-thread callbacks do. +// A fetch verdict arrived on the isolate's JS thread: compile + register the +// module, then walk its requests. Runs outside any V8 scope, so it enters the +// isolate the same way other cross-thread callbacks do. static void AsyncGraphOnFetchCompleted( const std::shared_ptr& load, const std::string& url, - bool ok, int status, const std::shared_ptr& body) { + const std::shared_ptr& fetched) { if (load->dead.load(std::memory_order_acquire)) return; v8::Isolate* isolate = load->isolate; if (Runtime::GetRuntime(isolate) == nullptr) return; @@ -1610,19 +1615,35 @@ static void AsyncGraphOnFetchCompleted( const bool isRoot = (key == load->rootKey); if (!load->failed) { - if (!ok) { + if (!fetched->ok) { if (isRoot) { load->failed = true; - load->failureMessage = "HTTP import failed: " + url + - " (status=" + std::to_string(status) + ")"; + load->failureMessage = fetched->failureReason; } else { - TNS_DEBUG(Esm, "[graph][dep-fetch-fail] %s status=%d (left to sync resolver)", - url.c_str(), status); + TNS_DEBUG(Esm, "[graph][dep-fetch-fail] %s (left to sync resolver)", + fetched->failureReason.c_str()); + } + } else if (fetched->kind == ModuleResponseKind::kJson) { + // JSON compiles, instantiates and evaluates in one step and carries no + // module requests, so there is nothing further to walk from here. + load->fetchedCount++; + v8::TryCatch tcJson(isolate); + if (CompileJsonTextAsEsModule(isolate, context, fetched->body, key, url) + .IsEmpty()) { + if (isRoot) { + load->failed = true; + load->failureMessage = "JSON module failed to compile: " + url; + } else { + TNS_DEBUG(Esm, "[graph][dep-json-fail] %s (left to sync resolver)", + url.c_str()); + } + } else { + load->compiledCount++; } } else { load->fetchedCount++; v8::MaybeLocal maybeMod = - CompileModuleForResolveRegisterOnly(isolate, context, *body, key); + CompileModuleForResolveRegisterOnly(isolate, context, fetched->body, key); v8::Local mod; if (!maybeMod.ToLocal(&mod)) { if (isRoot) { @@ -1727,8 +1748,7 @@ static void AsyncGraphEnqueue(const std::shared_ptr& load, load->pendingFetches++; std::shared_ptr loadRef = load; const std::string url = target; - FetchModuleBodyAsync(url, [loadRef, url](bool ok, int status, - std::string body) { + FetchModuleBodyAsync(url, [loadRef, url](ModuleFetchResult result) { // Arbitrary thread. Hop to the isolate's home thread as a nestable v8 // foreground task — delivery is a property of the isolate, not of the // thread that started the fetch, and the pumped walk's @@ -1742,11 +1762,10 @@ static void AsyncGraphEnqueue(const std::shared_ptr& load, platform != nullptr ? platform->LookupEventLoop(loadRef->isolate) : nullptr; if (loop == nullptr) return; - auto bodyPtr = std::make_shared(std::move(body)); + auto resultPtr = std::make_shared(std::move(result)); loop->PostV8Task( - std::make_unique([loadRef, url, ok, status, - bodyPtr]() { - AsyncGraphOnFetchCompleted(loadRef, url, ok, status, bodyPtr); + std::make_unique([loadRef, url, resultPtr]() { + AsyncGraphOnFetchCompleted(loadRef, url, resultPtr); }), /*nestable=*/true, /*delaySeconds=*/0); }); @@ -2158,11 +2177,15 @@ static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, // ───────────────────────────────────────────────────────────── // JSON module → synthetic ES module -// Compile a `.json` file as an ES module whose default export is the parsed -// JSON value. Handles registry insertion and eager evaluation. -static v8::MaybeLocal CompileJsonAsEsModule( +// Wrap JSON source as an ES module with the parsed value as its default +// export. Shared by the filesystem path and the HTTP path so a served JSON +// module and an imported .json file behave identically; `displayUrl` only +// names the module in stack traces. Handles registry insertion and eager +// evaluation. +static v8::MaybeLocal CompileJsonTextAsEsModule( v8::Isolate* isolate, v8::Local context, - const std::string& absPath, const std::string& registryAbsPath) { + const std::string& jsonText, const std::string& registryAbsPath, + const std::string& displayUrl) { auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) { return v8::MaybeLocal(); @@ -2171,7 +2194,7 @@ static v8::MaybeLocal CompileJsonAsEsModule( // JSON modules are compiled eagerly to kEvaluated, so a registered entry is // complete and must be reused — recompiling would mint a second module - // identity (and namespace) for the same file on every resolve. + // identity (and namespace) for the same source on every resolve. auto existingIt = g_moduleRegistry.find(registryAbsPath); if (existingIt != g_moduleRegistry.end()) { v8::Local existing = existingIt->second.Get(isolate); @@ -2183,13 +2206,12 @@ static v8::MaybeLocal CompileJsonAsEsModule( g_moduleRegistry.erase(existingIt); } - TNS_DEBUG(Esm, "[resolver][json] wrapping %s", absPath.c_str()); + TNS_DEBUG(Esm, "[json] wrapping %s", displayUrl.c_str()); - std::string jsonText = Runtime::GetRuntime(isolate)->ReadFileText(absPath); std::string moduleSource = "export default " + jsonText + ";"; v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, moduleSource); - std::string url = "file://" + absPath; + const std::string& url = displayUrl; v8::Local urlString; if (!v8::String::NewFromUtf8(isolate, url.c_str(), @@ -2226,6 +2248,15 @@ static v8::MaybeLocal CompileJsonAsEsModule( return v8::MaybeLocal(jsonModule); } +// The filesystem entry point: read the file, then share the wrap. +static v8::MaybeLocal CompileJsonAsEsModule( + v8::Isolate* isolate, v8::Local context, + const std::string& absPath, const std::string& registryAbsPath) { + const std::string jsonText = Runtime::GetRuntime(isolate)->ReadFileText(absPath); + return CompileJsonTextAsEsModule(isolate, context, jsonText, registryAbsPath, + "file://" + absPath); +} + // ───────────────────────────────────────────────────────────── // node: builtin polyfills (Android). iOS ships node:url only; Android has // carried node:url / node:module / node:path shims for longer. Kept here to @@ -2375,7 +2406,7 @@ v8::MaybeLocal ResolveModuleCallback( return v8::MaybeLocal(); } case ModuleResolution::Kind::kHttp: - // Security: HttpFetchText gates remote module access centrally. + // Security: HttpFetchModule gates remote module access centrally. return LoadHttpModuleForUrl(isolate, context, resolution.url); case ModuleResolution::Kind::kNodePolyfill: { const std::string& key = resolution.specifier; // e.g. "node:url" @@ -2476,6 +2507,10 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, key.c_str()); } } + // The loader throws the classifier's reason (status, MIME or transport) on + // failure; catch it here so it becomes the waiters' rejection instead of a + // generic message left beside a pending exception. + v8::TryCatch tcLoad(isolate); v8::MaybeLocal modMaybe = LoadHttpModuleForUrl(isolate, context, requestUrl); if (!modMaybe.IsEmpty()) { @@ -2584,10 +2619,13 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, return; } } - RejectHttpDynamicWaiters( - isolate, context, key, - v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, "HTTP fetch/compile failed"))); + v8::Local reason = + tcLoad.HasCaught() + ? tcLoad.Exception() + : v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "HTTP fetch/compile failed: " + requestUrl)); + tcLoad.Reset(); + RejectHttpDynamicWaiters(isolate, context, key, reason); } // ───────────────────────────────────────────────────────────── @@ -3091,7 +3129,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } // ── HTTP(S) fast path ── - // Security: HttpFetchText gates remote module access centrally. + // Security: HttpFetchModule gates remote module access centrally. if (!normalizedSpec.empty() && (StartsWith(normalizedSpec, "http://") || StartsWith(normalizedSpec, "https://"))) { From 13a6dc9a9d2b0d1a71c8c3d8a947f52d4193100b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 13:37:32 -0300 Subject: [PATCH 17/36] fix(runtime): clear dynamic-import routing state before settling waiters Settling a promise from a plain task context runs its reactions synchronously, and a reaction that re-imports the same URL must take the registry-hit path - not park on a waiter list that was just flushed and will never be settled again. ResolveHttpDynamicWaiters and RejectHttpDynamicWaiters therefore detach the waiter vector and erase the in-flight mark before resolving or rejecting anything. Since fetch completions arrive as platform tasks, the settle really does run with no JS on the stack, so the window was live. --- .../src/main/cpp/ModuleInternalCallbacks.cpp | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 479bc85f7..4c687411e 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -2109,16 +2109,24 @@ static void ResolveHttpDynamicWaiters(v8::Isolate* isolate, v8::Local module) { auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) return; + // Settling a promise can run its reactions immediately: with the default + // microtask policy, a Resolve/Reject issued from a plain platform task (no + // JS on the stack) drains the queue as the API call unwinds. A reaction that + // re-imports this URL would then see stale routing state and park on a + // waiter list that was just flushed — a promise nothing would ever settle. + // So every piece of state that can route a new import onto the waiter list + // is cleared FIRST; a re-entrant import then takes the registry-hit path. + std::vector> resolvers; auto& g_httpDynamicWaiters = moduleState->httpDynamicWaiters; auto waitIt = g_httpDynamicWaiters.find(registryKey); if (waitIt != g_httpDynamicWaiters.end()) { - std::vector> resolvers; resolvers.swap(waitIt->second); g_httpDynamicWaiters.erase(waitIt); - ResolveResolversWithModuleNamespace(isolate, context, resolvers, module, - registryKey); } moduleState->modulesInFlight.erase(registryKey); + + ResolveResolversWithModuleNamespace(isolate, context, resolvers, module, + registryKey); } static void RejectHttpDynamicWaiters(v8::Isolate* isolate, @@ -2127,15 +2135,19 @@ static void RejectHttpDynamicWaiters(v8::Isolate* isolate, v8::Local reason) { auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) return; + // Cleared before rejecting, for the same reason as the resolve path: a + // rejection handler that retries this URL must not join a flushed waiter + // list. + std::vector> resolvers; auto& g_httpDynamicWaiters = moduleState->httpDynamicWaiters; auto waitIt = g_httpDynamicWaiters.find(registryKey); if (waitIt != g_httpDynamicWaiters.end()) { - std::vector> resolvers; resolvers.swap(waitIt->second); g_httpDynamicWaiters.erase(waitIt); - RejectResolversWithReason(isolate, context, resolvers, reason); } moduleState->modulesInFlight.erase(registryKey); + + RejectResolversWithReason(isolate, context, resolvers, reason); } static void RejectResolversForInvalidation( From 9fb35590c8ae95ffa7026c2a75567e346113bb94 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 13:50:22 -0300 Subject: [PATCH 18/36] feat(runtime): extract module evaluation policies; require(esm) refuses async graphs One primitive - ModuleInternal::EvaluateModuleGraph - now evaluates every ES-module graph under a named policy: - Sync-strict, Node's require(esm): IsGraphAsync() is refused before evaluation (the graph stays instantiated; import() can still load it), including registry hits whose evaluation promise is still pending - a TLA-parked module reports evaluated, and returning its namespace would hand out TDZ bindings. Sync graphs must settle synchronously. Global require() of an async-graph module now throws Node-style (ERR_REQUIRE_ASYNC_MODULE parity) - a deliberate breaking change. - Sync-pumping, boot and worker entries: drive nestable platform tasks and microtask checkpoints until the capability promise settles or the deadline expires. HTTP entries get the 60s deadline and throw; local entries get a 1s in-place yield that returns pending rather than throwing. - Async: evaluate and hand back the capability promise. One deadline constant (kModuleEvaluateDeadlineSeconds = 60) replaces the 30s TLA spin and the scattered 60.0 literals. Module loading also now fails identically in debug and release: the debug-only FNV-hash/snippet/heuristic-classification compile diagnostic is gone, and a failed compile leaves the real SyntaxError (message, line, column) pending for the importer instead of swallowing it. A module whose evaluation rejects is evicted from the registry rather than served again. --- .../runtime/src/main/cpp/ModuleInternal.cpp | 245 ++++++++++++++---- .../runtime/src/main/cpp/ModuleInternal.h | 25 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 195 +++++++------- test-app/runtime/src/main/cpp/Runtime.cpp | 3 +- 4 files changed, 329 insertions(+), 139 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 242f75670..c47fa017a 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -15,6 +15,7 @@ #include "NativeScriptAssert.h" #include "Constants.h" #include "CrashBreadcrumbs.h" +#include "EventLoop.h" #include "NativeScriptException.h" #include "NsBuiltinModules.h" #include "napi/NapiModules.h" @@ -309,7 +310,9 @@ void ModuleInternal::Load(Local context, const string& path) { TNSPERF(); auto isolate = m_isolate; if (IsHttpModulePath(path) || IsESModule(path)) { - LoadESModule(isolate, path); + // The entry runs before this thread's event loop does, so its graph can + // only make progress from the pump inside LoadESModule. + LoadESModule(isolate, path, ModuleEvaluationPolicy::kSyncPumping); return; } auto globalObject = context->Global(); @@ -447,8 +450,10 @@ Local ModuleInternal::LoadModule(Isolate* isolate, const string& moduleP // Check if this is an ES module (.mjs) if (Util::EndsWith(modulePath, ".mjs")) { - // For ES modules, load using the ES module system - Local moduleNamespace = LoadESModule(isolate, modulePath); + // require()'s route into the ES module system, which cannot wait: an + // async graph is refused rather than pumped. + Local moduleNamespace = + LoadESModule(isolate, modulePath, ModuleEvaluationPolicy::kSyncStrict); // Create a wrapper object that behaves like a CommonJS module // but exports the ES module namespace @@ -660,11 +665,172 @@ MaybeLocal ModuleInternal::CompileFileEsModule(Isolate* isolate, const s return ScriptCompiler::CompileModule(isolate, &source); } +namespace { + +struct ModuleEvaluationOptions { + enum class TimeoutBehavior { kReturnPending, kThrow }; + + ModuleEvaluationPolicy policy = ModuleEvaluationPolicy::kSyncStrict; + // kSyncPumping only: how long the graph gets to settle in-pump. + double deadlineSeconds = 0.0; + // kSyncPumping only: what an expired window means. + TimeoutBehavior timeoutBehavior = TimeoutBehavior::kReturnPending; + // kSyncPumping only: also give the Android looper a slice per iteration, for + // graphs whose progress depends on native transports rather than V8 tasks. + bool pumpRunLoop = false; +}; + +// `require()` cannot wait, so an async graph is refused rather than evaluated. +// Never evicts: the module is perfectly loadable through import(). +[[noreturn]] void ThrowAsyncGraphRefusal(const std::string& canonicalPath) { + throw NativeScriptException("require() cannot load ES module '" + canonicalPath + + "': the module graph contains top-level await. Use import() " + "instead."); +} + +// Evicts the module and surfaces the rejection reason. Always throws, in every +// build — the reason has to reach the boundary handler that reports it. +[[noreturn]] void ThrowModuleEvaluationRejection(Isolate* isolate, Local promise, + TryCatch& tc, + const std::string& canonicalPath) { + RemoveModuleFromRegistry(canonicalPath); + std::string detail = PromiseRejectionMessage(isolate, promise, canonicalPath); + if (!tc.HasCaught()) { + Local reason = promise->Result(); + if (!reason.IsEmpty()) { + isolate->ThrowException(reason); + } + } + if (tc.HasCaught()) { + throw NativeScriptException(tc, detail); + } + throw NativeScriptException(detail); +} + +// Evaluates an instantiated graph under `options`. Returns the capability +// promise for kAsync and an empty handle otherwise; the namespace always comes +// from the module itself. Throws NativeScriptException on failure, in every +// build. `canonicalPath` names the registry entry to evict on failure. +MaybeLocal EvaluateModuleGraph(Isolate* isolate, Local context, + Local module, + const std::string& canonicalPath, + const ModuleEvaluationOptions& options) { + if (options.policy == ModuleEvaluationPolicy::kSyncStrict) { + if (module->IsGraphAsync()) { + // Refusing before evaluation leaves the graph at kInstantiated, so a + // later import() can still evaluate it, and keeps this diagnosis ahead + // of whatever runtime error the graph would have produced first. + ThrowAsyncGraphRefusal(canonicalPath); + } + if (module->GetStatus() == Module::kEvaluating) { + // Re-entered through a cycle while the graph is still on the stack; its + // namespace holds whatever has been initialized so far. + return MaybeLocal(); + } + } + + TryCatch tcEval(isolate); + Local result; + if (!module->Evaluate(context).ToLocal(&result)) { + RemoveModuleFromRegistry(canonicalPath); + if (tcEval.HasCaught()) { + throw NativeScriptException(tcEval, "Cannot evaluate module " + canonicalPath); + } + throw NativeScriptException(string("Cannot evaluate module ") + canonicalPath); + } + + if (!result->IsPromise()) { + return MaybeLocal(); + } + Local promise = result.As(); + + if (options.policy == ModuleEvaluationPolicy::kAsync) { + return promise; + } + + TryCatch promiseTc(isolate); + + if (options.policy == ModuleEvaluationPolicy::kSyncStrict) { + Promise::PromiseState state = promise->State(); + if (state == Promise::kRejected) { + ThrowModuleEvaluationRejection(isolate, promise, promiseTc, canonicalPath); + } + if (state == Promise::kPending) { + // V8 guarantees a settled capability for a graph that reported + // !IsGraphAsync, so reaching here means the graph classification and the + // evaluation disagree — never paper over it with a half-initialized + // namespace. + throw NativeScriptException("ES module " + canonicalPath + + " left its evaluation promise pending on a graph reported " + "as synchronous"); + } + return MaybeLocal(); + } + + // Top-level await can depend on native async work such as fetch(), which + // needs both V8 microtasks and the looper to advance. An await whose + // resolution arrives as a v8 foreground task never settles from checkpoints + // alone; JS frames are on the stack, so like the inspector pause loops only + // nestable tasks may run here. + Runtime* runtime = Runtime::GetRuntime(isolate); + std::shared_ptr eventLoop = runtime != nullptr ? runtime->GetEventLoop() : nullptr; + + auto pumpAsyncProgress = [&]() { + if (eventLoop != nullptr) { + eventLoop->RunNestableV8Tasks(); + } + isolate->PerformMicrotaskCheckpoint(); + if (options.pumpRunLoop) { + ALooper_pollOnce(10 /* ms */, nullptr, nullptr, nullptr); + isolate->PerformMicrotaskCheckpoint(); + } + }; + + const auto deadline = + std::chrono::steady_clock::now() + + std::chrono::milliseconds(static_cast(options.deadlineSeconds * 1000.0)); + bool settled = false; + + // State is checked before the first pump: a synchronous graph's evaluation + // promise is already settled when Evaluate() returns, so it exits here + // without paying for a looper slice. + while (!promiseTc.HasCaught()) { + Promise::PromiseState state = promise->State(); + if (state != Promise::kPending) { + settled = true; + if (state == Promise::kRejected) { + ThrowModuleEvaluationRejection(isolate, promise, promiseTc, canonicalPath); + } + break; + } + + if (std::chrono::steady_clock::now() >= deadline) { + break; + } + + pumpAsyncProgress(); + if (!options.pumpRunLoop) { + usleep(1000); // 1ms delay for non-HTTP top-level await polling + } + } + + if (!settled && promise->State() == Promise::kPending && + options.timeoutBehavior == ModuleEvaluationOptions::TimeoutBehavior::kThrow) { + RemoveModuleFromRegistry(canonicalPath); + throw NativeScriptException("Top-level await timed out for ES module " + canonicalPath); + } + + return MaybeLocal(); +} + +} // namespace + // The root entry point for an ES module graph: compile + register the root, // then instantiate and evaluate it once. Dependencies are compiled and // registered by ResolveModuleCallback while V8 walks the graph from here; // nothing below the root evaluates on its own. -Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& path) { +Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& path, + ModuleEvaluationPolicy policy) { auto context = isolate->GetCurrentContext(); const bool isHttpModule = IsHttpModulePath(path); // The key the resolver would derive for this same module as someone's @@ -676,7 +842,7 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p Local module; if (isHttpModule) { - RunModuleGraphLoadPumped(isolate, context, requestPath, 60.0); + RunModuleGraphLoadPumped(isolate, context, requestPath, kModuleEvaluateDeadlineSeconds); // The loader throws the classifier's reason (status, MIME or // transport); catch it so it lands in the message instead of staying // pending on the isolate behind a C++ throw. @@ -695,6 +861,12 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p throw NativeScriptException(message); } if (module->GetStatus() == Module::kEvaluated) { + // A top-level-await graph reports kEvaluated while its capability + // promise is still pending, so the namespace here may be in its TDZ; + // require() refuses the graph whatever the load order, matching Node. + if (policy == ModuleEvaluationPolicy::kSyncStrict && module->IsGraphAsync()) { + ThrowAsyncGraphRefusal(canonicalPath); + } return module->GetModuleNamespace(); } } else { @@ -711,6 +883,12 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p if (status == Module::kErrored) { RemoveModuleFromRegistry(canonicalPath); } else if (status == Module::kEvaluated) { + // A top-level-await graph reports kEvaluated while its capability + // promise is still pending, so the namespace here may be in its TDZ; + // require() refuses the graph whatever the load order, matching Node. + if (policy == ModuleEvaluationPolicy::kSyncStrict && existing->IsGraphAsync()) { + ThrowAsyncGraphRefusal(canonicalPath); + } return existing->GetModuleNamespace(); } else if (status == Module::kUninstantiated || status == Module::kInstantiated) { // Recompiling would mint a second module identity while importers still @@ -727,7 +905,8 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p // whole closure up front — including this root — so instantiation // resolves as pure lookup. A graph with no HTTP edges settles inside the // call and pays no wait. - RunModuleGraphLoadPumped(isolate, context, canonicalPath, 60.0); + RunModuleGraphLoadPumped(isolate, context, canonicalPath, + kModuleEvaluateDeadlineSeconds); auto walkedIt = g_moduleRegistry.find(canonicalPath); if (walkedIt != g_moduleRegistry.end()) { Local walked = walkedIt->second.Get(isolate); @@ -771,45 +950,23 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p } } - // Evaluate with its own TryCatch - Local result; - { - TryCatch tcEval(isolate); - if (!module->Evaluate(context).ToLocal(&result)) { - if (tcEval.HasCaught()) { - throw NativeScriptException(tcEval, "Cannot evaluate module " + canonicalPath); - } else { - throw NativeScriptException(string("Cannot evaluate module ") + canonicalPath); - } - } - - // Handle the case where evaluation returns a Promise (for top-level await) - if (result->IsPromise()) { - Local promise = result.As(); - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); - - while (true) { - isolate->PerformMicrotaskCheckpoint(); - Promise::PromiseState state = promise->State(); - - if (state != Promise::kPending) { - if (state == Promise::kRejected) { - Local reason = promise->Result(); - isolate->ThrowException(reason); - throw NativeScriptException(PromiseRejectionMessage(isolate, promise, canonicalPath)); - } - break; - } - - if (std::chrono::steady_clock::now() >= deadline) { - throw NativeScriptException(string("Module evaluation promise timed out: ") + canonicalPath); - } - - ALooper_pollOnce(10, nullptr, nullptr, nullptr); - usleep(100); - } - } + // Evaluate the graph under the caller's policy. + ModuleEvaluationOptions evalOptions; + evalOptions.policy = policy; + if (policy == ModuleEvaluationPolicy::kSyncPumping) { + // For local modules the bound is a yield, not a timeout: only nestable V8 + // tasks can run while these JS frames are on the stack, so a TLA parked on + // a non-nestable foreground task can never settle in-pump — give it one + // short window, then return and let the real event loop finish it after + // the turn. HTTP entries must settle in-pump — the dev client needs the + // rejection reason synchronously — so they get the full deadline. + evalOptions.deadlineSeconds = isHttpModule ? kModuleEvaluateDeadlineSeconds : 1.0; + evalOptions.timeoutBehavior = isHttpModule + ? ModuleEvaluationOptions::TimeoutBehavior::kThrow + : ModuleEvaluationOptions::TimeoutBehavior::kReturnPending; + evalOptions.pumpRunLoop = isHttpModule; } + EvaluateModuleGraph(isolate, context, module, canonicalPath, evalOptions); return module->GetModuleNamespace(); } diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.h b/test-app/runtime/src/main/cpp/ModuleInternal.h index 54e9c0ffd..2cd7a9593 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.h +++ b/test-app/runtime/src/main/cpp/ModuleInternal.h @@ -15,6 +15,22 @@ #include namespace tns { + +// The single deadline for every module-graph settle wait: the entry +// top-level-await pump in LoadESModule and the pumped module-graph walk. One +// knob, so the waits stay ordered — transport timeouts < this. +inline constexpr double kModuleEvaluateDeadlineSeconds = 60.0; + +// How a module graph's evaluation promise is settled. +// kSyncStrict - Node's `require(esm)`: an async graph is refused before it +// ever evaluates, and the capability promise must already be +// settled when Evaluate() returns. +// kSyncPumping - drive this thread in place until the promise settles or the +// window closes. Only legal while nothing else owns the loop +// (entry evaluation), and only nestable V8 tasks can run. +// kAsync - evaluate and hand the caller the capability promise. +enum class ModuleEvaluationPolicy { kSyncStrict, kSyncPumping, kAsync }; + class ModuleInternal { public: ModuleInternal(); @@ -39,7 +55,14 @@ class ModuleInternal { // Helper functions for ES module support static bool IsESModule(const std::string& path); - static v8::Local LoadESModule(v8::Isolate* isolate, const std::string& path); + + /* + * Compile/link/evaluate an ES module; returns its namespace object. `policy` + * decides how the graph's evaluation promise is settled — see + * ModuleEvaluationPolicy. + */ + static v8::Local LoadESModule(v8::Isolate* isolate, const std::string& path, + ModuleEvaluationPolicy policy); /* * Read + compile `path` as an ES module WITHOUT registering, instantiating or diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 4c687411e..a76cb15fa 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -461,10 +461,34 @@ static v8::MaybeLocal CompileModuleFromSource( return hs.Escape(mod); } +// "message (line L:C)" for a caught exception, or empty. The line/column are +// the part no caller can reconstruct from a failure code. +static std::string DescribeCaughtError(v8::Isolate* isolate, + v8::Local context, + const v8::TryCatch& tc) { + if (!tc.HasCaught()) return std::string(); + v8::Local message = tc.Message(); + if (message.IsEmpty()) return std::string(); + v8::String::Utf8Value text(isolate, message->Get()); + std::string described = *text ? *text : ""; + int line = message->GetLineNumber(context).FromMaybe(0); + if (line > 0) { + described += " (line " + std::to_string(line) + ":" + + std::to_string(message->GetStartColumn()) + ")"; + } + return described; +} + // Compile-only variant used inside ResolveModuleCallback. Compiles a // v8::Module and registers it under urlStr but does NOT instantiate or // evaluate. V8 is currently instantiating the importer and will handle // instantiation of this dependency. +// +// On compile failure the exception is left PENDING, the same contract as +// ModuleInternal::CompileFileEsModule: it names the file, line and column, +// which nothing downstream can reconstruct. A caller that cannot let it +// propagate must consume it through its own TryCatch and route the text into +// its own failure channel — never drop it. static v8::MaybeLocal CompileModuleForResolveRegisterOnly( v8::Isolate* isolate, v8::Local context, const std::string& code, const std::string& urlStr) { @@ -481,6 +505,16 @@ static v8::MaybeLocal CompileModuleForResolveRegisterOnly( urlStr.c_str(), registryKey.c_str()); } + // Checked before compiling: recompiling a key that is already registered + // would mint a second module identity while importers hold the first. + auto itExisting = g_moduleRegistry.find(registryKey); + if (itExisting != g_moduleRegistry.end()) { + v8::Local existing = itExisting->second.Get(isolate); + if (!existing.IsEmpty()) { + return hs.Escape(existing); + } + } + v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, code); v8::Local urlV8; @@ -496,79 +530,12 @@ static v8::MaybeLocal CompileModuleForResolveRegisterOnly( { v8::TryCatch tcCompile(isolate); if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { - if (IsDebuggable() && LogCategoryEnabled(LogCategory::Esm)) { - uint64_t h = 1469598103934665603ull; // FNV-1a 64-bit - for (unsigned char c : code) { - h ^= c; - h *= 1099511628211ull; - } - std::string snippet = code.substr(0, 600); - for (char& ch : snippet) { - if (ch == '\n' || ch == '\r') ch = ' '; - } - const char* classification = "unknown"; - v8::Local message = tcCompile.Message(); - std::string msgStr; - std::string srcLineStr; - int lineNum = 0; - int startCol = 0; - int endCol = 0; - if (!message.IsEmpty()) { - v8::String::Utf8Value m8(isolate, message->Get()); - if (*m8) msgStr = *m8; - lineNum = message->GetLineNumber(context).FromMaybe(0); - startCol = message->GetStartColumn(); - endCol = message->GetEndColumn(); - v8::MaybeLocal maybeLine = message->GetSourceLine(context); - if (!maybeLine.IsEmpty()) { - v8::String::Utf8Value l8(isolate, maybeLine.ToLocalChecked()); - if (*l8) srcLineStr = *l8; - } - if (msgStr.find("Unexpected identifier") != std::string::npos || - msgStr.find("Unexpected token") != std::string::npos) { - if (msgStr.find("export") != std::string::npos && - code.find("export default") == std::string::npos && - code.find("__sfc__") != std::string::npos) - classification = "missing-export-default"; - else - classification = "syntax"; - } else if (msgStr.find("Cannot use import statement") != std::string::npos) { - classification = "wrap-error"; - } - } - if (classification == std::string("unknown")) { - if (code.find("export default") == std::string::npos && - code.find("__sfc__") != std::string::npos) - classification = "missing-export-default"; - else if (code.find("__sfc__") != std::string::npos && - code.find("export {") == std::string::npos && - code.find("export ") == std::string::npos) - classification = "no-exports"; - else if (code.find("import ") == std::string::npos && - code.find("export ") == std::string::npos) - classification = "not-module"; - else if (code.find("_openBlock") != std::string::npos && - code.find("openBlock") == std::string::npos) - classification = "underscore-helper-unmapped"; - } - if (srcLineStr.size() > 240) srcLineStr = srcLineStr.substr(0, 240); - TNS_DEBUG(Esm, - "[http-esm][compile][v8-error][%s] %s line=%d col=%d..%d " - "hash=%llx bytes=%lu msg=%s srcLine=%s snippet=%s", - classification, urlStr.c_str(), lineNum, startCol, endCol, - (unsigned long long)h, (unsigned long)code.size(), - msgStr.c_str(), srcLineStr.c_str(), snippet.c_str()); - } + TNS_DEBUG(Esm, "[http-esm][compile][fail] %s %s", urlStr.c_str(), + DescribeCaughtError(isolate, context, tcCompile).c_str()); + tcCompile.ReThrow(); return v8::MaybeLocal(); } } - auto itExisting = g_moduleRegistry.find(registryKey); - if (itExisting != g_moduleRegistry.end()) { - v8::Local existing = itExisting->second.Get(isolate); - if (!existing.IsEmpty()) { - return hs.Escape(existing); - } - } UnindexRegistryKey(*moduleState, isolate, registryKey); g_moduleRegistry[registryKey].Reset(isolate, mod); IndexRegisteredModule(*moduleState, registryKey, mod); @@ -698,15 +665,26 @@ v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, requestedUrl); } - v8::MaybeLocal loaded = CompileModuleForResolveRegisterOnly( - isolate, context, fetched.body, registryKey); - if (loaded.IsEmpty()) { - TNS_DEBUG(Esm, "[http-esm][load][compile-fail] request=%s key=%s bytes=%zu", - requestedUrl.c_str(), registryKey.c_str(), fetched.body.size()); - std::string msg = "HTTP import compile failed: " + requestedUrl; - isolate->ThrowException(v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, msg))); - return v8::MaybeLocal(); + v8::Local loaded; + { + v8::TryCatch tcCompile(isolate); + if (!CompileModuleForResolveRegisterOnly(isolate, context, fetched.body, + registryKey) + .ToLocal(&loaded)) { + TNS_DEBUG(Esm, "[http-esm][load][compile-fail] request=%s key=%s bytes=%zu", + requestedUrl.c_str(), registryKey.c_str(), + fetched.body.size()); + if (tcCompile.HasCaught()) { + // The compile error names the module, line and column; replacing it + // with a generic "compile failed" would strictly lose information. + tcCompile.ReThrow(); + } else { + std::string msg = "HTTP import compile failed: " + requestedUrl; + isolate->ThrowException(v8::Exception::Error( + ArgConverter::ConvertToV8String(isolate, msg))); + } + return v8::MaybeLocal(); + } } TNS_DEBUG(Esm, "[http-esm][load][ok] request=%s key=%s type=%s bytes=%zu", @@ -1642,16 +1620,31 @@ static void AsyncGraphOnFetchCompleted( } } else { load->fetchedCount++; - v8::MaybeLocal maybeMod = - CompileModuleForResolveRegisterOnly(isolate, context, fetched->body, key); v8::Local mod; - if (!maybeMod.ToLocal(&mod)) { + bool compiled = false; + std::string compileError; + { + // This callback runs on to completion and a microtask checkpoint, so a + // compile exception must be consumed here rather than left pending; its + // text goes into the load's own failure channel instead. + v8::TryCatch tcCompile(isolate); + compiled = CompileModuleForResolveRegisterOnly(isolate, context, + fetched->body, key) + .ToLocal(&mod); + if (!compiled) { + compileError = DescribeCaughtError(isolate, context, tcCompile); + } + } + if (!compiled) { if (isRoot) { load->failed = true; load->failureMessage = "HTTP import compile failed: " + url; + if (!compileError.empty()) { + load->failureMessage += " — " + compileError; + } } else { - TNS_DEBUG(Esm, "[graph][dep-compile-fail] %s (left to sync resolver)", - url.c_str()); + TNS_DEBUG(Esm, "[graph][dep-compile-fail] %s %s (left to sync resolver)", + url.c_str(), compileError.c_str()); } } else { load->compiledCount++; @@ -1821,7 +1814,7 @@ void StartModuleGraphLoad( bool RunModuleGraphLoadPumped(v8::Isolate* isolate, v8::Local context, const std::string& root, double timeoutSeconds) { - if (timeoutSeconds <= 0.0) timeoutSeconds = 60.0; + if (timeoutSeconds <= 0.0) timeoutSeconds = kModuleEvaluateDeadlineSeconds; auto done = std::make_shared(false); StartModuleGraphLoad(isolate, context, root, [done](bool /*ok*/, const std::string& /*errorMessage*/, @@ -2366,6 +2359,8 @@ static v8::MaybeLocal CompileNodeBuiltinPolyfill( isolate, NsBuiltinModules::NotFoundMessage(spec)))); return v8::MaybeLocal(); } + // The polyfill source is the runtime's own, so a compile failure here is a + // runtime bug: keep the parse error rather than masking it. return CompileModuleForResolveRegisterOnly(isolate, context, polyfill, key); } @@ -2992,14 +2987,27 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( TNS_DEBUG(Esm, "[dyn-import][blob] compiling blob module, code length=%zu", code.size()); - v8::MaybeLocal modMaybe = - CompileModuleForResolveRegisterOnly(iso, ctx, code, d->blobUrl); v8::Local mod; - if (!modMaybe.ToLocal(&mod)) { - RejectHttpDynamicWaiters(iso, ctx, d->registryKey, - v8::Exception::Error( - ArgConverter::ConvertToV8String( - iso, "Failed to compile blob module"))); + bool compiled = false; + std::string compileError; + { + // A pending exception would escape this callback into V8's promise + // machinery; the waiters are this path's failure channel. + v8::TryCatch tcCompile(iso); + compiled = CompileModuleForResolveRegisterOnly(iso, ctx, code, d->blobUrl) + .ToLocal(&mod); + if (!compiled) { + compileError = DescribeCaughtError(iso, ctx, tcCompile); + } + } + if (!compiled) { + std::string msg = "Failed to compile blob module"; + if (!compileError.empty()) { + msg += ": " + compileError; + } + RejectHttpDynamicWaiters( + iso, ctx, d->registryKey, + v8::Exception::Error(ArgConverter::ConvertToV8String(iso, msg))); delete d; return; } @@ -3374,7 +3382,8 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( const ModuleResolution rootResolution = ResolveSpecifierToPath( isolate, context, *adjustedUtf8 ? *adjustedUtf8 : "", std::string()); if (rootResolution.kind == ModuleResolution::Kind::kFile) { - RunModuleGraphLoadPumped(isolate, context, rootResolution.path, 60.0); + RunModuleGraphLoadPumped(isolate, context, rootResolution.path, + kModuleEvaluateDeadlineSeconds); } } diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 8f022dfaa..d21ae834e 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -364,7 +364,8 @@ static void PumpPendingHttpModuleGraph(v8::Isolate* isolate) { isolate->PerformMicrotaskCheckpoint(); ALooper_pollOnce(10, nullptr, nullptr, nullptr); isolate->PerformMicrotaskCheckpoint(); - if (std::chrono::duration(std::chrono::steady_clock::now() - start).count() > 60.0) { + if (std::chrono::duration(std::chrono::steady_clock::now() - start).count() > + kModuleEvaluateDeadlineSeconds) { DEBUG_WRITE("PumpPendingHttpModuleGraph: deadline expired with pending async module work"); break; } From b0d537aa03337444849da872528643d456ca4c0c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 13:59:01 -0300 Subject: [PATCH 19/36] feat(runtime): Node-parity exports interop for require(esm) require() of an ES module now populates module.exports exactly as Node's populateCJSExportsFromESM: a literal 'module.exports' named export wins outright; a namespace with no default export, or with its own __esModule, passes through unchanged; otherwise a synthetic facade module (export * / export default / __esModule = true) built over the target provides live bindings, linked through a dedicated resolve callback that hands the target module through a pending slot no user code can reach. Facades are cached per target in an identity-hash bucket with handle confirmation and dropped whenever their target's registry key is unindexed - eviction and replacement alike - so a reloaded module cannot serve a stale facade and a replaced one cannot leak it. --- .../runtime/src/main/cpp/ModuleInternal.cpp | 114 ++++++++++---- .../runtime/src/main/cpp/ModuleInternal.h | 23 +++ .../src/main/cpp/ModuleInternalCallbacks.cpp | 143 ++++++++++++++++++ .../src/main/cpp/ModuleInternalCallbacks.h | 11 ++ 4 files changed, 263 insertions(+), 28 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index c47fa017a..f7eed66e5 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -429,6 +429,71 @@ Local ModuleInternal::LoadImpl(Isolate* isolate, const string& moduleNam return result; } +static bool NamespaceHasOwn(Isolate* isolate, Local context, Local ns, + const char* name) { + return ns->HasOwnProperty(context, ArgConverter::ConvertToV8String(isolate, name)) + .FromMaybe(false); +} + +// The live compiled module behind a registry key, or empty. +static Local RegisteredModuleForPath(Isolate* isolate, const std::string& canonicalPath) { + auto* registryPtr = ModuleRegistryFor(isolate); + if (registryPtr == nullptr) { + return Local(); + } + auto it = registryPtr->find(canonicalPath); + if (it == registryPtr->end()) { + return Local(); + } + return it->second.Get(isolate); +} + +// What `require()` of an ES module hands back, per Node's +// populateCJSExportsFromESM: an explicit `module.exports` export wins outright; +// a namespace with no default export, or one that already declares +// __esModule, passes through untouched; everything else gets the facade so +// transpiled consumers reading `_mod.__esModule ? _mod.default : _mod` find the +// default. Export names are arbitrary strings, hence the own-property probes. +static Local RequireExportsForNamespace(Isolate* isolate, Local context, + Local ns, + const std::string& canonicalPath) { + TryCatch tc(isolate); + + if (NamespaceHasOwn(isolate, context, ns, "module.exports")) { + Local moduleExports; + if (!ns->Get(context, ArgConverter::ConvertToV8String(isolate, "module.exports")) + .ToLocal(&moduleExports)) { + throw NativeScriptException( + tc, "Cannot read the 'module.exports' export of " + canonicalPath); + } + return moduleExports; + } + + bool hasDefault = NamespaceHasOwn(isolate, context, ns, "default"); + bool hasEsModuleMarker = NamespaceHasOwn(isolate, context, ns, "__esModule"); + if (!hasDefault || hasEsModuleMarker) { + return ns; + } + + Local target = RegisteredModuleForPath(isolate, canonicalPath); + if (target.IsEmpty()) { + // The load that produced this namespace registered the module under this + // very key, so a miss means the registry and the namespace disagree — + // returning the bare namespace would drop __esModule and misroute every + // transpiled consumer downstream. + throw NativeScriptException( + "require() cannot build the exports facade for " + canonicalPath + + ": the module evaluated but is absent from the registry under its canonical key"); + } + + Local facade; + if (!GetOrCreateRequireFacade(isolate, context, target, canonicalPath).ToLocal(&facade)) { + throw NativeScriptException("Cannot build the require() exports facade for " + + canonicalPath); + } + return facade->GetModuleNamespace(); +} + Local ModuleInternal::LoadModule(Isolate* isolate, const string& modulePath, const string& moduleCacheKey) { string frameName("LoadModule " + modulePath); tns::instrumentation::Frame frame(frameName); @@ -454,11 +519,21 @@ Local ModuleInternal::LoadModule(Isolate* isolate, const string& moduleP // async graph is refused rather than pumped. Local moduleNamespace = LoadESModule(isolate, modulePath, ModuleEvaluationPolicy::kSyncStrict); - - // Create a wrapper object that behaves like a CommonJS module - // but exports the ES module namespace - moduleObj->Set(context, ArgConverter::ConvertToV8String(isolate, "exports"), moduleNamespace); - + + // `module.exports` is what Node's populateCJSExportsFromESM produces for + // this namespace, not the namespace itself. A namespace can still be + // empty when the load bailed on a torn-down isolate; nothing to interop. + Local esmExports = moduleNamespace; + if (!moduleNamespace.IsEmpty() && moduleNamespace->IsObject()) { + esmExports = RequireExportsForNamespace(isolate, context, + moduleNamespace.As(), + CanonicalizeRegistryKey(modulePath)); + } + if (!esmExports.IsEmpty()) { + moduleObj->Set(context, ArgConverter::ConvertToV8String(isolate, "exports"), + esmExports); + } + tempModule.SaveToCache(); result = moduleObj; return result; @@ -667,19 +742,6 @@ MaybeLocal ModuleInternal::CompileFileEsModule(Isolate* isolate, const s namespace { -struct ModuleEvaluationOptions { - enum class TimeoutBehavior { kReturnPending, kThrow }; - - ModuleEvaluationPolicy policy = ModuleEvaluationPolicy::kSyncStrict; - // kSyncPumping only: how long the graph gets to settle in-pump. - double deadlineSeconds = 0.0; - // kSyncPumping only: what an expired window means. - TimeoutBehavior timeoutBehavior = TimeoutBehavior::kReturnPending; - // kSyncPumping only: also give the Android looper a slice per iteration, for - // graphs whose progress depends on native transports rather than V8 tasks. - bool pumpRunLoop = false; -}; - // `require()` cannot wait, so an async graph is refused rather than evaluated. // Never evicts: the module is perfectly loadable through import(). [[noreturn]] void ThrowAsyncGraphRefusal(const std::string& canonicalPath) { @@ -707,14 +769,12 @@ struct ModuleEvaluationOptions { throw NativeScriptException(detail); } -// Evaluates an instantiated graph under `options`. Returns the capability -// promise for kAsync and an empty handle otherwise; the namespace always comes -// from the module itself. Throws NativeScriptException on failure, in every -// build. `canonicalPath` names the registry entry to evict on failure. -MaybeLocal EvaluateModuleGraph(Isolate* isolate, Local context, - Local module, - const std::string& canonicalPath, - const ModuleEvaluationOptions& options) { +} // namespace + +MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local context, + Local module, + const std::string& canonicalPath, + const ModuleEvaluationOptions& options) { if (options.policy == ModuleEvaluationPolicy::kSyncStrict) { if (module->IsGraphAsync()) { // Refusing before evaluation leaves the graph at kInstantiated, so a @@ -823,8 +883,6 @@ MaybeLocal EvaluateModuleGraph(Isolate* isolate, Local context return MaybeLocal(); } -} // namespace - // The root entry point for an ES module graph: compile + register the root, // then instantiate and evaluate it once. Dependencies are compiled and // registered by ResolveModuleCallback while V8 walks the graph from here; diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.h b/test-app/runtime/src/main/cpp/ModuleInternal.h index 2cd7a9593..a48eb68d6 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.h +++ b/test-app/runtime/src/main/cpp/ModuleInternal.h @@ -31,6 +31,29 @@ inline constexpr double kModuleEvaluateDeadlineSeconds = 60.0; // kAsync - evaluate and hand the caller the capability promise. enum class ModuleEvaluationPolicy { kSyncStrict, kSyncPumping, kAsync }; +struct ModuleEvaluationOptions { + enum class TimeoutBehavior { kReturnPending, kThrow }; + + ModuleEvaluationPolicy policy = ModuleEvaluationPolicy::kSyncStrict; + // kSyncPumping only: how long the graph gets to settle in-pump. + double deadlineSeconds = 0.0; + // kSyncPumping only: what an expired window means. + TimeoutBehavior timeoutBehavior = TimeoutBehavior::kReturnPending; + // kSyncPumping only: also give the Android looper a slice per iteration, for + // graphs whose progress depends on native transports rather than V8 tasks. + bool pumpRunLoop = false; +}; + +// Evaluates an instantiated graph under `options`. Returns the capability +// promise for kAsync and an empty handle otherwise; the namespace always comes +// from the module itself. Throws NativeScriptException on failure, in every +// build. `canonicalPath` names the registry entry to evict on failure. +v8::MaybeLocal EvaluateModuleGraph(v8::Isolate* isolate, + v8::Local context, + v8::Local module, + const std::string& canonicalPath, + const ModuleEvaluationOptions& options); + class ModuleInternal { public: ModuleInternal(); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index a76cb15fa..6762c8966 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -239,6 +239,13 @@ static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, namespace { struct AsyncGraphLoad; +// One require(esm) exports facade and the module it wraps. Held as a pair +// because identity hashes collide: lookups compare the target handle. +struct RequireFacadeEntry { + v8::Global target; + v8::Global facade; +}; + // Everything the dev client teaches one isolate's loader (see the header's // long-form note): the import map, the canonicalization vocabulary and the // volatile-URL patterns. @@ -306,6 +313,18 @@ struct ModuleLoaderState { // the ones it no longer backs, so a stale candidate can never answer a // lookup. robin_hood::unordered_map> keysByModuleHash; + + // require(esm) facades, keyed by the TARGET module's identity hash — same + // bucket-plus-handle-compare shape as keysByModuleHash. Repeated require() of + // one ES module must hand back the identical exports object, and a facade + // must never outlive the module it re-exports (UnindexRegistryKey drops the + // entry as the target stops being the registry's answer for its key). + robin_hood::unordered_map> + requireFacadesByTargetHash; + + // Holds the facade target across that facade's InstantiateModule and nothing + // else — the facade's resolve callback is the only reader. + v8::Global pendingFacadeTarget; }; // This isolate's loader state, or null once teardown has begun — callers must @@ -326,6 +345,27 @@ void IndexRegisteredModule(ModuleLoaderState& state, const std::string& key, } } +// Drop any facade wrapping `target`. Called as the target stops being the +// registry's answer for its key: a facade whose re-export source is gone would +// serve a dead namespace. +void DropRequireFacadesForTarget(ModuleLoaderState& state, v8::Isolate* isolate, + v8::Local target) { + if (target.IsEmpty()) return; + auto bucketIt = state.requireFacadesByTargetHash.find(target->GetIdentityHash()); + if (bucketIt == state.requireFacadesByTargetHash.end()) return; + auto& entries = bucketIt->second; + for (auto it = entries.begin(); it != entries.end();) { + if (it->target.Get(isolate) == target) { + it = entries.erase(it); + } else { + ++it; + } + } + if (entries.empty()) { + state.requireFacadesByTargetHash.erase(bucketIt); + } +} + // Drop `key` from the bucket of whatever module the registry holds under it // right now. Call before replacing or erasing that entry, while the outgoing // handle is still reachable — afterwards its hash is unrecoverable. @@ -335,6 +375,7 @@ void UnindexRegistryKey(ModuleLoaderState& state, v8::Isolate* isolate, if (regIt == state.registry.end() || regIt->second.IsEmpty()) return; v8::Local outgoing = regIt->second.Get(isolate); if (outgoing.IsEmpty()) return; + DropRequireFacadesForTarget(state, isolate, outgoing); auto bucketIt = state.keysByModuleHash.find(outgoing->GetIdentityHash()); if (bucketIt == state.keysByModuleHash.end()) return; auto& keys = bucketIt->second; @@ -377,6 +418,108 @@ std::string LookupModuleKeyForModule(v8::Isolate* isolate, return FindKeyForModule(*state, isolate, mod); } +namespace { +// The single module request in the facade source, and the source itself. Both +// match Node's required_module_facade_source_string so the semantics (live +// bindings, enumerable re-exports, overridable __esModule) stay identical. +constexpr const char* kRequireFacadeSpecifier = "original"; +constexpr const char* kRequireFacadeSource = + "export * from 'original'; export { default } from 'original'; " + "export const __esModule = true;"; + +// Resolves the facade's one request. Passed only to a facade's +// InstantiateModule, so the general resolver never sees 'original' and user +// code can never reach this slot. +v8::MaybeLocal ResolveRequireFacadeTarget( + v8::Local context, v8::Local specifier, + v8::Local /*import_assertions*/, + v8::Local /*referrer*/) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + auto* state = ModuleLoaderStateFor(isolate); + v8::String::Utf8Value specUtf8(isolate, specifier); + const std::string spec = *specUtf8 ? *specUtf8 : ""; + if (state == nullptr || state->pendingFacadeTarget.IsEmpty() || + spec != kRequireFacadeSpecifier) { + DEBUG_WRITE_FORCE("FATAL: require(esm) facade resolve for '%s' with no pending target", + spec.c_str()); + isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "require(esm) facade could not be linked to its target module"))); + return v8::MaybeLocal(); + } + return v8::MaybeLocal(state->pendingFacadeTarget.Get(isolate)); +} +} // namespace + +v8::MaybeLocal GetOrCreateRequireFacade( + v8::Isolate* isolate, v8::Local context, + v8::Local target, const std::string& targetCanonicalPath) { + if (target.IsEmpty()) return v8::MaybeLocal(); + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return v8::MaybeLocal(); + + auto bucketIt = state->requireFacadesByTargetHash.find(target->GetIdentityHash()); + if (bucketIt != state->requireFacadesByTargetHash.end()) { + for (auto& entry : bucketIt->second) { + if (entry.target.Get(isolate) == target) { + return v8::MaybeLocal(entry.facade.Get(isolate)); + } + } + } + + v8::EscapableHandleScope hs(isolate); + const std::string facadeUrl = "ns:require-facade:" + targetCanonicalPath; + + v8::Local urlV8; + if (!v8::String::NewFromUtf8(isolate, facadeUrl.c_str(), v8::NewStringType::kNormal) + .ToLocal(&urlV8)) { + return v8::MaybeLocal(); + } + v8::ScriptOrigin origin(urlV8, 0, 0, false, -1, v8::Local(), false, + false, true /* is_module */); + v8::ScriptCompiler::Source source( + ArgConverter::ConvertToV8String(isolate, kRequireFacadeSource), origin); + + v8::TryCatch tc(isolate); + v8::Local facade; + if (!v8::ScriptCompiler::CompileModule(isolate, &source).ToLocal(&facade)) { + throw NativeScriptException( + tc, "Cannot compile the require() facade for " + targetCanonicalPath); + } + + bool linked = false; + { + // The slot must be clear again whichever way instantiation ends. + struct PendingTargetScope { + ModuleLoaderState* state; + ~PendingTargetScope() { state->pendingFacadeTarget.Reset(); } + } pendingScope{state}; + state->pendingFacadeTarget.Reset(isolate, target); + linked = facade->InstantiateModule(context, &ResolveRequireFacadeTarget) + .FromMaybe(false); + } + if (!linked) { + throw NativeScriptException( + tc, "Cannot link the require() facade for " + targetCanonicalPath); + } + + // Three re-export statements over an already-evaluated module: trivially + // synchronous, so the strict policy's settled-promise requirement holds. + ModuleEvaluationOptions evalOptions; + evalOptions.policy = ModuleEvaluationPolicy::kSyncStrict; + EvaluateModuleGraph(isolate, context, facade, facadeUrl, evalOptions); + + // The facade is deliberately absent from the registry and the identity-hash + // index: nothing resolves to it by name, and its source has no import.meta or + // dynamic import, so no host callback ever needs to find it. + RequireFacadeEntry entry; + entry.target.Reset(isolate, target); + entry.facade.Reset(isolate, facade); + state->requireFacadesByTargetHash[target->GetIdentityHash()].push_back( + std::move(entry)); + + return hs.Escape(facade); +} + void IndexModuleForIsolate(v8::Isolate* isolate, const std::string& canonicalKey, v8::Local mod) { auto* state = ModuleLoaderStateFor(isolate); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index f74d36edc..3b59aa1c7 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -58,6 +58,17 @@ void UnindexModuleForIsolate(v8::Isolate* isolate, void IndexModuleForIsolate(v8::Isolate* isolate, const std::string& canonicalKey, v8::Local mod); +// The require(esm) exports facade: a synthetic source-text module that +// re-exports everything from `target` and adds `__esModule = true`, so +// transpiled CJS consumers (`_mod.__esModule ? _mod.default : _mod`) pick up a +// real ESM default export through require(). Re-exports keep the target's live +// bindings and enumerability, which a copied object would not. Returns the +// facade instantiated and evaluated; the caller takes GetModuleNamespace(). +// One facade per target module, cached until the target leaves the registry. +v8::MaybeLocal GetOrCreateRequireFacade( + v8::Isolate* isolate, v8::Local context, + v8::Local target, const std::string& targetCanonicalPath); + // Authoritative HTTP URL loader for dev-served ESM. This compiles and // registers the module under its canonical URL key without evaluating it. v8::MaybeLocal LoadHttpModuleForUrl( From 062e9be21a7a22bb76826289240660cacbc3c9fa Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 14:12:30 -0300 Subject: [PATCH 20/36] feat(runtime): createRequire and createPumpingRequire on ns:module, node: shims ns:module gains createRequire(filenameOrURL) - Node's argument contract: absolute path, file: URL string, or URL object; TypeError otherwise; http(s) bases refused - and createPumpingRequire(filenameOrURL, options). Options are validated and frozen at mint time (unknown keys throw): deadlineSeconds (positive finite, default 60), onTimeout ('throw'|'return-pending'), pumpRunLoop (default false). A minted require's evaluation options ride the require factory as opaque positional slots and inherit down the dependency tree; the per-directory require cache is fingerprinted by options so a pumping require can never be served a strict closure or poison one. Two new builtins: node:module re-exports createRequire only (a distinct frozen object - createPumpingRequire has no Node counterpart), and node:url ships fileURLToPath/pathToFileURL with Node-strict semantics on primordials-snapshotted intrinsics. The in-resolver node: polyfill (url/module/path) is deleted: unregistered node: specifiers now fail uniformly on every path - require, static import, dynamic import - with 'No such built-in module:', and node:path goes away with it (a future shim candidate, not v1). Pumping evaluation now refuses to run inside a microtask: a top-level await resumes via a microtask, so a pump there could never drain the queue it is running from - it throws up front instead of hanging to the deadline. --- test-app/runtime/CMakeLists.txt | 2 + test-app/runtime/src/main/cpp/HttpLoader.cpp | 5 + .../runtime/src/main/cpp/ModuleInternal.cpp | 228 +++++++++++++++--- .../runtime/src/main/cpp/ModuleInternal.h | 30 ++- .../src/main/cpp/ModuleInternalCallbacks.cpp | 135 +---------- .../runtime/src/main/cpp/NsBuiltinModules.cpp | 2 + test-app/runtime/src/main/cpp/Runtime.h | 9 + test-app/runtime/src/main/cpp/js/README.md | 3 + .../runtime/src/main/cpp/js/node-module.js | 15 ++ test-app/runtime/src/main/cpp/js/node-url.js | 104 ++++++++ test-app/runtime/src/main/cpp/js/ns-module.js | 161 ++++++++++++- .../runtime/src/main/cpp/js/primordials.js | 5 + .../src/main/cpp/js/require-factory.js | 9 +- 13 files changed, 529 insertions(+), 179 deletions(-) create mode 100644 test-app/runtime/src/main/cpp/js/node-module.js create mode 100644 test-app/runtime/src/main/cpp/js/node-url.js diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index 9c085b026..7d91ae760 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -73,6 +73,8 @@ set(RUNTIME_BUILTIN_JS ${RUNTIME_BUILTIN_JS_DIR}/events.js ${RUNTIME_BUILTIN_JS_DIR}/inspect.js ${RUNTIME_BUILTIN_JS_DIR}/json-helper.js + ${RUNTIME_BUILTIN_JS_DIR}/node-module.js + ${RUNTIME_BUILTIN_JS_DIR}/node-url.js ${RUNTIME_BUILTIN_JS_DIR}/node-util.js ${RUNTIME_BUILTIN_JS_DIR}/ns-module.js ${RUNTIME_BUILTIN_JS_DIR}/ns-runtime.js diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp index baead956b..9ba1884d8 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.cpp +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -17,6 +17,7 @@ #include "ArgConverter.h" #include "JEnv.h" +#include "ModuleInternal.h" #include "ModuleInternalCallbacks.h" #include "NativeScriptAssert.h" #include "NativeScriptException.h" @@ -1102,6 +1103,10 @@ bool BuildNsModuleBinding(v8::Local context, v8::Local GetLoadedModuleUrlsCallback); InstallDevFunction(isolate, context, binding, "setDevBootComplete", SetDevBootCompleteCallback); + if (!ModuleInternal::InstallCreateRequireBinding(context, binding)) { + return false; + } + if (IsDebuggable()) { auto canonicalizeCb = [](const v8::FunctionCallbackInfo& info) { v8::Isolate* iso = info.GetIsolate(); diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index f7eed66e5..ab2452d77 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -102,6 +102,9 @@ static bool IsBareSpecifier(const std::string& specifier) { return specifier.find(':') == std::string::npos; } +static ModuleEvaluationOptions BootEntryEvaluationOptions(bool isHttpModule); +static ModuleEvaluationOptions RequireEvaluationOptions(ModuleEvaluationPolicy policy); + // Helper function to check if a file path is an ES module (.mjs) but not a source map (.mjs.map) bool ModuleInternal::IsESModule(const std::string& path) { return path.size() >= 4 && path.compare(path.size() - 4, 4, ".mjs") == 0 && @@ -174,18 +177,74 @@ void ModuleInternal::Init(Isolate* isolate, const string& baseDir) { Local globalRequire; if (!baseDir.empty()) { - globalRequire = GetRequireFunction(isolate, baseDir); + globalRequire = GetRequireFunction(isolate, baseDir, RequireEvaluationOptions( + ModuleEvaluationPolicy::kSyncStrict)); } else { - globalRequire = GetRequireFunction(isolate, Constants::APP_ROOT_FOLDER_PATH); + globalRequire = GetRequireFunction(isolate, Constants::APP_ROOT_FOLDER_PATH, + RequireEvaluationOptions( + ModuleEvaluationPolicy::kSyncStrict)); } global->Set(context, ArgConverter::ConvertToV8String(isolate, "require"), globalRequire); } -Local ModuleInternal::GetRequireFunction(Isolate* isolate, const string& dirName) { +// How an entry module's graph settles. For local modules the bound is a yield, +// not a timeout: only nestable V8 tasks can run while these JS frames are on +// the stack, so a TLA parked on a non-nestable foreground task can never settle +// in-pump — give it one short window, then return and let the real event loop +// finish it after the turn. HTTP entries must settle in-pump — the dev client +// needs the rejection reason synchronously — so they get the full deadline and +// the looper slices their transport needs. +static ModuleEvaluationOptions BootEntryEvaluationOptions(bool isHttpModule) { + ModuleEvaluationOptions options; + options.policy = ModuleEvaluationPolicy::kSyncPumping; + options.deadlineSeconds = isHttpModule ? kModuleEvaluateDeadlineSeconds : 1.0; + options.timeoutBehavior = isHttpModule + ? ModuleEvaluationOptions::TimeoutBehavior::kThrow + : ModuleEvaluationOptions::TimeoutBehavior::kReturnPending; + options.pumpRunLoop = isHttpModule; + return options; +} + +// How a graph reached through require() settles. A pumping require must settle +// or throw — handing back a half-initialized namespace is what the strict +// policy exists to prevent — so it gets the full deadline. It never slices the +// looper by default: outside boot the loop belongs to the app, and re-entering +// arbitrary looper sources from the middle of a require would run UI callbacks +// underneath JS frames. +static ModuleEvaluationOptions RequireEvaluationOptions(ModuleEvaluationPolicy policy) { + ModuleEvaluationOptions options; + options.policy = policy; + if (policy == ModuleEvaluationPolicy::kSyncPumping) { + options.deadlineSeconds = kModuleEvaluateDeadlineSeconds; + options.timeoutBehavior = ModuleEvaluationOptions::TimeoutBehavior::kThrow; + options.pumpRunLoop = false; + } + return options; +} + +// The require cache is keyed by directory AND by the options the require was +// minted with: a pumping require for a directory must never be served from a +// strict require cached for the same directory, in either direction. +static std::string RequireCacheKey(const std::string& dirName, + const ModuleEvaluationOptions& options) { + std::string key = dirName; + key += '\x1f'; + key += std::to_string(static_cast(options.policy)); + key += '\x1f'; + key += std::to_string(options.deadlineSeconds); + key += '\x1f'; + key += (options.timeoutBehavior == ModuleEvaluationOptions::TimeoutBehavior::kThrow) ? '1' : '0'; + key += options.pumpRunLoop ? '1' : '0'; + return key; +} + +Local ModuleInternal::GetRequireFunction(Isolate* isolate, const string& dirName, + const ModuleEvaluationOptions& options) { TNSPERF(); Local requireFunc; - auto itFound = m_requireCache.find(dirName); + const std::string cacheKey = RequireCacheKey(dirName, options); + auto itFound = m_requireCache.find(cacheKey); if (itFound != m_requireCache.end()) { requireFunc = Local::New(isolate, *itFound->second); @@ -196,12 +255,18 @@ Local ModuleInternal::GetRequireFunction(Isolate* isolate, const strin auto requireInternalFunc = Local::New(isolate, *m_requireFunction); - Local args[2] { - requireInternalFunc, ArgConverter::ConvertToV8String(isolate, dirName) + Local args[6] { + requireInternalFunc, + ArgConverter::ConvertToV8String(isolate, dirName), + Integer::New(isolate, static_cast(options.policy)), + Number::New(isolate, options.deadlineSeconds), + v8::Boolean::New(isolate, options.timeoutBehavior == + ModuleEvaluationOptions::TimeoutBehavior::kThrow), + v8::Boolean::New(isolate, options.pumpRunLoop) }; Local result; auto thiz = Object::New(isolate); - auto success = requireFuncFactory->Call(context, thiz, 2, args).ToLocal(&result); + auto success = requireFuncFactory->Call(context, thiz, 6, args).ToLocal(&result); NS_CHECK(success && !result.IsEmpty() && result->IsFunction()); @@ -209,12 +274,63 @@ Local ModuleInternal::GetRequireFunction(Isolate* isolate, const strin auto poFunc = new Persistent(isolate, requireFunc); - m_requireCache.emplace(dirName, poFunc); + m_requireCache.emplace(cacheKey, poFunc); } return requireFunc; } +void ModuleInternal::CreateRequireCallback(const v8::FunctionCallbackInfo& args) { + Isolate* isolate = args.GetIsolate(); + if (args.Length() < 1 || !args[0]->IsString()) { + isolate->ThrowException(Exception::TypeError(ArgConverter::ConvertToV8String( + isolate, "createRequire expects a base directory string"))); + return; + } + + Runtime* runtime = Runtime::GetRuntime(isolate); + ModuleInternal* moduleInternal = runtime != nullptr ? runtime->GetModuleInternal() : nullptr; + if (moduleInternal == nullptr) { + isolate->ThrowException(Exception::Error(ArgConverter::ConvertToV8String( + isolate, "createRequire is unavailable: this isolate has no module loader"))); + return; + } + + string dirName = ArgConverter::ConvertToString(args[0].As()); + const bool pumping = args.Length() > 1 && args[1]->BooleanValue(isolate); + ModuleEvaluationOptions options = RequireEvaluationOptions( + pumping ? ModuleEvaluationPolicy::kSyncPumping : ModuleEvaluationPolicy::kSyncStrict); + + // ns-module.js has already validated these and passes undefined for anything + // the caller left out, so each present value simply overrides its default. + if (args.Length() > 2 && args[2]->IsNumber()) { + options.deadlineSeconds = args[2].As()->Value(); + } + if (args.Length() > 3 && args[3]->IsBoolean()) { + options.timeoutBehavior = args[3]->BooleanValue(isolate) + ? ModuleEvaluationOptions::TimeoutBehavior::kThrow + : ModuleEvaluationOptions::TimeoutBehavior::kReturnPending; + } + if (args.Length() > 4 && args[4]->IsBoolean()) { + options.pumpRunLoop = args[4]->BooleanValue(isolate); + } + + args.GetReturnValue().Set(moduleInternal->GetRequireFunction(isolate, dirName, options)); +} + +bool ModuleInternal::InstallCreateRequireBinding(Local context, Local binding) { + Isolate* isolate = v8::Isolate::GetCurrent(); + Local fn; + if (!Function::New(context, ModuleInternal::CreateRequireCallback).ToLocal(&fn)) { + return false; + } + fn->SetName(ArgConverter::ConvertToV8String(isolate, "createRequire")); + return binding->CreateDataProperty(context, + ArgConverter::ConvertToV8String(isolate, "createRequire"), + fn) + .FromMaybe(false); +} + void ModuleInternal::RequireCallback(const v8::FunctionCallbackInfo& args) { try { auto thiz = static_cast(args.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); @@ -235,8 +351,8 @@ void ModuleInternal::RequireCallback(const v8::FunctionCallbackInfo& void ModuleInternal::RequireCallbackImpl(const v8::FunctionCallbackInfo& args) { auto isolate = args.GetIsolate(); - if (args.Length() != 2) { - throw NativeScriptException(string("require should be called with two parameters")); + if (args.Length() < 2) { + throw NativeScriptException(string("require should be called with at least two parameters")); } if (!args[0]->IsString()) { throw NativeScriptException(string("require's first parameter should be string")); @@ -281,7 +397,28 @@ void ModuleInternal::RequireCallbackImpl(const v8::FunctionCallbackInfo()); auto isData = false; - auto moduleObj = LoadImpl(isolate, moduleName, callingModuleDirName, isData); + // The require factory forwards the options its require was minted with; an + // absent policy is the strict default every ordinary require uses. + ModuleEvaluationPolicy policy = ModuleEvaluationPolicy::kSyncStrict; + if (args.Length() > 2 && args[2]->IsInt32() && + args[2].As()->Value() == static_cast(ModuleEvaluationPolicy::kSyncPumping)) { + policy = ModuleEvaluationPolicy::kSyncPumping; + } + ModuleEvaluationOptions evaluationOptions = RequireEvaluationOptions(policy); + if (args.Length() > 3 && args[3]->IsNumber()) { + evaluationOptions.deadlineSeconds = args[3].As()->Value(); + } + if (args.Length() > 4 && args[4]->IsBoolean()) { + evaluationOptions.timeoutBehavior = + args[4]->BooleanValue(isolate) + ? ModuleEvaluationOptions::TimeoutBehavior::kThrow + : ModuleEvaluationOptions::TimeoutBehavior::kReturnPending; + } + if (args.Length() > 5 && args[5]->IsBoolean()) { + evaluationOptions.pumpRunLoop = args[5]->BooleanValue(isolate); + } + + auto moduleObj = LoadImpl(isolate, moduleName, callingModuleDirName, isData, evaluationOptions); if (isData) { NS_DCHECK(!moduleObj.IsEmpty()); @@ -312,7 +449,7 @@ void ModuleInternal::Load(Local context, const string& path) { if (IsHttpModulePath(path) || IsESModule(path)) { // The entry runs before this thread's event loop does, so its graph can // only make progress from the pump inside LoadESModule. - LoadESModule(isolate, path, ModuleEvaluationPolicy::kSyncPumping); + LoadESModule(isolate, path, BootEntryEvaluationOptions(IsHttpModulePath(path))); return; } auto globalObject = context->Global(); @@ -349,7 +486,9 @@ void ModuleInternal::CheckFileExists(Isolate* isolate, const std::string& path, env.CallStaticObjectMethod(MODULE_CLASS, RESOLVE_PATH_METHOD_ID, (jstring) jsModulename, (jstring) jsBaseDir); } -Local ModuleInternal::LoadImpl(Isolate* isolate, const string& moduleName, const string& baseDir, bool& isData) { +Local ModuleInternal::LoadImpl(Isolate* isolate, const string& moduleName, + const string& baseDir, bool& isData, + const ModuleEvaluationOptions& options) { auto pathKind = GetModulePathKind(moduleName); auto cachePathKey = (pathKind == ModulePathKind::Global) ? moduleName : (baseDir + "*" + moduleName); @@ -407,7 +546,7 @@ Local ModuleInternal::LoadImpl(Isolate* isolate, const string& moduleNam if (it2 == m_loadedModules.end()) { if (Util::EndsWith(path, ".js") || Util::EndsWith(path, ".mjs") || Util::EndsWith(path, ".so")) { isData = false; - result = LoadModule(isolate, path, cachePathKey); + result = LoadModule(isolate, path, cachePathKey, options); } else if (Util::EndsWith(path, ".json")) { isData = true; result = LoadData(isolate, path); @@ -494,7 +633,9 @@ static Local RequireExportsForNamespace(Isolate* isolate, Local return facade->GetModuleNamespace(); } -Local ModuleInternal::LoadModule(Isolate* isolate, const string& modulePath, const string& moduleCacheKey) { +Local ModuleInternal::LoadModule(Isolate* isolate, const string& modulePath, + const string& moduleCacheKey, + const ModuleEvaluationOptions& options) { string frameName("LoadModule " + modulePath); tns::instrumentation::Frame frame(frameName); CrashBreadcrumbs::ModuleScope moduleBreadcrumb(modulePath.c_str()); @@ -517,8 +658,7 @@ Local ModuleInternal::LoadModule(Isolate* isolate, const string& moduleP if (Util::EndsWith(modulePath, ".mjs")) { // require()'s route into the ES module system, which cannot wait: an // async graph is refused rather than pumped. - Local moduleNamespace = - LoadESModule(isolate, modulePath, ModuleEvaluationPolicy::kSyncStrict); + Local moduleNamespace = LoadESModule(isolate, modulePath, options); // `module.exports` is what Node's populateCJSExportsFromESM produces for // this namespace, not the namespace itself. A namespace can still be @@ -612,7 +752,9 @@ Local ModuleInternal::LoadModule(Isolate* isolate, const string& moduleP strcpy(pathcopy, modulePath.c_str()); string strDirName(dirname(pathcopy)); auto dirName = ArgConverter::ConvertToV8String(isolate, strDirName); - auto require = GetRequireFunction(isolate, strDirName); + // A module's own require inherits the options it was loaded under, so a + // pumping require's whole dependency tree keeps pumping. + auto require = GetRequireFunction(isolate, strDirName, options); Local requireArgs[5] { moduleObj, exportsObj, require, fileName, dirName }; @@ -746,8 +888,20 @@ namespace { // Never evicts: the module is perfectly loadable through import(). [[noreturn]] void ThrowAsyncGraphRefusal(const std::string& canonicalPath) { throw NativeScriptException("require() cannot load ES module '" + canonicalPath + - "': the module graph contains top-level await. Use import() " - "instead."); + "': the module graph contains top-level await. Use import() or " + "createPumpingRequire from ns:module instead."); +} + +// The pump advances the loop with nestable tasks and microtask checkpoints, and +// V8 ignores a checkpoint while the isolate is already draining the microtask +// queue — so a graph whose top-level await resumes through a promise reaction +// could never settle from here. Refused up front, before evaluation, so the +// graph stays instantiated and import() can still load it. +[[noreturn]] void ThrowMicrotaskPumpRefusal(const std::string& canonicalPath) { + throw NativeScriptException( + "createPumpingRequire cannot settle module graph '" + canonicalPath + + "' from inside a microtask (after an await or inside a promise callback): the event " + "loop cannot be pumped re-entrantly. Call it from a task context, or use import()."); } // Evicts the module and surfaces the rejection reason. Always throws, in every @@ -789,6 +943,14 @@ MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local co } } + if (options.policy == ModuleEvaluationPolicy::kSyncPumping && module->IsGraphAsync() && + v8::MicrotasksScope::IsRunningMicrotasks(isolate)) { + // Only an async graph needs the pump; a synchronous one settles on its own + // and stays legal from anywhere. Entry modules also arrive here, but from + // native at task level, so they never trip this. + ThrowMicrotaskPumpRefusal(canonicalPath); + } + TryCatch tcEval(isolate); Local result; if (!module->Evaluate(context).ToLocal(&result)) { @@ -888,7 +1050,7 @@ MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local co // registered by ResolveModuleCallback while V8 walks the graph from here; // nothing below the root evaluates on its own. Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& path, - ModuleEvaluationPolicy policy) { + const ModuleEvaluationOptions& options) { auto context = isolate->GetCurrentContext(); const bool isHttpModule = IsHttpModulePath(path); // The key the resolver would derive for this same module as someone's @@ -922,7 +1084,7 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p // A top-level-await graph reports kEvaluated while its capability // promise is still pending, so the namespace here may be in its TDZ; // require() refuses the graph whatever the load order, matching Node. - if (policy == ModuleEvaluationPolicy::kSyncStrict && module->IsGraphAsync()) { + if (options.policy == ModuleEvaluationPolicy::kSyncStrict && module->IsGraphAsync()) { ThrowAsyncGraphRefusal(canonicalPath); } return module->GetModuleNamespace(); @@ -944,7 +1106,8 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p // A top-level-await graph reports kEvaluated while its capability // promise is still pending, so the namespace here may be in its TDZ; // require() refuses the graph whatever the load order, matching Node. - if (policy == ModuleEvaluationPolicy::kSyncStrict && existing->IsGraphAsync()) { + if (options.policy == ModuleEvaluationPolicy::kSyncStrict && + existing->IsGraphAsync()) { ThrowAsyncGraphRefusal(canonicalPath); } return existing->GetModuleNamespace(); @@ -1008,23 +1171,8 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p } } - // Evaluate the graph under the caller's policy. - ModuleEvaluationOptions evalOptions; - evalOptions.policy = policy; - if (policy == ModuleEvaluationPolicy::kSyncPumping) { - // For local modules the bound is a yield, not a timeout: only nestable V8 - // tasks can run while these JS frames are on the stack, so a TLA parked on - // a non-nestable foreground task can never settle in-pump — give it one - // short window, then return and let the real event loop finish it after - // the turn. HTTP entries must settle in-pump — the dev client needs the - // rejection reason synchronously — so they get the full deadline. - evalOptions.deadlineSeconds = isHttpModule ? kModuleEvaluateDeadlineSeconds : 1.0; - evalOptions.timeoutBehavior = isHttpModule - ? ModuleEvaluationOptions::TimeoutBehavior::kThrow - : ModuleEvaluationOptions::TimeoutBehavior::kReturnPending; - evalOptions.pumpRunLoop = isHttpModule; - } - EvaluateModuleGraph(isolate, context, module, canonicalPath, evalOptions); + // Evaluate the graph under the caller's options. + EvaluateModuleGraph(isolate, context, module, canonicalPath, options); return module->GetModuleNamespace(); } diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.h b/test-app/runtime/src/main/cpp/ModuleInternal.h index a48eb68d6..b23842c9f 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.h +++ b/test-app/runtime/src/main/cpp/ModuleInternal.h @@ -85,7 +85,15 @@ class ModuleInternal { * ModuleEvaluationPolicy. */ static v8::Local LoadESModule(v8::Isolate* isolate, const std::string& path, - ModuleEvaluationPolicy policy); + const ModuleEvaluationOptions& options); + + /* + * Installs `createRequire` on the `ns:module` binding object. Kept here rather + * than with the dev-loader members because it hands out the very require the + * CommonJS loader builds for every module. + */ + static bool InstallCreateRequireBinding(v8::Local context, + v8::Local binding); /* * Read + compile `path` as an ES module WITHOUT registering, instantiating or @@ -121,19 +129,33 @@ class ModuleInternal { static void RequireNativeCallback(const v8::FunctionCallbackInfo& args); + static void CreateRequireCallback(const v8::FunctionCallbackInfo& args); + void RequireCallbackImpl(const v8::FunctionCallbackInfo& args); v8::Local WrapModuleContent(const std::string& path); - v8::Local LoadImpl(v8::Isolate* isolate, const std::string& moduleName, const std::string& baseDir, bool& isData); + v8::Local LoadImpl(v8::Isolate* isolate, const std::string& moduleName, + const std::string& baseDir, bool& isData, + const ModuleEvaluationOptions& options); - v8::Local LoadModule(v8::Isolate* isolate, const std::string& path, const std::string& moduleCacheKey); + v8::Local LoadModule(v8::Isolate* isolate, const std::string& path, + const std::string& moduleCacheKey, + const ModuleEvaluationOptions& options); v8::Local LoadData(v8::Isolate* isolate, const std::string& path); v8::Local LoadScript(v8::Isolate* isolate, const std::string& modulePath, const v8::Local& fullRequiredModulePath); - v8::Local GetRequireFunction(v8::Isolate* isolate, const std::string& dirName); + /* + * A require bound to `dirName`, whose ES module loads evaluate under `options`. + * The options ride along as trailing arguments to the require factory, so + * nothing about them is ambient — and they are resolved once at mint time, + * never per require() call. + */ + v8::Local GetRequireFunction(v8::Isolate* isolate, + const std::string& dirName, + const ModuleEvaluationOptions& options); v8::ScriptCompiler::CachedData* TryLoadScriptCache(const std::string& path); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 6762c8966..48388e267 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -53,11 +53,6 @@ static inline bool EndsWith(const std::string& value, const std::string& suffix) return std::equal(suffix.rbegin(), suffix.rend(), value.rbegin()); } -// Node.js built-in namespace check (node:url, node:module, node:path, ...). -static bool IsNodeBuiltinModule(const std::string& moduleName) { - return moduleName.rfind("node:", 0) == 0; -} - // Filesystem: `path` names an existing regular file. static bool IsFile(const std::string& path) { struct stat st; @@ -1235,7 +1230,6 @@ struct ModuleResolution { enum class Kind { kUnresolved, // nothing locatable; the caller decides how to report it kBuiltin, // ns:/node: — served from the builtin registry - kNodePolyfill, // node: name with no builtin and no file — in-memory shim kHttp, // absolute http(s) URL kFile, // absolute filesystem path, confirmed to be a regular file }; @@ -1302,8 +1296,9 @@ static ModuleResolution ResolveSpecifierToPath(v8::Isolate* isolate, if (rawSpec.empty()) return result; // Builtins resolve before any path handling, so a file can never shadow one. - if (NsBuiltinModules::IsRegistered(rawSpec) || - NsBuiltinModules::IsNsScheme(rawSpec)) { + // The whole scheme is claimed, registered or not, so an unknown `node:` name + // fails as a missing builtin instead of falling through to the filesystem. + if (NsBuiltinModules::IsBuiltinScheme(rawSpec)) { result.kind = ModuleResolution::Kind::kBuiltin; result.specifier = rawSpec; return result; @@ -1523,13 +1518,6 @@ static ModuleResolution ResolveSpecifierToPath(v8::Isolate* isolate, return result; } - // node: names with no registered builtin and no file on disk get an - // in-memory polyfill module rather than a resolution failure. - if (IsNodeBuiltinModule(spec)) { - result.kind = ModuleResolution::Kind::kNodePolyfill; - return result; - } - result.attempted = absPath; return result; } @@ -2405,108 +2393,6 @@ static v8::MaybeLocal CompileJsonAsEsModule( "file://" + absPath); } -// ───────────────────────────────────────────────────────────── -// node: builtin polyfills (Android). iOS ships node:url only; Android has -// carried node:url / node:module / node:path shims for longer. Kept here to -// avoid a behavior regression relative to current Android main. -static const char* NodeUrlPolyfill() { - return "// In-memory polyfill for node:url\n" - "export function fileURLToPath(url) {\n" - " if (typeof url === 'string') {\n" - " if (url.startsWith('file://')) {\n" - " return decodeURIComponent(url.slice(7));\n" - " }\n" - " return url;\n" - " }\n" - " if (url && typeof url.href === 'string') {\n" - " return fileURLToPath(url.href);\n" - " }\n" - " throw new Error('Invalid URL');\n" - "}\n" - "\n" - "export function pathToFileURL(path) {\n" - " const encoded = encodeURIComponent(path).replace(/%2F/g, '/');\n" - " return new URL('file://' + encoded);\n" - "}\n"; -} - -static const char* NodeModulePolyfill() { - return "// In-memory polyfill for node:module\n" - "export function createRequire(filename) {\n" - " if (typeof require === 'function') {\n" - " return require;\n" - " }\n" - " return function(id) {\n" - " throw new Error('Module ' + id + ' not found. NativeScript require() not available.');\n" - " };\n" - "}\n" - "export default { createRequire };\n"; -} - -static const char* NodePathPolyfill() { - return "// In-memory polyfill for node:path\n" - "export const sep = '/';\n" - "export const delimiter = ':';\n" - "\n" - "export function basename(path, ext) {\n" - " const name = path.split('/').pop() || '';\n" - " return ext && name.endsWith(ext) ? name.slice(0, -ext.length) : name;\n" - "}\n" - "\n" - "export function dirname(path) {\n" - " const parts = path.split('/');\n" - " return parts.slice(0, -1).join('/') || '/';\n" - "}\n" - "\n" - "export function extname(path) {\n" - " const name = basename(path);\n" - " const dot = name.lastIndexOf('.');\n" - " return dot > 0 ? name.slice(dot) : '';\n" - "}\n" - "\n" - "export function join(...paths) {\n" - " return paths.filter(Boolean).join('/').replace(/\\/+/g, '/');\n" - "}\n" - "\n" - "export function resolve(...paths) {\n" - " let resolved = '';\n" - " for (let path of paths) {\n" - " if (path.startsWith('/')) {\n" - " resolved = path;\n" - " } else {\n" - " resolved = join(resolved, path);\n" - " }\n" - " }\n" - " return resolved || '/';\n" - "}\n" - "\n" - "export function isAbsolute(path) {\n" - " return path.startsWith('/');\n" - "}\n" - "\n" - "export default { basename, dirname, extname, join, resolve, isAbsolute, sep, delimiter };\n"; -} - -// Compile + register a node: builtin polyfill under `key`. Returns the -// compiled (but not instantiated) module on success. -static v8::MaybeLocal CompileNodeBuiltinPolyfill( - v8::Isolate* isolate, v8::Local context, - const std::string& spec, const std::string& key) { - const std::string builtinName = spec.substr(5); // drop "node:" - const char* polyfill = nullptr; - if (builtinName == "url") polyfill = NodeUrlPolyfill(); - else if (builtinName == "module") polyfill = NodeModulePolyfill(); - else if (builtinName == "path") polyfill = NodePathPolyfill(); - else { - isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String( - isolate, NsBuiltinModules::NotFoundMessage(spec)))); - return v8::MaybeLocal(); - } - // The polyfill source is the runtime's own, so a compile failure here is a - // runtime bug: keep the parse error rather than masking it. - return CompileModuleForResolveRegisterOnly(isolate, context, polyfill, key); -} - // ───────────────────────────────────────────────────────────── // ResolveModuleCallback — invoked by V8 to resolve `import X from ''`. // @@ -2558,21 +2444,6 @@ v8::MaybeLocal ResolveModuleCallback( case ModuleResolution::Kind::kHttp: // Security: HttpFetchModule gates remote module access centrally. return LoadHttpModuleForUrl(isolate, context, resolution.url); - case ModuleResolution::Kind::kNodePolyfill: { - const std::string& key = resolution.specifier; // e.g. "node:url" - auto itExisting = g_moduleRegistry.find(key); - if (itExisting != g_moduleRegistry.end()) { - v8::Local existing = itExisting->second.Get(isolate); - if (!existing.IsEmpty() && - existing->GetStatus() != v8::Module::kErrored) { - return v8::MaybeLocal(existing); - } - RemoveModuleFromRegistry(key); - } - // On failure CompileNodeBuiltinPolyfill has already thrown (unknown - // builtin, or compile failure); do not overwrite that exception. - return CompileNodeBuiltinPolyfill(isolate, context, key, key); - } case ModuleResolution::Kind::kUnresolved: { // Surfaced as an exception rather than left to ReadFileText, which would // abort trying to open a directory. diff --git a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp index ba9943911..f2f3f3f10 100644 --- a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp +++ b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp @@ -38,6 +38,8 @@ constexpr Registration kRegistry[] = { {"ns:module", BuiltinId::kNsModule}, {"ns:runtime", BuiltinId::kNsRuntime}, {"ns:util", BuiltinId::kNsUtil}, + {"node:module", BuiltinId::kNodeModule}, + {"node:url", BuiltinId::kNodeUrl}, {"node:util", BuiltinId::kNodeUtil}, }; diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index 1c8d18ef8..38e9a5318 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -177,6 +177,15 @@ class Runtime { return m_eventLoop; } + /* + * This runtime's CommonJS loader. `ns:module`'s createRequire mints its + * requires through it, so the require it hands out is the very one the + * loader builds for every module. + */ + ModuleInternal* GetModuleInternal() { + return &m_module; + } + /* * Milliseconds since this runtime's time origin, on the monotonic * clock. Not inline: v8::Platform is only forward-declared through diff --git a/test-app/runtime/src/main/cpp/js/README.md b/test-app/runtime/src/main/cpp/js/README.md index 054a384f9..16a7fea53 100644 --- a/test-app/runtime/src/main/cpp/js/README.md +++ b/test-app/runtime/src/main/cpp/js/README.md @@ -48,6 +48,9 @@ module.exports = somethingTheCallSiteNeeds; contract. - `ns-module.js` is the `ns:module` loader-control surface and `ns-runtime.js` is the `ns:runtime` live config surface (`setConfig`/`getConfig`). +- `node-module.js` re-exports `ns:module`'s `createRequire` as the `node:module` + shim, and `node-url.js` is the `node:url` shim (`fileURLToPath` / + `pathToFileURL`), the one shim with no `ns:` counterpart to adapt. - Destructure `binding` and `primordials` once, at the top of the file, so the file's dependencies are visible and greppable. diff --git a/test-app/runtime/src/main/cpp/js/node-module.js b/test-app/runtime/src/main/cpp/js/node-module.js new file mode 100644 index 000000000..5cd3202b1 --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/node-module.js @@ -0,0 +1,15 @@ +"use strict"; + +// The `node:module` compatibility shim: the documented subset of Node's +// module API, backed by `ns:module` (docs/ns-builtin-modules.md). Compiled +// the first time `node:module` is resolved, so an app that never touches the +// `node:` scheme never pays for it. +// +// Only `createRequire` is re-exported. `createPumpingRequire` is a +// NativeScript extension with no Node counterpart and stays on `ns:module`, +// so code written against this shim keeps running on Node unchanged. + +const { ObjectFreeze } = primordials; +const { createRequire } = require("ns:module"); + +module.exports = ObjectFreeze({ createRequire }); diff --git a/test-app/runtime/src/main/cpp/js/node-url.js b/test-app/runtime/src/main/cpp/js/node-url.js new file mode 100644 index 000000000..d499e1b64 --- /dev/null +++ b/test-app/runtime/src/main/cpp/js/node-url.js @@ -0,0 +1,104 @@ +"use strict"; + +// The `node:url` compatibility shim: the two path/URL converters +// (docs/ns-builtin-modules.md). Parsing goes through the URL intrinsic rather +// than a hand-rolled scan, so authority normalization (`file://localhost/x` +// has no host, per the URL spec), percent-decoding and path canonicalization +// all follow the spec instead of an approximation. + +const { + decodeURIComponent, + ObjectFreeze, + StringPrototypeCharCodeAt, + StringPrototypeStartsWith, + TypeError, + URL, +} = primordials; + +const INVALID_ARG = + 'The "path" argument must be of type string or an instance of URL.'; + +function toUrl(input) { + let href; + if (typeof input === "string") { + href = input; + } else if (input !== null && typeof input === "object" && + typeof input.href === "string") { + // Duck-typed so a URL from another realm still works. + href = input.href; + } else { + throw new TypeError(INVALID_ARG); + } + + try { + return new URL(href); + } catch { + throw new TypeError(INVALID_ARG); + } +} + +function fileURLToPath(input) { + const url = toUrl(input); + + if (url.protocol !== "file:") { + throw new TypeError("The URL must be of scheme file"); + } + // The URL parser already folded a "localhost" authority to the empty host, + // so anything left here is a real remote host and names no local file. + if (url.hostname !== "") { + throw new TypeError('File URL host must be "localhost" or empty'); + } + + // `pathname` carries neither the query nor the fragment. + const pathname = url.pathname; + for (let i = 0; i < pathname.length; i++) { + if (pathname[i] !== "%") { + continue; + } + // %2F would decode to a separator and silently change the path's shape. + const third = StringPrototypeCharCodeAt(pathname, i + 2) | 0x20; + if (pathname[i + 1] === "2" && third === 102 /* 'f' */) { + throw new TypeError("File URL path must not include encoded / characters"); + } + } + + return decodeURIComponent(pathname); +} + +const kHexDigits = "0123456789ABCDEF"; + +// Percent-encode everything the URL parser would otherwise read as syntax (or +// reject), leaving `/` as the separator it is. Non-ASCII is left alone: the +// parser UTF-8 encodes it correctly on its own. +function encodePathChars(filepath) { + let encoded = ""; + for (let i = 0; i < filepath.length; i++) { + const char = filepath[i]; + const code = StringPrototypeCharCodeAt(filepath, i); + const mustEncode = + code < 0x21 || code === 0x7f || char === "%" || char === "?" || + char === "#" || char === "\\" || char === '"' || char === "<" || + char === ">" || char === "`" || char === "{" || char === "}"; + if (mustEncode) { + encoded += "%" + kHexDigits[(code >> 4) & 0xf] + kHexDigits[code & 0xf]; + } else { + encoded += char; + } + } + return encoded; +} + +function pathToFileURL(filepath) { + if (typeof filepath !== "string") { + throw new TypeError('The "path" argument must be of type string.'); + } + // Node resolves a relative path against the process working directory; there + // is no such thing here, so a relative path has no single correct answer. + if (!StringPrototypeStartsWith(filepath, "/")) { + throw new TypeError('The "path" argument must be an absolute path.'); + } + + return new URL("file://" + encodePathChars(filepath)); +} + +module.exports = ObjectFreeze({ fileURLToPath, pathToFileURL }); diff --git a/test-app/runtime/src/main/cpp/js/ns-module.js b/test-app/runtime/src/main/cpp/js/ns-module.js index 9e3b6ce7a..b409b9981 100644 --- a/test-app/runtime/src/main/cpp/js/ns-module.js +++ b/test-app/runtime/src/main/cpp/js/ns-module.js @@ -10,13 +10,172 @@ // Missing members are simply absent — never present-but-throwing — so // feature checks work. -const { ObjectFreeze } = primordials; +const { + ArrayPrototypeIndexOf, + decodeURIComponent, + NumberIsFinite, + ObjectFreeze, + ObjectKeys, + StringPrototypeEndsWith, + StringPrototypeIndexOf, + StringPrototypeLastIndexOf, + StringPrototypeSlice, + StringPrototypeStartsWith, + TypeError, +} = primordials; + +// Node's wording (lib/internal/modules/cjs/loader.js), so a message copied out +// of a NativeScript stack trace still matches what the ecosystem documents. +const CREATE_REQUIRE_ERROR = + "The argument 'filename' must be a file URL object, file URL string, or absolute path string."; + +// A `file:` URL string down to the path it names. Deliberately string-based +// rather than routed through the global URL: this runs before app code and +// must not depend on an intrinsic the app may have replaced. +function fileUrlToPath(href) { + let rest = StringPrototypeSlice(href, "file://".length); + + // Only an empty or localhost authority names a local file. + const authorityEnd = StringPrototypeIndexOf(rest, "/"); + if (authorityEnd < 0) { + throw new TypeError(CREATE_REQUIRE_ERROR); + } + const authority = StringPrototypeSlice(rest, 0, authorityEnd); + if (authority !== "" && authority !== "localhost") { + throw new TypeError(CREATE_REQUIRE_ERROR); + } + rest = StringPrototypeSlice(rest, authorityEnd); + + // The query and fragment are URL syntax, never part of the path. + const queryAt = StringPrototypeIndexOf(rest, "?"); + if (queryAt >= 0) { + rest = StringPrototypeSlice(rest, 0, queryAt); + } + const hashAt = StringPrototypeIndexOf(rest, "#"); + if (hashAt >= 0) { + rest = StringPrototypeSlice(rest, 0, hashAt); + } + + try { + return decodeURIComponent(rest); + } catch { + throw new TypeError(CREATE_REQUIRE_ERROR); + } +} + +// The directory a require created for `filenameOrURL` resolves against. +function requireBaseDir(filenameOrURL) { + let filepath; + + if (typeof filenameOrURL === "object" && filenameOrURL !== null) { + // A URL object, identified by its href rather than by instanceof so a + // URL from another realm still works. + const href = filenameOrURL.href; + if (typeof href !== "string") { + throw new TypeError(CREATE_REQUIRE_ERROR); + } + filepath = urlStringToPath(href); + } else if (typeof filenameOrURL !== "string") { + throw new TypeError(CREATE_REQUIRE_ERROR); + } else if (StringPrototypeStartsWith(filenameOrURL, "/")) { + filepath = filenameOrURL; + } else { + filepath = urlStringToPath(filenameOrURL); + } + + // Node treats a trailing slash as "this directory is the base"; otherwise + // the base is the directory holding the named file. + if (StringPrototypeEndsWith(filepath, "/")) { + const trimmed = StringPrototypeSlice(filepath, 0, filepath.length - 1); + return trimmed === "" ? "/" : trimmed; + } + const lastSlash = StringPrototypeLastIndexOf(filepath, "/"); + return lastSlash <= 0 ? "/" : StringPrototypeSlice(filepath, 0, lastSlash); +} + +function urlStringToPath(value) { + if (StringPrototypeStartsWith(value, "file://")) { + return fileUrlToPath(value); + } + if (StringPrototypeStartsWith(value, "http://") || + StringPrototypeStartsWith(value, "https://")) { + // require() over HTTP is blocked runtime-wide; a dev-served module is + // reachable through import(), and a require base must name a real file. + throw new TypeError( + "createRequire() cannot take an http(s) URL (" + value + + "): require() of a dev-served module is not supported. Pass an app-root " + + "file path and use import() for remote modules."); + } + throw new TypeError(CREATE_REQUIRE_ERROR); +} + +// Every option a pumping require accepts, so an unknown key is a typo the +// caller hears about rather than a setting that silently does nothing. +const kPumpingOptionKeys = ["deadlineSeconds", "onTimeout", "pumpRunLoop"]; + +// Validated once, when the require is minted — a require() call itself does no +// option work at all. Returns the three values the native mint expects, with +// `undefined` standing for "leave the default alone". +function validatePumpingOptions(options) { + if (options === undefined) { + return { deadlineSeconds: undefined, throwOnTimeout: undefined, pumpRunLoop: undefined }; + } + if (typeof options !== "object" || options === null) { + throw new TypeError("createPumpingRequire: options must be an object"); + } + + const keys = ObjectKeys(options); + for (let i = 0; i < keys.length; i++) { + if (ArrayPrototypeIndexOf(kPumpingOptionKeys, keys[i]) < 0) { + throw new TypeError("createPumpingRequire: unknown option '" + keys[i] + "'"); + } + } + + const deadlineSeconds = options.deadlineSeconds; + if (deadlineSeconds !== undefined && + (typeof deadlineSeconds !== "number" || !NumberIsFinite(deadlineSeconds) || + deadlineSeconds <= 0)) { + throw new TypeError( + "createPumpingRequire: 'deadlineSeconds' must be a positive finite number"); + } + + const onTimeout = options.onTimeout; + if (onTimeout !== undefined && onTimeout !== "throw" && onTimeout !== "return-pending") { + throw new TypeError("createPumpingRequire: 'onTimeout' must be 'throw' or 'return-pending'"); + } + + const pumpRunLoop = options.pumpRunLoop; + if (pumpRunLoop !== undefined && typeof pumpRunLoop !== "boolean") { + throw new TypeError("createPumpingRequire: 'pumpRunLoop' must be a boolean"); + } + + return { + deadlineSeconds, + throwOnTimeout: onTimeout === undefined ? undefined : onTimeout === "throw", + pumpRunLoop, + }; +} + +function createRequire(filenameOrURL, options) { + if (options !== undefined) { + throw new TypeError("options are not supported on createRequire"); + } + return binding.createRequire(requireBaseDir(filenameOrURL), false); +} + +function createPumpingRequire(filenameOrURL, options) { + const resolved = validatePumpingOptions(options); + return binding.createRequire(requireBaseDir(filenameOrURL), true, resolved.deadlineSeconds, + resolved.throwOnTimeout, resolved.pumpRunLoop); +} const surface = { configureLoader: binding.configureLoader, invalidateModules: binding.invalidateModules, getLoadedModuleUrls: binding.getLoadedModuleUrls, setDevBootComplete: binding.setDevBootComplete, + createRequire, + createPumpingRequire, }; if (binding.canonicalizeHttpUrlKey !== undefined) { surface.canonicalizeHttpUrlKey = binding.canonicalizeHttpUrlKey; diff --git a/test-app/runtime/src/main/cpp/js/primordials.js b/test-app/runtime/src/main/cpp/js/primordials.js index 8970295e9..82ac503d3 100644 --- a/test-app/runtime/src/main/cpp/js/primordials.js +++ b/test-app/runtime/src/main/cpp/js/primordials.js @@ -29,6 +29,7 @@ const intrinsics = { Set, String, TypeError, + URL, // Well-known symbols. SymbolIterator: Symbol.iterator, @@ -40,6 +41,7 @@ const intrinsics = { // Statics. ArrayBufferIsView: ArrayBuffer.isView, ArrayIsArray: Array.isArray, + decodeURIComponent, JSONStringify: JSON.stringify, NumberIsFinite: Number.isFinite, NumberIsNaN: Number.isNaN, @@ -82,8 +84,11 @@ const intrinsics = { SetPrototypeHas: uncurryThis(Set.prototype.has), SetPrototypeValues: uncurryThis(Set.prototype.values), StringPrototypeCharCodeAt: uncurryThis(String.prototype.charCodeAt), + StringPrototypeEndsWith: uncurryThis(String.prototype.endsWith), StringPrototypeIndexOf: uncurryThis(String.prototype.indexOf), + StringPrototypeLastIndexOf: uncurryThis(String.prototype.lastIndexOf), StringPrototypeSlice: uncurryThis(String.prototype.slice), + StringPrototypeStartsWith: uncurryThis(String.prototype.startsWith), SymbolPrototypeToString: uncurryThis(Symbol.prototype.toString), // Iterator-protocol escape hatches: the captured `next` of the live map/set diff --git a/test-app/runtime/src/main/cpp/js/require-factory.js b/test-app/runtime/src/main/cpp/js/require-factory.js index dc477af0a..58c5645b2 100644 --- a/test-app/runtime/src/main/cpp/js/require-factory.js +++ b/test-app/runtime/src/main/cpp/js/require-factory.js @@ -1,4 +1,5 @@ -function require_factory(requireInternal, dirName) { +function require_factory(requireInternal, dirName, policy, deadlineSeconds, throwOnTimeout, + pumpRunLoop) { return function require(modulePath) { if (global.__requireOverride) { var result = global.__requireOverride(modulePath, dirName); @@ -6,7 +7,11 @@ function require_factory(requireInternal, dirName) { return result; } } - return requireInternal(modulePath, dirName); + // `policy` and the three evaluate options are opaque native tokens, + // resolved once when this require was minted; undefined means the + // strict default. + return requireInternal(modulePath, dirName, policy, deadlineSeconds, throwOnTimeout, + pumpRunLoop); } } module.exports = require_factory; From 3cccda2ac473e5a675429669565ec4f7233715f4 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 14:18:16 -0300 Subject: [PATCH 21/36] feat(runtime): import-map scopes, validation, and atomic installation The import map is now the full WHATWG shape - {imports, scopes} - parsed with the engine's JSON parser and validated completely before anything is installed: malformed JSON, non-string or empty keys and targets, a trailing-slash key whose target lacks the slash, and unknown top-level sections each throw a TypeError naming the offense, in both builds, and leave the installed vocabulary untouched. The previous parser cleared the live map before reading its input, so a bad payload emptied a running dev session's vocabulary. Scope keys match as plain prefixes of the importing module's canonical registry key. Resolution cascades most-specific matching scope, then outer matching scopes, then top-level imports, each consulted with the same exact-then-longest-trailing-slash-prefix primitive; scopes sort once at install. The one scoped lookup serves the resolver, the graph walk, and dynamic import, whose referrer derives from the host-supplied resource name. --- test-app/runtime/src/main/cpp/HttpLoader.cpp | 18 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 438 ++++++++++-------- .../src/main/cpp/ModuleInternalCallbacks.h | 14 +- 3 files changed, 281 insertions(+), 189 deletions(-) diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp index 9ba1884d8..d48cbb78a 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.cpp +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -977,11 +977,21 @@ void ConfigureLoaderCallback(const v8::FunctionCallbackInfo& info) { if (*utf8) jsonStr = *utf8; } } - if (!jsonStr.empty()) { - SetImportMap(jsonStr); - TNS_DEBUG(Esm, "[ns:module configureLoader] import map set (%zu bytes)", - jsonStr.size()); + if (jsonStr.empty()) { + isolate->ThrowException(v8::Exception::TypeError(ToV8String( + isolate, "configureLoader: importMap must be an object or a JSON string"))); + return; + } + std::string importMapError; + if (!SetImportMap(jsonStr, &importMapError)) { + // The previous map is still installed: a rejected update changes + // nothing, so a typo cannot empty a live session's vocabulary. + isolate->ThrowException(v8::Exception::TypeError( + ToV8String(isolate, "configureLoader: " + importMapError))); + return; } + TNS_DEBUG(Esm, "[ns:module configureLoader] import map set (%zu bytes)", + jsonStr.size()); } auto readStringArray = [&](v8::Local obj, const char* key, diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 48388e267..0a4920fdd 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -241,14 +241,29 @@ struct RequireFacadeEntry { v8::Global facade; }; +// One import-map section: specifier key → target. Lookup within a section is +// exact-then-trailing-slash-prefix with longest match, per the import-maps +// spec. +using ImportMapEntries = robin_hood::unordered_map; + +// A parsed import map. `scopes` is kept ordered most-specific-first so the +// resolution cascade walks it without re-sorting on every lookup. +struct ParsedImportMap { + ImportMapEntries imports; + std::vector> scopes; + + bool empty() const { return imports.empty() && scopes.empty(); } +}; + // Everything the dev client teaches one isolate's loader (see the header's // long-form note): the import map, the canonicalization vocabulary and the // volatile-URL patterns. struct LoaderVocabulary { - // Bare specifier → resolved URL. Instead of rewriting import statements on - // the bundler side, the runtime resolves bare specifiers through this map to - // HTTP module URLs; source code is served as-is. - robin_hood::unordered_map importMap; + // Bare specifier → resolved URL, plus the per-referrer `scopes` overrides. + // Instead of rewriting import statements on the bundler side, the runtime + // resolves bare specifiers through this map to HTTP module URLs; source code + // is served as-is. + ParsedImportMap importMap; // URLs matching any of these substrings are always re-fetched (the cache is // evicted before loading). The vocabulary is server/framework policy, so the @@ -834,187 +849,207 @@ v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, // ───────────────────────────────────────────────────────────── // Import map helpers -// Small hand-rolled JSON scanner for a flat {"imports": {"key": "value", ...}} -// shape. Only strings are accepted; anything malformed is silently skipped — -// same behaviour as the iOS Foundation-based parser for non-object roots. -namespace { -struct JsonScanner { - const std::string& s; - size_t i = 0; +// Read one imports-shaped section. Every rejection names the offending key so +// a bad map is fixable from the message alone. +static bool ParseImportMapEntries(v8::Isolate* isolate, v8::Local context, + v8::Local source, + const std::string& sectionLabel, + ImportMapEntries* out, std::string* error) { + v8::Local keys; + if (!source->GetOwnPropertyNames(context).ToLocal(&keys)) { + *error = sectionLabel + ": could not be read"; + return false; + } + for (uint32_t i = 0; i < keys->Length(); i++) { + v8::Local keyVal; + if (!keys->Get(context, i).ToLocal(&keyVal) || !keyVal->IsString()) { + *error = sectionLabel + ": every key must be a string"; + return false; + } + v8::String::Utf8Value keyUtf8(isolate, keyVal); + if (!*keyUtf8) { + *error = sectionLabel + ": every key must be a string"; + return false; + } + const std::string specifier(*keyUtf8); + if (specifier.empty()) { + *error = sectionLabel + ": a specifier key must not be empty"; + return false; + } - explicit JsonScanner(const std::string& src) : s(src) {} + v8::Local value; + if (!source->Get(context, keyVal).ToLocal(&value) || !value->IsString()) { + *error = sectionLabel + ": the target for '" + specifier + "' must be a string"; + return false; + } + v8::String::Utf8Value valueUtf8(isolate, value); + if (!*valueUtf8) { + *error = sectionLabel + ": the target for '" + specifier + "' must be a string"; + return false; + } + const std::string target(*valueUtf8); + if (target.empty()) { + *error = sectionLabel + ": the target for '" + specifier + "' must not be empty"; + return false; + } - void SkipWs() { - while (i < s.size() && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || - s[i] == '\r')) { - ++i; + // A trailing-slash key maps a whole subtree, so its target must name one + // too — otherwise the remainder would be pasted onto a file path. + if (specifier.back() == '/' && target.back() != '/') { + *error = sectionLabel + ": the target for '" + specifier + + "' must end with '/' because the specifier key does"; + return false; } - } - bool Peek(char c) { - SkipWs(); - return i < s.size() && s[i] == c; + (*out)[specifier] = target; } + return true; +} - bool Consume(char c) { - if (Peek(c)) { - ++i; - return true; - } +// Parse without touching the live map. On any failure `error` explains what is +// wrong and `out` is meaningless — the caller keeps whatever it already had. +// V8's JSON parser stands in for iOS's NSJSONSerialization: escapes, nesting +// and malformed input are handled by the engine rather than a hand-rolled +// scanner, and this always runs on the isolate's own thread. +static bool ParseImportMap(v8::Isolate* isolate, const std::string& json, + ParsedImportMap* out, std::string* error) { + if (json.empty()) { + *error = "an import map must be a non-empty JSON object"; return false; } - // Parses a JSON string into `out`. Handles standard escape sequences - // (\", \\, \/, \b, \f, \n, \r, \t) and \uXXXX (BMP only; surrogate pairs - // are decoded to their two escapes as-is when not paired — good enough - // for the small import-map vocabulary the dev server emits). - bool ReadString(std::string& out) { - SkipWs(); - if (i >= s.size() || s[i] != '"') return false; - ++i; - out.clear(); - while (i < s.size()) { - char c = s[i++]; - if (c == '"') return true; - if (c != '\\') { - out.push_back(c); - continue; - } - if (i >= s.size()) return false; - char e = s[i++]; - switch (e) { - case '"': - case '\\': - case '/': - out.push_back(e); - break; - case 'b': out.push_back('\b'); break; - case 'f': out.push_back('\f'); break; - case 'n': out.push_back('\n'); break; - case 'r': out.push_back('\r'); break; - case 't': out.push_back('\t'); break; - case 'u': { - if (i + 4 > s.size()) return false; - unsigned int cp = 0; - for (int k = 0; k < 4; ++k) { - char h = s[i++]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= (unsigned)(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= (unsigned)(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= (unsigned)(h - 'A' + 10); - else return false; - } - if (cp < 0x80) { - out.push_back((char)cp); - } else if (cp < 0x800) { - out.push_back((char)(0xC0 | (cp >> 6))); - out.push_back((char)(0x80 | (cp & 0x3F))); - } else { - out.push_back((char)(0xE0 | (cp >> 12))); - out.push_back((char)(0x80 | ((cp >> 6) & 0x3F))); - out.push_back((char)(0x80 | (cp & 0x3F))); - } - break; - } - default: - return false; - } - } + v8::Local context = isolate->GetCurrentContext(); + v8::TryCatch tc(isolate); + v8::Local parsed; + if (!v8::JSON::Parse(context, ArgConverter::ConvertToV8String(isolate, json)) + .ToLocal(&parsed)) { + std::string detail = DescribeCaughtError(isolate, context, tc); + *error = "an import map must be valid JSON" + (detail.empty() ? "" : ": " + detail); + return false; + } + if (!parsed->IsObject() || parsed->IsArray()) { + *error = "an import map must be a JSON object"; return false; } + v8::Local top = parsed.As(); - // Skip an arbitrary JSON value (object/array/string/number/keyword) — - // used to step over "imports" siblings we don't care about. - bool SkipValue() { - SkipWs(); - if (i >= s.size()) return false; - char c = s[i]; - if (c == '"') { - std::string tmp; - return ReadString(tmp); - } - if (c == '{' || c == '[') { - char open = c, close = (c == '{') ? '}' : ']'; - int depth = 0; - bool inString = false; - while (i < s.size()) { - char ch = s[i++]; - if (inString) { - if (ch == '\\' && i < s.size()) ++i; - else if (ch == '"') inString = false; - } else { - if (ch == '"') inString = true; - else if (ch == open) ++depth; - else if (ch == close) { - --depth; - if (depth == 0) return true; - } - } - } + // Only the map's OWN keys are sections; reading through the prototype would + // let a polluted Object.prototype smuggle one in. + v8::Local sections; + if (!top->GetOwnPropertyNames(context).ToLocal(§ions)) { + *error = "an import map must be a JSON object"; + return false; + } + bool hasImports = false; + bool hasScopes = false; + for (uint32_t i = 0; i < sections->Length(); i++) { + v8::Local sectionVal; + std::string name; + if (sections->Get(context, i).ToLocal(§ionVal) && sectionVal->IsString()) { + v8::String::Utf8Value utf8(isolate, sectionVal); + if (*utf8) name = *utf8; + } + if (name == "imports") { + hasImports = true; + } else if (name == "scopes") { + hasScopes = true; + } else { + *error = "unsupported import-map section '" + name + + "'; only \"imports\" and \"scopes\" are supported"; return false; } - // Number / true / false / null — read until the next value terminator. - while (i < s.size()) { - char ch = s[i]; - if (ch == ',' || ch == '}' || ch == ']' || ch == ' ' || ch == '\t' || - ch == '\n' || ch == '\r') { - return true; - } - ++i; - } - return true; } -}; -} // namespace - -void SetImportMap(const std::string& json) { - LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); - if (vocabulary == nullptr) return; - auto& g_importMap = vocabulary->importMap; - g_importMap.clear(); - if (json.empty()) return; - JsonScanner sc(json); - if (!sc.Consume('{')) { - TNS_DEBUG(Esm, "[import-map] parse failed: not an object"); - return; + v8::Local imports; + if (hasImports && + top->Get(context, ArgConverter::ConvertToV8String(isolate, "imports")).ToLocal(&imports) && + !imports->IsUndefined()) { + if (!imports->IsObject() || imports->IsArray()) { + *error = "the \"imports\" section must be an object"; + return false; + } + if (!ParseImportMapEntries(isolate, context, imports.As(), "imports", + &out->imports, error)) { + return false; + } } - // Find and enter the "imports" object; skip any siblings. - bool foundImports = false; - while (!sc.Peek('}')) { - std::string key; - if (!sc.ReadString(key)) break; - if (!sc.Consume(':')) break; - if (key == "imports") { - if (!sc.Consume('{')) break; - foundImports = true; - // Parse the flat {"k":"v", ...} body. - while (!sc.Peek('}')) { - std::string k, v; - if (!sc.ReadString(k)) break; - if (!sc.Consume(':')) break; - if (sc.Peek('"')) { - if (!sc.ReadString(v)) break; - g_importMap[k] = v; - } else { - // Skip non-string values (arrays, objects, etc.) — mirrors iOS. - if (!sc.SkipValue()) break; - } - if (!sc.Consume(',')) break; + v8::Local scopes; + if (hasScopes && + top->Get(context, ArgConverter::ConvertToV8String(isolate, "scopes")).ToLocal(&scopes) && + !scopes->IsUndefined()) { + if (!scopes->IsObject() || scopes->IsArray()) { + *error = "the \"scopes\" section must be an object"; + return false; + } + v8::Local scopesObj = scopes.As(); + v8::Local scopeKeys; + if (!scopesObj->GetOwnPropertyNames(context).ToLocal(&scopeKeys)) { + *error = "the \"scopes\" section must be an object"; + return false; + } + for (uint32_t i = 0; i < scopeKeys->Length(); i++) { + v8::Local scopeKeyVal; + if (!scopeKeys->Get(context, i).ToLocal(&scopeKeyVal) || !scopeKeyVal->IsString()) { + *error = "scopes: every scope key must be a string"; + return false; } - sc.Consume('}'); - } else { - if (!sc.SkipValue()) break; + v8::String::Utf8Value scopeUtf8(isolate, scopeKeyVal); + const std::string scopePrefix(*scopeUtf8 ? *scopeUtf8 : ""); + if (scopePrefix.empty()) { + *error = "scopes: a scope key must not be empty"; + return false; + } + v8::Local scopeMap; + if (!scopesObj->Get(context, scopeKeyVal).ToLocal(&scopeMap) || !scopeMap->IsObject() || + scopeMap->IsArray()) { + *error = "scopes: the map for scope '" + scopePrefix + "' must be an object"; + return false; + } + ImportMapEntries entries; + if (!ParseImportMapEntries(isolate, context, scopeMap.As(), + "scope '" + scopePrefix + "'", &entries, error)) { + return false; + } + out->scopes.emplace_back(scopePrefix, std::move(entries)); } - if (!sc.Consume(',')) break; } - if (!foundImports) { - TNS_DEBUG(Esm, "[import-map] no 'imports' object found"); + // Most specific first: a longer prefix is the more specific scope, and the + // key comparison keeps the order deterministic for equal-length prefixes. + std::sort(out->scopes.begin(), out->scopes.end(), + [](const std::pair& a, + const std::pair& b) { + if (a.first.size() != b.first.size()) { + return a.first.size() > b.first.size(); + } + return a.first > b.first; + }); + return true; +} + +bool SetImportMap(const std::string& json, std::string* error) { + v8::Isolate* isolate = v8::Isolate::GetCurrent(); + LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); + std::string localError; + std::string& err = error != nullptr ? *error : localError; + if (vocabulary == nullptr) { + err = "the calling isolate has no loader vocabulary"; + return false; } - TNS_DEBUG(Esm, "[import-map] loaded %lu entries", - (unsigned long)g_importMap.size()); + + // Parse-validate-swap: the live vocabulary is replaced only once a complete + // map has been built, so a rejected update leaves resolution exactly as it + // was rather than silently emptying it. + ParsedImportMap parsedMap; + if (!ParseImportMap(isolate, json, &parsedMap, &err)) { + return false; + } + vocabulary->importMap = std::move(parsedMap); + TNS_DEBUG(Esm, "[import-map] loaded %lu entries, %lu scopes", + (unsigned long)vocabulary->importMap.imports.size(), + (unsigned long)vocabulary->importMap.scopes.size()); + return true; } void SetVolatilePatterns(const std::vector& patterns) { @@ -1179,22 +1214,23 @@ static std::string NormalizeViteSpecifier(const std::string& specifier) { return ""; } -// Look up a specifier in the import map. Supports exact and prefix matches -// (trailing-slash entries like "solid-js/" that map subpaths). -static std::string LookupImportMap(const LoaderVocabulary& vocabulary, +// Look up a specifier in ONE import-map section: exact match first, then the +// longest trailing-slash prefix entry, whose remainder is appended to the +// target. Returns empty when the section has no answer. +static std::string LookupInEntries(const ImportMapEntries& entries, const std::string& specifier) { - const auto& g_importMap = vocabulary.importMap; - auto it = g_importMap.find(specifier); - if (it != g_importMap.end()) { + auto it = entries.find(specifier); + if (it != entries.end()) { TNS_DEBUG(Esm, "[import-map] exact: %s -> %s", specifier.c_str(), it->second.c_str()); return it->second; } + std::string bestKey; std::string bestValue; - for (const auto& kv : g_importMap) { + for (const auto& kv : entries) { const std::string& key = kv.first; - if (key.back() != '/') continue; + if (key.back() != '/') continue; // only trailing-slash entries map subtrees if (specifier.size() > key.size() && specifier.compare(0, key.size(), key) == 0) { if (key.size() > bestKey.size()) { @@ -1203,14 +1239,40 @@ static std::string LookupImportMap(const LoaderVocabulary& vocabulary, } } } - if (!bestKey.empty()) { - std::string remainder = specifier.substr(bestKey.size()); - std::string resolved = bestValue + remainder; - TNS_DEBUG(Esm, "[import-map] prefix: %s -> %s (via %s)", specifier.c_str(), - resolved.c_str(), bestKey.c_str()); - return resolved; + if (bestKey.empty()) return ""; + std::string resolved = bestValue + specifier.substr(bestKey.size()); + TNS_DEBUG(Esm, "[import-map] prefix: %s -> %s (via %s)", specifier.c_str(), + resolved.c_str(), bestKey.c_str()); + return resolved; +} + +// The import-map resolution cascade: the most specific applicable scope first, +// then progressively less specific ones, then the top-level imports — each +// consulted with the same per-section lookup. +// +// A scope key matches as a plain prefix of `referrerKey`, the importing +// module's canonical registry key: an absolute http(s) URL for a served +// module, or a canonical absolute path for a file. That key is this runtime's +// analogue of the web's resolved referrer URL, which is what scope prefixes +// match there. Ending a scope key with '/' keeps it on a directory boundary, +// exactly as on the web. +static std::string LookupImportMap(const LoaderVocabulary& vocabulary, + const std::string& specifier, + const std::string& referrerKey) { + for (const auto& scope : vocabulary.importMap.scopes) { + const std::string& prefix = scope.first; + if (referrerKey.size() < prefix.size() || + referrerKey.compare(0, prefix.size(), prefix) != 0) { + continue; + } + std::string mapped = LookupInEntries(scope.second, specifier); + if (!mapped.empty()) { + TNS_DEBUG(Esm, "[import-map] scope '%s' matched referrer %s", prefix.c_str(), + referrerKey.c_str()); + return mapped; + } } - return ""; + return LookupInEntries(vocabulary.importMap.imports, specifier); } // ───────────────────────────────────────────────────────────── @@ -1324,11 +1386,11 @@ static ModuleResolution ResolveSpecifierToPath(v8::Isolate* isolate, auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState != nullptr && !moduleState->vocabulary.importMap.empty()) { const LoaderVocabulary& vocabulary = moduleState->vocabulary; - std::string mapped = LookupImportMap(vocabulary, spec); + std::string mapped = LookupImportMap(vocabulary, spec, referrerKey); if (mapped.empty()) { std::string normalized = NormalizeViteSpecifier(spec); if (!normalized.empty()) { - mapped = LookupImportMap(vocabulary, normalized); + mapped = LookupImportMap(vocabulary, normalized, referrerKey); if (!mapped.empty()) { TNS_DEBUG(Esm, "[resolver][import-map] normalized: %s -> %s -> %s", spec.c_str(), normalized.c_str(), mapped.c_str()); @@ -1348,7 +1410,8 @@ static ModuleResolution ResolveSpecifierToPath(v8::Isolate* isolate, spec.find('\\') == std::string::npos; if (looksBare) { TNS_DEBUG(Esm, "[resolver][import-map][miss] bare='%s' importMap.size=%lu", - spec.c_str(), (unsigned long)vocabulary.importMap.size()); + spec.c_str(), + (unsigned long)vocabulary.importMap.imports.size()); } } } @@ -2735,14 +2798,25 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } // ── Import map resolution for dynamic import() ── + // The same scoped lookup the resolver and the walk use. The referrer key + // comes from the host-supplied resource name, canonicalized the way the + // registry keys it, so a scope matches an import() exactly as it matches a + // static import from the same module. const LoaderVocabulary& vocabulary = moduleState->vocabulary; if (!vocabulary.importMap.empty() && !normalizedSpec.empty() && normalizedSpec != "@") { - std::string mapped = LookupImportMap(vocabulary, normalizedSpec); + std::string dynamicReferrerKey; + if (!resource_name.IsEmpty() && resource_name->IsString()) { + v8::String::Utf8Value resourceUtf8(isolate, resource_name); + if (*resourceUtf8) { + dynamicReferrerKey = CanonicalizeRegistryKey(*resourceUtf8); + } + } + std::string mapped = LookupImportMap(vocabulary, normalizedSpec, dynamicReferrerKey); if (mapped.empty()) { std::string normalized = NormalizeViteSpecifier(normalizedSpec); if (!normalized.empty()) { - mapped = LookupImportMap(vocabulary, normalized); + mapped = LookupImportMap(vocabulary, normalized, dynamicReferrerKey); if (!mapped.empty()) { TNS_DEBUG(Esm, "[dyn-import][import-map] normalized: %s -> %s -> %s", normalizedSpec.c_str(), normalized.c_str(), mapped.c_str()); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index 3b59aa1c7..1ada8cb05 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -166,9 +166,17 @@ void InitializeImportMetaObject(v8::Local context, // its own and nothing here needs synchronization. All of it must be set from // the isolate's own thread. -// Parse and store an import map from JSON on the calling isolate. Expected -// shape: {"imports": {"key": "value", ...}} -void SetImportMap(const std::string& json); +// Import map support. +// +// Shape: {"imports": {"specifier": "target", ...}, +// "scopes": {"": {imports-shaped map}, ...}} +// +// Parsed and validated in full before anything is installed: on any invalid +// input this returns false with `error` explaining which key or section is +// wrong, and the calling isolate's currently installed map is left untouched. +// Per-isolate like the rest of the loader vocabulary — a worker resolves +// through the copy taken at spawn. +bool SetImportMap(const std::string& json, std::string* error); // Set URL patterns that should bypass module cache (e.g. "/@ns/sfc/", "?v=") // on the calling isolate. From 97710320ccad76ed0d055aa9f7b98eb362dd2e47 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 14:22:19 -0300 Subject: [PATCH 22/36] refactor(runtime): workers copy the loader vocabulary at spawn CaptureLoaderVocabulary runs in the WorkerWrapper constructor - on the parent's JS thread, where the parent's per-isolate state is safely readable - and the copy rides the wrapper by value into BackgroundLooper, where InstallLoaderVocabulary writes it into the worker isolate after runtime init and before any module load. Zero synchronization: each side only ever touches its own isolate's state. A worker therefore resolves through the vocabulary its parent had at spawn; a live worker deliberately does not observe later reconfiguration - the dev client restarts workers when the vocabulary changes. The vocabulary types move to the header for the by-value carry. --- .../src/main/cpp/ModuleInternalCallbacks.cpp | 51 ++++++------------ .../src/main/cpp/ModuleInternalCallbacks.h | 52 +++++++++++++++++++ .../runtime/src/main/cpp/WorkerWrapper.cpp | 5 ++ test-app/runtime/src/main/cpp/WorkerWrapper.h | 8 +++ 4 files changed, 81 insertions(+), 35 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 0a4920fdd..9cc0795f1 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -241,41 +241,6 @@ struct RequireFacadeEntry { v8::Global facade; }; -// One import-map section: specifier key → target. Lookup within a section is -// exact-then-trailing-slash-prefix with longest match, per the import-maps -// spec. -using ImportMapEntries = robin_hood::unordered_map; - -// A parsed import map. `scopes` is kept ordered most-specific-first so the -// resolution cascade walks it without re-sorting on every lookup. -struct ParsedImportMap { - ImportMapEntries imports; - std::vector> scopes; - - bool empty() const { return imports.empty() && scopes.empty(); } -}; - -// Everything the dev client teaches one isolate's loader (see the header's -// long-form note): the import map, the canonicalization vocabulary and the -// volatile-URL patterns. -struct LoaderVocabulary { - // Bare specifier → resolved URL, plus the per-referrer `scopes` overrides. - // Instead of rewriting import statements on the bundler side, the runtime - // resolves bare specifiers through this map to HTTP module URLs; source code - // is served as-is. - ParsedImportMap importMap; - - // URLs matching any of these substrings are always re-fetched (the cache is - // evicted before loading). The vocabulary is server/framework policy, so the - // runtime carries no framework-specific URL strings of its own. - std::vector volatilePatterns; - - CanonicalizationConfig canonicalization; - // Distinguishes "no vocabulary supplied" (mechanical canonicalization only) - // from "supplied, and empty" — an empty vocabulary is explicit policy. - bool canonicalizationConfigured = false; -}; - // ───────────────────────────────────────────────────────────── // Per-isolate module-loader state // @@ -1060,6 +1025,22 @@ void SetVolatilePatterns(const std::vector& patterns) { (unsigned long)vocabulary->volatilePatterns.size()); } +LoaderVocabulary CaptureLoaderVocabulary(v8::Isolate* isolate) { + auto* state = ModuleLoaderStateFor(isolate); + return state != nullptr ? state->vocabulary : LoaderVocabulary(); +} + +void InstallLoaderVocabulary(v8::Isolate* isolate, LoaderVocabulary vocabulary) { + auto* state = ModuleLoaderStateFor(isolate); + if (state == nullptr) return; + state->vocabulary = std::move(vocabulary); + TNS_DEBUG(Esm, + "[import-map] inherited %lu entries, %lu scopes, %lu volatile patterns", + (unsigned long)state->vocabulary.importMap.imports.size(), + (unsigned long)state->vocabulary.importMap.scopes.size(), + (unsigned long)state->vocabulary.volatilePatterns.size()); +} + const CanonicalizationConfig* CanonicalizationConfigForCurrentIsolate() { const LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); if (vocabulary == nullptr || !vocabulary->canonicalizationConfigured) { diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index 1ada8cb05..e1ff02a8f 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -11,6 +11,58 @@ namespace tns { +// ── The loader vocabulary ──────────────────────────────────── +// +// Everything the dev client teaches one isolate's module loader: which bare +// specifiers resolve where, how URLs are keyed, and which URLs are never +// cached. Per-isolate, not process-wide — it lives in the isolate's loader +// state and dies with the isolate. +// +// A worker inherits a COPY taken on the parent's thread at spawn (see +// CaptureLoaderVocabulary / InstallLoaderVocabulary), so no synchronization is +// needed anywhere: each isolate only ever reads and writes its own. A live +// worker therefore does not observe a later reconfiguration — the dev client +// restarts workers on updates. + +// One import-map section: specifier key → target. Lookup within a section is +// exact-then-trailing-slash-prefix with longest match, per the import-maps +// spec. +using ImportMapEntries = robin_hood::unordered_map; + +// A parsed import map. `scopes` is kept ordered most-specific-first so the +// resolution cascade walks it without re-sorting on every lookup. +struct ParsedImportMap { + ImportMapEntries imports; + std::vector> scopes; + + bool empty() const { return imports.empty() && scopes.empty(); } +}; + +struct LoaderVocabulary { + // Bare specifier → resolved URL, plus the per-referrer `scopes` overrides. + // Instead of rewriting import statements on the bundler side, the runtime + // resolves bare specifiers through this map to HTTP module URLs; source + // code is served as-is. + ParsedImportMap importMap; + + // URLs matching any of these substrings are always re-fetched (the cache is + // evicted before loading). The vocabulary is server/framework policy, so + // the runtime carries no framework-specific URL strings of its own. + std::vector volatilePatterns; + + CanonicalizationConfig canonicalization; + // Distinguishes "no vocabulary supplied" (mechanical canonicalization only) + // from "supplied, and empty" — an empty vocabulary is explicit policy. + bool canonicalizationConfigured = false; +}; + +// Copy `isolate`'s vocabulary. Call on that isolate's own thread. +LoaderVocabulary CaptureLoaderVocabulary(v8::Isolate* isolate); + +// Replace `isolate`'s vocabulary wholesale. Call on that isolate's own thread, +// before it loads any module. +void InstallLoaderVocabulary(v8::Isolate* isolate, LoaderVocabulary vocabulary); + // Canonical module key → compiled-module handle map used by the per-isolate // registries below. using ModuleHandleMap = diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index 0e2c228ba..5825e4a9f 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -40,6 +40,8 @@ WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string w // workerPath_ (not workerPath) - the parameter was just moved from threadName_("W" + std::to_string(workerId) + ": " + workerPath_), priority_(priority), + // Runs on the parent's thread, so this is the parent's live vocabulary. + inheritedVocabulary_(CaptureLoaderVocabulary(parentIsolate)), poWorker_(new Persistent(parentIsolate, workerObject)), isClosing_(false), isTerminating_(false), @@ -420,6 +422,9 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { auto context = runtime_->GetContext(); Context::Scope context_scope(context); + // Before any module load runs in this isolate. + InstallLoaderVocabulary(isolate, inheritedVocabulary_); + #ifdef APPLICATION_IN_DEBUG // Expose this worker to an attached Chrome DevTools frontend // as a child target, mirroring the iOS runtime. Created before diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.h b/test-app/runtime/src/main/cpp/WorkerWrapper.h index 5fc46084c..4e8ad1a8f 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.h +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.h @@ -16,6 +16,7 @@ #endif #include "ConcurrentQueue.h" +#include "ModuleInternalCallbacks.h" #include "WorkerMessage.h" #include "v8.h" @@ -165,6 +166,13 @@ class WorkerWrapper : public std::enable_shared_from_this { const std::string threadName_; const int priority_; + // The parent's loader vocabulary, copied on the parent's thread when this + // wrapper is constructed and installed on the worker's own isolate before + // it loads anything. Nothing is shared, so nothing needs synchronizing — + // and a live worker deliberately does not observe a later configureLoader + // on the parent (the dev client restarts workers on vocabulary updates). + const LoaderVocabulary inheritedVocabulary_; + v8::Persistent* poWorker_; std::atomic_bool isClosing_; From 73ebaabc6c57ee800692eb996f7e2e4e92cfb054 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 14:27:25 -0300 Subject: [PATCH 23/36] refactor(runtime): drop hardcoded client vocabulary from the loader The runtime provides mechanism; the client supplies vocabulary. Removed: NormalizeViteSpecifier and both of its second-chance import-map lookups (clients register every rewritten specifier form verbatim), the underscore-chunk bare-specifier heuristic, the import('@') empty-stub sentinel complex on all five sites (a bare '@' now fails loudly as an unresolvable specifier on every route, and can be mapped through the import map like any other specifier), the default canonicalization vocabulary (/ns/, /node_modules/.vite/, /@id/, /@fs/, the /@ng/component preserve rule, and the import/t/v strip params), and the client-URL-shape diagnostic labels. Unconfigured canonicalization is purely mechanical - the fragment is stripped and nothing else; the query is part of the module's identity until the client teaches the runtime otherwise via configureLoader({ canonicalization }). The two supported configure-before-ESM-traffic bootstrap shapes are documented in the cross-runtime contract. --- .../src/main/assets/app/tests/testNsModule.js | 37 +-- test-app/runtime/src/main/cpp/HttpLoader.cpp | 62 ++--- .../runtime/src/main/cpp/MetadataNode.cpp | 4 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 218 +----------------- .../src/main/cpp/ModuleInternalCallbacks.h | 2 +- 5 files changed, 61 insertions(+), 262 deletions(-) diff --git a/test-app/app/src/main/assets/app/tests/testNsModule.js b/test-app/app/src/main/assets/app/tests/testNsModule.js index 6c33b9b2b..a68361f13 100644 --- a/test-app/app/src/main/assets/app/tests/testNsModule.js +++ b/test-app/app/src/main/assets/app/tests/testNsModule.js @@ -67,22 +67,22 @@ describe("HTTP canonical key (ns:module canonicalizeHttpUrlKey)", function () { expect(typeof canon).toBe("function"); }); - it("drops dev cache-busters (t/v/import) but keeps real query params", function () { - checkKey("http://h/ns/core?p=x&t=123&v=9&import=1", "http://h/ns/core?p=x"); + // Unconfigured, canonicalization is purely mechanical: the fragment goes + // and the query stays. Which params are cache-busters and which paths are + // dev endpoints is client vocabulary the runtime no longer guesses. + it("unconfigured: strips the fragment and nothing else", function () { + checkKey("http://h/app/foo.js#frag", "http://h/app/foo.js"); + checkKey("http://h/app/foo.js?t=123&v=9#frag", "http://h/app/foo.js?t=123&v=9"); }); - it("leaves public (non-dev, non-volatile) URLs untouched", function () { + it("unconfigured: leaves every query param in the key", function () { + checkKey("http://h/app/foo.js?t=123&v=9&import=1", "http://h/app/foo.js?t=123&v=9&import=1"); checkKey("https://cdn.example.com/lib.js?token=abc", "https://cdn.example.com/lib.js?token=abc"); }); it("treats module identity as literally the URL — no path-tag collapses", function () { - checkKey("http://h/ns/m/foo.js", "http://h/ns/m/foo.js"); - checkKey("http://h/ns/rt", "http://h/ns/rt"); - checkKey("http://h/ns/core", "http://h/ns/core"); - }); - - it("ignores URL fragments for dev endpoints", function () { - checkKey("http://h/ns/m/foo.js#frag", "http://h/ns/m/foo.js"); + checkKey("http://h/app/m/foo.js", "http://h/app/m/foo.js"); + checkKey("http://h/app/rt", "http://h/app/rt"); }); it("honors a client-supplied canonicalization vocabulary via configureLoader", function () { @@ -91,15 +91,20 @@ describe("HTTP canonical key (ns:module canonicalizeHttpUrlKey)", function () { pending("ns:module.canonicalizeHttpUrlKey not exposed (release build)"); return; } + // Neutral vocabulary: the mechanics under test are the runtime's, the + // strings are the client's to choose. require("ns:module").configureLoader({ canonicalization: { - stripParams: ["t", "v", "import"], - forPathPrefixes: ["/ns/", "/node_modules/.vite/", "/@id/", "/@fs/"], - preserveQueryFor: ["/@ng/component"], + stripParams: ["cachebust", "rev"], + forPathPrefixes: ["/dev/"], + preserveQueryFor: ["/dev/metadata"], }, }); - expect(canon("http://h/ns/core?p=x&t=123&v=9&import=1")).toBe("http://h/ns/core?p=x"); - expect(canon("http://h/ns/m/comp/@ng/component?c=a&t=42")).toBe("http://h/ns/m/comp/@ng/component?c=a&t=42"); - expect(canon("https://cdn.example.com/lib.js?token=abc")).toBe("https://cdn.example.com/lib.js?token=abc"); + // Under a configured dev prefix, the named params drop and the rest sort. + expect(canon("http://h/dev/core?p=x&cachebust=123&rev=9")).toBe("http://h/dev/core?p=x"); + // preserveQueryFor wins over the dev prefix: the query IS the identity. + expect(canon("http://h/dev/metadata?c=a&cachebust=42")).toBe("http://h/dev/metadata?c=a&cachebust=42"); + // Outside every configured prefix, the query is untouched. + expect(canon("https://cdn.example.com/lib.js?cachebust=abc")).toBe("https://cdn.example.com/lib.js?cachebust=abc"); }); }); diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp index d48cbb78a..47cacac69 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.cpp +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -200,37 +200,42 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) { std::string originAndPath = (qPos == std::string::npos) ? noHash : noHash.substr(0, qPos); std::string query = (qPos == std::string::npos) ? std::string() : noHash.substr(qPos + 1); + // This key is the module registry/cache key. For general-purpose HTTP + // module loading the query can be part of a module's identity (auth, + // content versioning, routing), so query normalization applies only to the + // endpoints the client names, through the vocabulary it supplies. + // + // `preserveQueryFor` is checked BEFORE the dev-endpoint prefix test, so it + // covers endpoints nested under a dev prefix: for some endpoints the query + // IS the identity, and stripping it would collapse every refetch onto the + // boot-time key. + // + // Until a client supplies that vocabulary, canonicalization is purely + // mechanical: the fragment is gone and the query stays. Which params are + // cache-busters and which paths are dev endpoints is knowledge only the + // client has; guessing would silently collapse two distinct modules onto + // one registry key. const CanonicalizationConfig* canon = CanonicalizationConfigForCurrentIsolate(); + if (canon == nullptr) { + return noHash; + } { std::string pathOnly = originAndPath.substr(pathStart); - if (canon) { - for (const auto& p : canon->preserveQueryPrefixes) { - if (!p.empty() && pathOnly.find(p) != std::string::npos) { - return noHash; - } - } - bool isDevEndpoint = false; - for (const auto& p : canon->devPathPrefixes) { - if (!p.empty() && StartsWith(pathOnly, p.c_str())) { - isDevEndpoint = true; - break; - } - } - if (!isDevEndpoint) { + for (const auto& p : canon->preserveQueryPrefixes) { + if (!p.empty() && pathOnly.find(p) != std::string::npos) { return noHash; } - } else { - if (pathOnly.find("/@ng/component") != std::string::npos) { - return noHash; - } - const bool isDevEndpoint = StartsWith(pathOnly, "/ns/") || - StartsWith(pathOnly, "/node_modules/.vite/") || - StartsWith(pathOnly, "/@id/") || - StartsWith(pathOnly, "/@fs/"); - if (!isDevEndpoint) { - return noHash; + } + bool isDevEndpoint = false; + for (const auto& p : canon->devPathPrefixes) { + if (!p.empty() && StartsWith(pathOnly, p.c_str())) { + isDevEndpoint = true; + break; } } + if (!isDevEndpoint) { + return noHash; + } } if (query.empty()) return originAndPath; @@ -244,13 +249,8 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) { if (!pair.empty()) { size_t eq = pair.find('='); std::string name = (eq == std::string::npos) ? pair : pair.substr(0, eq); - bool drop; - if (canon) { - drop = std::find(canon->stripParams.begin(), canon->stripParams.end(), - name) != canon->stripParams.end(); - } else { - drop = (name == "import" || name == "t" || name == "v"); - } + const bool drop = std::find(canon->stripParams.begin(), canon->stripParams.end(), + name) != canon->stripParams.end(); if (!drop) kept.push_back(pair); } if (amp == std::string::npos) break; diff --git a/test-app/runtime/src/main/cpp/MetadataNode.cpp b/test-app/runtime/src/main/cpp/MetadataNode.cpp index a6a116ffc..f2e85a70c 100644 --- a/test-app/runtime/src/main/cpp/MetadataNode.cpp +++ b/test-app/runtime/src/main/cpp/MetadataNode.cpp @@ -1866,8 +1866,8 @@ bool MetadataNode::GetExtendLocation(v8::Isolate* isolate, string& extendLocatio } else { // srcFileName is not always `file:///.js`: // HTTP ESM loading (HMR dev workflow) passes a full URL like - // `http://127.0.0.1:5173/ns/core/...` with no `.js` suffix and - // no app-root prefix, so naive scheme/app-root/`.js` stripping + // `http:///` with no `.js` suffix and no + // app-root prefix, so naive scheme/app-root/`.js` stripping // can yield an empty `fullPathToFile` and crash downstream on // an empty token list. string normalized = srcFileName; diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 9cc0795f1..38f9a168f 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -686,8 +686,7 @@ static LoaderVocabulary* VocabularyForCurrentIsolate() { static bool ShouldTraceRegistryKey(const std::string& rawKey, const std::string& registryKey) { if (rawKey != registryKey) return true; - return StartsWith(registryKey, "optional:") || - StartsWith(registryKey, "node:") || + return StartsWith(registryKey, "node:") || StartsWith(registryKey, "blob:"); } @@ -710,7 +709,7 @@ std::string CanonicalizeRegistryKey(const std::string& key) { classification = "blob"; traceEvenWithoutChange = true; } else { - // Preserve non-filesystem module namespaces such as optional: and node: + // Preserve non-filesystem module namespaces such as node: // so synthetic/in-memory modules keep their exact registry identity. size_t schemePos = key.find(':'); size_t slashPos = key.find('/'); @@ -1069,132 +1068,6 @@ static bool IsVolatileUrl(const LoaderVocabulary& vocabulary, return false; } -// Normalize a Vite-rewritten specifier into the canonical import-map key. -// Handles two common patterns: -// 1. Prebundled deps: "/node_modules/.vite/deps/solid-js.js?v=abc" → "solid-js" -// "/node_modules/.vite/deps/@tanstack_solid-router.js" → -// "@tanstack/solid-router" -// 2. Explicit node_modules paths: -// "/node_modules/@angular/core/fesm2022/core.mjs" → "@angular/core/fesm2022/core.mjs" -// "/node_modules/tslib/tslib.es6.mjs" → "tslib" -static std::string NormalizeViteSpecifier(const std::string& specifier) { - // Pattern 1: Vite prebundled deps. - { - const std::string viteDepsPrefix = "/node_modules/.vite/deps/"; - const std::string viteDepsPrefix2 = "node_modules/.vite/deps/"; - std::string prefix; - if (specifier.compare(0, viteDepsPrefix.size(), viteDepsPrefix) == 0) - prefix = viteDepsPrefix; - else if (specifier.compare(0, viteDepsPrefix2.size(), viteDepsPrefix2) == 0) - prefix = viteDepsPrefix2; - - if (!prefix.empty()) { - std::string id = specifier.substr(prefix.size()); - auto qpos = id.find('?'); - if (qpos != std::string::npos) id = id.substr(0, qpos); - auto dotpos = id.rfind('.'); - if (dotpos != std::string::npos) id = id.substr(0, dotpos); - if (!id.empty() && id[0] == '@') { - auto upos = id.find('_'); - if (upos != std::string::npos) { - id = id.substr(0, upos) + "/" + id.substr(upos + 1); - auto upos2 = id.find('_', upos + 1); - if (upos2 != std::string::npos) { - id = id.substr(0, upos2); - } - } - } - TNS_DEBUG(Esm, "[import-map][normalize] vite-deps: %s -> %s", - specifier.c_str(), id.c_str()); - return id; - } - } - - // Pattern 2: Resolved node_modules path — /node_modules//... - { - const std::string nmPrefix = "/node_modules/"; - const std::string nmPrefix2 = "node_modules/"; - std::string sub; - if (specifier.compare(0, nmPrefix.size(), nmPrefix) == 0) - sub = specifier.substr(nmPrefix.size()); - else if (specifier.compare(0, nmPrefix2.size(), nmPrefix2) == 0) - sub = specifier.substr(nmPrefix2.size()); - - if (!sub.empty() && sub[0] != '.') { - if (sub.compare(0, 6, ".vite/") == 0) return ""; - - std::string subNoQuery = sub; - std::string querySuffix; - auto subQueryPos = sub.find('?'); - if (subQueryPos != std::string::npos) { - subNoQuery = sub.substr(0, subQueryPos); - querySuffix = sub.substr(subQueryPos); - } - - std::string pkgName; - if (subNoQuery[0] == '@') { - auto slash1 = subNoQuery.find('/'); - if (slash1 != std::string::npos) { - auto slash2 = subNoQuery.find('/', slash1 + 1); - pkgName = (slash2 != std::string::npos) ? subNoQuery.substr(0, slash2) - : subNoQuery; - } - } else { - auto slash = subNoQuery.find('/'); - pkgName = (slash != std::string::npos) ? subNoQuery.substr(0, slash) - : subNoQuery; - } - if (!pkgName.empty()) { - std::string normalized = pkgName; - std::string remainder; - if (subNoQuery.size() > pkgName.size()) { - remainder = subNoQuery.substr(pkgName.size()); - if (!remainder.empty() && remainder[0] == '/') { - remainder.erase(0, 1); - } - } - - if (!remainder.empty()) { - bool preserveSubpath = remainder.find('/') != std::string::npos; - - if (!preserveSubpath) { - const std::string pkgBaseName = - pkgName.substr(pkgName.find_last_of('/') + 1); - std::string withoutExt = remainder; - auto dot = withoutExt.rfind('.'); - if (dot != std::string::npos) { - withoutExt = withoutExt.substr(0, dot); - } - std::string withoutPlatform = withoutExt; - for (const auto& suffix : {std::string(".ios"), std::string(".android"), - std::string(".visionos")}) { - if (EndsWith(withoutPlatform, suffix)) { - withoutPlatform = - withoutPlatform.substr(0, withoutPlatform.size() - suffix.size()); - break; - } - } - const bool isRootLevelMainEntry = - withoutPlatform == "index" || - withoutPlatform == pkgBaseName || - withoutPlatform.rfind(pkgBaseName + ".", 0) == 0; - preserveSubpath = !isRootLevelMainEntry; - } - - if (preserveSubpath) { - normalized = pkgName + "/" + remainder + querySuffix; - } - } - - TNS_DEBUG(Esm, "[import-map][normalize] node_modules: %s -> %s", - specifier.c_str(), normalized.c_str()); - return normalized; - } - } - } - return ""; -} - // Look up a specifier in ONE import-map section: exact match first, then the // longest trailing-slash prefix entry, whose remainder is appended to the // target. Returns empty when the section has no answer. @@ -1358,9 +1231,6 @@ static ModuleResolution ResolveSpecifierToPath(v8::Isolate* isolate, TNS_DEBUG(Esm, "[resolver][spec] %s", spec.c_str()); - // A bare '@' is never a module; some dev toolchains emit it during bootstrap. - if (spec == "@") return result; - // The import map is consulted before any other resolution: bare specifiers // resolve through it to vendor or HTTP URLs. A client that rewrites // specifiers must map every form it emits — keys are matched literally. @@ -1368,16 +1238,6 @@ static ModuleResolution ResolveSpecifierToPath(v8::Isolate* isolate, if (moduleState != nullptr && !moduleState->vocabulary.importMap.empty()) { const LoaderVocabulary& vocabulary = moduleState->vocabulary; std::string mapped = LookupImportMap(vocabulary, spec, referrerKey); - if (mapped.empty()) { - std::string normalized = NormalizeViteSpecifier(spec); - if (!normalized.empty()) { - mapped = LookupImportMap(vocabulary, normalized, referrerKey); - if (!mapped.empty()) { - TNS_DEBUG(Esm, "[resolver][import-map] normalized: %s -> %s -> %s", - spec.c_str(), normalized.c_str(), mapped.c_str()); - } - } - } if (!mapped.empty()) { TNS_DEBUG(Esm, "[resolver][import-map] rewrite: %s -> %s", spec.c_str(), mapped.c_str()); @@ -1511,11 +1371,6 @@ static ModuleResolution ResolveSpecifierToPath(v8::Isolate* isolate, // Bare specifier — resolve relative to the application root. std::string base = NormalizePath(appPath + "/" + spec); candidateBases.push_back(base); - // Underscore-separated bundler chunk heuristic. - std::string withSlashes = spec; - std::replace(withSlashes.begin(), withSlashes.end(), '_', '/'); - std::string baseSlashes = NormalizePath(appPath + "/" + withSlashes); - if (baseSlashes != base) candidateBases.push_back(baseSlashes); } std::string absPath; @@ -2052,27 +1907,14 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { auto& g_moduleRegistry = moduleState->registry; const std::string registryKey = CanonicalizeRegistryKey(canonicalPath); - // Defensive: never operate on an anomalous/sentinel key. - auto isSentinel = [](const std::string& s) -> bool { - if (s == "@") return true; - return s.find("__invalid_at__.mjs") != std::string::npos; - }; - if (isSentinel(registryKey)) { - TNS_DEBUG(Esm, "[resolver][guard-v3] ignore remove for sentinel %s", - registryKey.c_str()); - return; - } - const LoaderVocabulary& vocabulary = moduleState->vocabulary; auto classify = [&vocabulary](const std::string& s) -> const char* { - if (s == "@") return "sentinel:@"; - if (s.find("__invalid_at__.mjs") != std::string::npos) - return "sentinel:invalid_at"; bool http = StartsWith(s, "http://") || StartsWith(s, "https://"); if (http) { + // `http:volatile` is client-configured, via volatilePatterns; every other + // arm is derived from the URL's own shape, never from a client's + // conventions. if (IsVolatileUrl(vocabulary, s)) return "http:volatile"; - if (s.find("/@ns/sfc/") != std::string::npos) return "http:sfc"; - if (s.find("/@ns/m/") != std::string::npos) return "http:m"; return "http:other"; } if (StartsWith(s, "file://")) return "file-url"; @@ -2459,13 +2301,6 @@ v8::MaybeLocal ResolveModuleCallback( const std::string rawSpec = *specUtf8 ? *specUtf8 : ""; if (rawSpec.empty()) return v8::MaybeLocal(); - // A bare '@' is invalid; refuse to poison the registry, and stay silent - // rather than throwing — some dev toolchains emit one during bootstrap. - if (rawSpec == "@") { - TNS_DEBUG(Esm, "[resolver][normalize] ignoring invalid '@' static spec"); - return v8::MaybeLocal(); - } - const bool isWorker = IsCurrentIsolateWorker(isolate); const std::string referrerPath = FindKeyForModule(*moduleState, isolate, referrer); @@ -2784,8 +2619,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( // registry keys it, so a scope matches an import() exactly as it matches a // static import from the same module. const LoaderVocabulary& vocabulary = moduleState->vocabulary; - if (!vocabulary.importMap.empty() && !normalizedSpec.empty() && - normalizedSpec != "@") { + if (!vocabulary.importMap.empty() && !normalizedSpec.empty()) { std::string dynamicReferrerKey; if (!resource_name.IsEmpty() && resource_name->IsString()) { v8::String::Utf8Value resourceUtf8(isolate, resource_name); @@ -2794,16 +2628,6 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } } std::string mapped = LookupImportMap(vocabulary, normalizedSpec, dynamicReferrerKey); - if (mapped.empty()) { - std::string normalized = NormalizeViteSpecifier(normalizedSpec); - if (!normalized.empty()) { - mapped = LookupImportMap(vocabulary, normalized, dynamicReferrerKey); - if (!mapped.empty()) { - TNS_DEBUG(Esm, "[dyn-import][import-map] normalized: %s -> %s -> %s", - normalizedSpec.c_str(), normalized.c_str(), mapped.c_str()); - } - } - } if (!mapped.empty()) { normalizedSpec = mapped; specifier = ArgConverter::ConvertToV8String(isolate, normalizedSpec); @@ -2813,36 +2637,6 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } try { - // Defensive guard: some dev-time toolchains emit a stray import('@') during - // bootstrap. Treat it as a no-op module to avoid a hard failure. - if (!normalizedSpec.empty() && normalizedSpec == "@") { - TNS_DEBUG(Esm, - "[dyn-import] ignoring invalid '@' spec (returning empty module)"); - const char* kEmptySrc = "export {}\n"; - std::string url = "file:///app/__invalid_at__.mjs"; - v8::MaybeLocal modMaybe = - CompileModuleFromSource(isolate, context, kEmptySrc, url); - v8::Local mod; - if (modMaybe.ToLocal(&mod)) { - const std::string atStubKey = CanonicalizeRegistryKey(url); - UnindexRegistryKey(*moduleState, isolate, atStubKey); - g_moduleRegistry[atStubKey].Reset(isolate, mod); - IndexRegisteredModule(*moduleState, atStubKey, mod); - if (mod->GetStatus() != v8::Module::kEvaluated) { - if (mod->Evaluate(context).IsEmpty()) { - resolver - ->Reject(context, - v8::Exception::Error(ArgConverter::ConvertToV8String( - isolate, "Evaluation failed for empty module"))) - .FromMaybe(false); - return scope.Escape(resolver->GetPromise()); - } - } - resolver->Resolve(context, mod->GetModuleNamespace()).FromMaybe(false); - return scope.Escape(resolver->GetPromise()); - } - } - // ── Blob URL support (e.g. blob:nativescript/) ── // Retrieve the blob content from the global BLOB_STORE via // URL.InternalAccessor.getData() (installed by Android's blob-url.js) and diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index e1ff02a8f..bffcfc7d5 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -230,7 +230,7 @@ void InitializeImportMetaObject(v8::Local context, // through the copy taken at spawn. bool SetImportMap(const std::string& json, std::string* error); -// Set URL patterns that should bypass module cache (e.g. "/@ns/sfc/", "?v=") +// Set URL patterns that should bypass module cache (e.g. "?v=", "/hot/") // on the calling isolate. void SetVolatilePatterns(const std::vector& patterns); From 332c28c2bdfe0489cb5b01d35bdbd633908d396c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 14:31:22 -0300 Subject: [PATCH 24/36] refactor(runtime): derive boot state natively; setDevBootComplete removed The cold-boot fetch-yield pump is now armed by the runtime itself - a thread-local RAII depth counter around entry evaluation (SetBootEvaluationActive) - instead of staying armed from process start until the dev client called ns:module.setDevBootComplete. The pump runs exactly while an entry evaluates on the calling thread, a booting worker can no longer arm the main thread's pump, and the client-visible switch is gone (clients feature-detect, so absence is a no-op). g_devSessionBootComplete and the __NS_HMR_BOOT_COMPLETE__ global go with it; CleanupHttpLoaderGlobals keeps only the process-wide cache-bust reset. --- .../src/main/assets/app/tests/testNsModule.js | 19 ++++---- test-app/runtime/src/main/cpp/HttpLoader.cpp | 47 ++++++------------- test-app/runtime/src/main/cpp/HttpLoader.h | 21 ++++----- .../runtime/src/main/cpp/ModuleInternal.cpp | 7 +++ test-app/runtime/src/main/cpp/js/ns-module.js | 1 - 5 files changed, 39 insertions(+), 56 deletions(-) diff --git a/test-app/app/src/main/assets/app/tests/testNsModule.js b/test-app/app/src/main/assets/app/tests/testNsModule.js index a68361f13..c5a5d70e8 100644 --- a/test-app/app/src/main/assets/app/tests/testNsModule.js +++ b/test-app/app/src/main/assets/app/tests/testNsModule.js @@ -5,14 +5,16 @@ describe("ns:module", function () { expect(typeof nsModule.configureLoader).toBe("function"); expect(typeof nsModule.invalidateModules).toBe("function"); expect(typeof nsModule.getLoadedModuleUrls).toBe("function"); - expect(typeof nsModule.setDevBootComplete).toBe("function"); + expect(typeof nsModule.createRequire).toBe("function"); + expect(typeof nsModule.createPumpingRequire).toBe("function"); expect(nsModule.terminateAllWorkers).toBeUndefined(); expect(global.__NS_DEV__).toBeUndefined(); }); it("exposes exactly the declared surface", function () { var nsModule = require("ns:module"); - var expected = ["configureLoader", "getLoadedModuleUrls", "invalidateModules", "setDevBootComplete"]; + var expected = ["configureLoader", "createPumpingRequire", "createRequire", + "getLoadedModuleUrls", "invalidateModules"]; if (typeof nsModule.canonicalizeHttpUrlKey === "function") { expected.push("canonicalizeHttpUrlKey"); } @@ -32,15 +34,12 @@ describe("ns:module", function () { }); }); - it("setDevBootComplete flips the JS-visible boot-complete global", function () { + // Boot state is derived natively from the entry-evaluation window; there is + // no client signal and no JS-visible mirror. + it("exposes no boot-complete signal", function () { var nsModule = require("ns:module"); - nsModule.setDevBootComplete(true); - expect(global.__NS_HMR_BOOT_COMPLETE__).toBe(true); - nsModule.setDevBootComplete(false); - expect(global.__NS_HMR_BOOT_COMPLETE__).toBe(false); - nsModule.setDevBootComplete(); - expect(global.__NS_HMR_BOOT_COMPLETE__).toBe(true); - nsModule.setDevBootComplete(false); + expect(nsModule.setDevBootComplete).toBeUndefined(); + expect(global.__NS_HMR_BOOT_COMPLETE__).toBeUndefined(); }); }); diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp index 47cacac69..ff72d0a57 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.cpp +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -150,27 +150,21 @@ bool IsRemoteUrlAllowed(const std::string& url) { return false; } -static void SetBooleanGlobal(v8::Isolate* isolate, v8::Local context, const char* key, - bool value) { - context->Global() - ->Set(context, ToV8String(isolate, key), v8::Boolean::New(isolate, value)) - .FromMaybe(false); -} - // ───────────────────────────────────────────────────────────── -// Dev-boot completion flag +// Boot-evaluation flag -static std::atomic g_devSessionBootComplete{false}; +// Nonzero while this thread is evaluating an entry module (main or worker) — +// the only window in which the fetch yield may pump the looper: during entry +// evaluation nothing else owns it, while pumping mid-app would re-enter +// arbitrary user code under a synchronous fetch. Thread-local because a fetch +// and the entry evaluation that triggered it always share a thread, so a +// worker booting never arms the main thread's pump. The runtime derives this +// itself — there is no client signal to forget. +static thread_local int t_bootEvaluationDepth = 0; -static inline bool IsDevSessionBootComplete() { - return g_devSessionBootComplete.load(std::memory_order_relaxed); -} +void SetBootEvaluationActive(bool active) { t_bootEvaluationDepth += active ? 1 : -1; } -void SetDevBootComplete(v8::Isolate* isolate, v8::Local context, bool value) { - SetBooleanGlobal(isolate, context, "__NS_HMR_BOOT_COMPLETE__", value); - g_devSessionBootComplete.store(value, std::memory_order_relaxed); - TNS_DEBUG(Esm, "[dev-boot] __NS_HMR_BOOT_COMPLETE__=%s", value ? "true" : "false"); -} +static inline bool IsBootEvaluationActive() { return t_bootEvaluationDepth > 0; } // ───────────────────────────────────────────────────────────── // Canonical module keys @@ -913,7 +907,7 @@ void FetchModuleBodyAsync(const std::string& url, static void MaybePumpJSThreadDuringBoot() { v8::Isolate* isolate = v8::Isolate::TryGetCurrent(); if (isolate == nullptr) return; - if (IsDevSessionBootComplete()) return; + if (!IsBootEvaluationActive()) return; if (isolate->GetData((uint32_t)Runtime::IsolateData::RUNTIME) == nullptr) return; isolate->PerformMicrotaskCheckpoint(); @@ -933,8 +927,9 @@ static inline void InvokeHttpFetchYield() { } void CleanupHttpLoaderGlobals() { + // The boot-evaluation flag is thread-local and RAII-balanced by + // ModuleInternal::Load, so it needs no reset here. ClearAllCacheBustMarks(); - g_devSessionBootComplete.store(false, std::memory_order_relaxed); } // ───────────────────────────────────────────────────────────── @@ -1089,19 +1084,6 @@ void GetLoadedModuleUrlsCallback(const v8::FunctionCallbackInfo& info info.GetReturnValue().Set(result); } -void SetDevBootCompleteCallback(const v8::FunctionCallbackInfo& info) { - v8::Isolate* isolate = info.GetIsolate(); - v8::HandleScope scope(isolate); - v8::Local ctx = isolate->GetCurrentContext(); - - bool value = true; - if (info.Length() >= 1 && !info[0]->IsUndefined() && !info[0]->IsNull()) { - value = info[0]->BooleanValue(isolate); - } - - tns::SetDevBootComplete(isolate, ctx, value); -} - } // namespace bool BuildNsModuleBinding(v8::Local context, v8::Local binding) { @@ -1111,7 +1093,6 @@ bool BuildNsModuleBinding(v8::Local context, v8::Local InstallDevFunction(isolate, context, binding, "invalidateModules", InvalidateModulesCallback); InstallDevFunction(isolate, context, binding, "getLoadedModuleUrls", GetLoadedModuleUrlsCallback); - InstallDevFunction(isolate, context, binding, "setDevBootComplete", SetDevBootCompleteCallback); if (!ModuleInternal::InstallCreateRequireBinding(context, binding)) { return false; diff --git a/test-app/runtime/src/main/cpp/HttpLoader.h b/test-app/runtime/src/main/cpp/HttpLoader.h index fd2909d7b..eca16fbf5 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.h +++ b/test-app/runtime/src/main/cpp/HttpLoader.h @@ -30,8 +30,8 @@ namespace tns { // normally arrive, // - eviction plumbing (an eviction-driven fetch nonce that defeats // any HTTP cache layer between the runtime and the origin), -// - the dev-boot-complete signal that disarms cold-boot-only -// behaviors (host yield pump), +// - the boot-evaluation flag that arms the cold-boot looper pump only +// while an entry module is evaluating (derived by the runtime itself), // - the remote-module security gate, seeded once from nativescript.config // at boot and never exposed on ns:runtime getConfig/setConfig. @@ -135,16 +135,14 @@ void RegisterHttpFetchYield(void (*callback)()); // The nonce is transport-only and never affects module identity. void MarkUrlsForCacheBust(const std::vector& urls); -// Flip the dev-boot-complete signal: sets the JS-visible -// `__NS_HMR_BOOT_COMPLETE__` global and the native atomic that gates the -// cold-boot-only behaviors (JS-thread looper pump between synchronous -// fetches). Exposed to JS as ns:module -// `setDevBootComplete(value?: boolean)`. -void SetDevBootComplete(v8::Isolate* isolate, v8::Local context, - bool value); +// Arm/disarm this thread's boot-evaluation window: while nonzero, the yield +// inside synchronous HTTP fetches may pump the JS thread's looper (safe only +// while the entry module is evaluating — nothing else owns the looper yet). +// Balanced RAII-style by ModuleInternal::Load. +void SetBootEvaluationActive(bool active); -// Clear the transport's process-wide state (cache-bust marks, boot-complete -// flag). MUST be called inside Runtime::DestroyRuntime() before isolate +// Clear the transport's process-wide state (cache-bust marks). MUST be +// called inside Runtime::DestroyRuntime() before isolate // disposal — and only for the MAIN isolate (worker teardown must not wipe // shared state the main isolate still uses). void CleanupHttpLoaderGlobals(); @@ -184,7 +182,6 @@ bool IsDebuggable(); // canonicalization vocabulary) // - invalidateModules(urls) (registry + cache eviction) // - getLoadedModuleUrls() (registry introspection) -// - setDevBootComplete(value?) (boot-complete signal) // - canonicalizeHttpUrlKey(url) (debug builds only; test diagnostic) // // Worker teardown across HMR cycles is userland: the dev client intercepts diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index ab2452d77..755396246 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -446,6 +446,13 @@ void ModuleInternal::RequireNativeCallback(const v8::FunctionCallbackInfo context, const string& path) { TNSPERF(); auto isolate = m_isolate; + // Entry evaluation is this thread's boot window: while it is active, the + // yield inside synchronous HTTP fetches may pump the looper (nothing else + // owns it yet). Balanced on every exit path, throws included. + struct BootEvalScope { + BootEvalScope() { SetBootEvaluationActive(true); } + ~BootEvalScope() { SetBootEvaluationActive(false); } + } bootEvalScope; if (IsHttpModulePath(path) || IsESModule(path)) { // The entry runs before this thread's event loop does, so its graph can // only make progress from the pump inside LoadESModule. diff --git a/test-app/runtime/src/main/cpp/js/ns-module.js b/test-app/runtime/src/main/cpp/js/ns-module.js index b409b9981..4059a7c10 100644 --- a/test-app/runtime/src/main/cpp/js/ns-module.js +++ b/test-app/runtime/src/main/cpp/js/ns-module.js @@ -173,7 +173,6 @@ const surface = { configureLoader: binding.configureLoader, invalidateModules: binding.invalidateModules, getLoadedModuleUrls: binding.getLoadedModuleUrls, - setDevBootComplete: binding.setDevBootComplete, createRequire, createPumpingRequire, }; From fd8ceec519f1fa628ebc5f38ed488319ed9c5eb6 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 14:38:53 -0300 Subject: [PATCH 25/36] fix(worker): enable the message queue on entry settle, like the web A worker's message queue now opens exactly when its entry evaluation settles: immediately for classic and synchronous module entries, and via a settle continuation on the entry's capability promise for a top-level await entry - messages posted while the entry is parked buffer and deliver after settle. A failed entry still routes through onerror first, then dispatches into a possibly-listenerless global, as on the web. This replaces the handler-presence probe with its detached 50ms retry thread, which only recognized the onmessage property (addEventListener users buffered until the ~2s budget expired and then lost messages) and gave asynchronously-installed handlers a grace window the web does not have. PendingEntryEvaluation performs the probe: module status cannot answer "is the entry still pending" - a TLA-parked module reports evaluated - so the capability promise is re-obtained and its state read directly. Workers get no post-load graph pump on purpose: the entry's transitive HTTP closure is fetched before evaluation, and anything still in flight lands on the worker's own event loop, which runWorkerLoop drives three statements later. --- .../runtime/src/main/cpp/ModuleInternal.cpp | 32 ++++++++ .../runtime/src/main/cpp/ModuleInternal.h | 12 +++ .../runtime/src/main/cpp/WorkerWrapper.cpp | 78 ++++++++++--------- test-app/runtime/src/main/cpp/WorkerWrapper.h | 18 ++++- 4 files changed, 100 insertions(+), 40 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 755396246..3392f3753 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -1052,6 +1052,38 @@ MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local co return MaybeLocal(); } +MaybeLocal ModuleInternal::PendingEntryEvaluation(Isolate* isolate, + const std::string& path) { + if (!IsESModule(path) && !IsHttpModulePath(path)) { + return MaybeLocal(); + } + auto* registryPtr = ModuleRegistryFor(isolate); + if (registryPtr == nullptr) { + return MaybeLocal(); + } + auto it = registryPtr->find(CanonicalizeRegistryKey(path)); + if (it == registryPtr->end()) { + return MaybeLocal(); + } + Local mod = it->second.Get(isolate); + if (mod.IsEmpty() || mod->GetStatus() != Module::kEvaluated) { + return MaybeLocal(); + } + // Evaluate() on an already-evaluated module hands back the same capability + // promise without re-running anything. + TryCatch tc(isolate); + Local context = isolate->GetCurrentContext(); + Local result; + if (!mod->Evaluate(context).ToLocal(&result) || !result->IsPromise()) { + return MaybeLocal(); + } + Local promise = result.As(); + if (promise->State() != Promise::kPending) { + return MaybeLocal(); + } + return MaybeLocal(promise); +} + // The root entry point for an ES module graph: compile + register the root, // then instantiate and evaluate it once. Dependencies are compiled and // registered by ResolveModuleCallback while V8 walks the graph from here; diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.h b/test-app/runtime/src/main/cpp/ModuleInternal.h index b23842c9f..218ad05de 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.h +++ b/test-app/runtime/src/main/cpp/ModuleInternal.h @@ -104,6 +104,18 @@ class ModuleInternal { */ static v8::MaybeLocal CompileFileEsModule(v8::Isolate* isolate, const std::string& path); + /* + * The entry module's still-pending evaluation promise, or empty when + * evaluation has settled (classic scripts settle synchronously and always + * return empty). Callers use this after the entry load to observe a + * top-level await that outlived the settle window. Note a TLA-parked module + * reports kEvaluated while its capability promise is still pending, so this + * probes the promise (Evaluate() hands back the same capability), not the + * status enum. + */ + static v8::MaybeLocal PendingEntryEvaluation(v8::Isolate* isolate, + const std::string& path); + static int MODULE_PROLOGUE_LENGTH; private: enum class ModulePathKind { diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index 5825e4a9f..850aa5a68 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -46,8 +46,7 @@ WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string w isClosing_(false), isTerminating_(false), isDisposed_(false), - drainRetryPending_(false), - drainRetryAttempts_(0), + messagesEnabled_(false), javaLooperRef_(nullptr) {} void WorkerWrapper::Start() { @@ -156,39 +155,13 @@ void WorkerWrapper::DrainPendingTasks() { Context::Scope context_scope(context); auto globalObject = context->Global(); - // WHATWG parity: buffer inbound messages until the entry script has - // installed `onmessage`. Async ESM entries (HTTP dev sessions, TLA) - // finish evaluating after the wrapper starts draining; silently dropping - // messages with no handler would leave the sender waiting forever. - if (!isTerminating_ && !isClosing_ && !queue_.IsEmpty()) { - Local onMessageValue; - bool gotHandler = - globalObject->Get(context, ArgConverter::ConvertToV8String(isolate, "onmessage")) - .ToLocal(&onMessageValue); - if (!gotHandler || !onMessageValue->IsFunction()) { - bool expected = false; - if (drainRetryAttempts_ < kMaxDrainRetryAttempts && - drainRetryPending_.compare_exchange_strong(expected, true)) { - ++drainRetryAttempts_; - const int workerId = workerId_; - std::thread([workerId]() { - usleep(50 * 1000); - auto wrapper = WorkerWrapper::GetById(workerId); - if (wrapper != nullptr) { - wrapper->drainRetryPending_ = false; - wrapper->SignalMessageDrain(); - } - }).detach(); - return; - } - if (drainRetryAttempts_ < kMaxDrainRetryAttempts) { - return; - } - // Retry budget exhausted: fall through so the per-message loop - // logs the missing handler and drops the messages. - } else { - drainRetryAttempts_ = 0; - } + // WHATWG parity: the implicit port's message queue starts disabled and is + // enabled once the entry script has finished evaluating (including after a + // pending top-level await settles). Until then messages stay buffered here; + // afterwards every message dispatches whether or not a handler exists — a + // handler installed later misses earlier messages, exactly as on the web. + if (!messagesEnabled_.load(std::memory_order_acquire)) { + return; } auto messages = queue_.PopAll(); @@ -227,7 +200,8 @@ void WorkerWrapper::DrainPendingTasks() { } } -void WorkerWrapper::SignalMessageDrain() { +void WorkerWrapper::EnableMessageQueue() { + messagesEnabled_.store(true, std::memory_order_release); queue_.Signal(); } @@ -435,6 +409,38 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { if (!isTerminating_) { runtime_->RunWorker(workerPath_); + + // WHATWG parity: enable the implicit port's message queue + // once the entry has finished evaluating. RunWorker returns + // settled for classic scripts and pumped HTTP entries; a + // local top-level-await entry that outlived its settle + // window enables when its evaluation promise settles + // (fulfilled or rejected — a broken worker just dispatches + // into a listenerless global, as on the web). + Local pendingEntry; + if (!ModuleInternal::PendingEntryEvaluation(isolate, workerPath_) + .ToLocal(&pendingEntry)) { + EnableMessageQueue(); + } else { + auto onSettled = [](const v8::FunctionCallbackInfo& info) { + // Resolve the wrapper by id — never capture it across + // the settle; the worker may be gone by then. + auto wrapper = WorkerWrapper::GetById( + info.Data().As()->Value()); + if (wrapper != nullptr) { + wrapper->EnableMessageQueue(); + } + }; + Local enableFn; + if (Function::New(context, onSettled, + v8::Integer::New(isolate, workerId_)) + .ToLocal(&enableFn)) { + pendingEntry->Then(context, enableFn, enableFn) + .FromMaybe(Local()); + } else { + EnableMessageQueue(); + } + } } } diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.h b/test-app/runtime/src/main/cpp/WorkerWrapper.h index 4e8ad1a8f..11f2ab47b 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.h +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.h @@ -101,6 +101,16 @@ class WorkerWrapper : public std::enable_shared_from_this { */ static int NextWorkerId(); static std::shared_ptr GetById(int workerId); + + /* + * WHATWG parity: the worker's implicit port message queue starts disabled; + * the worker thread calls this once the entry script has finished + * evaluating (including after a pending top-level await settles). From then + * on every buffered and future message dispatches whether or not a handler + * exists — a handler installed later (e.g. from a timer) misses earlier + * messages, exactly as on the web. + */ + void EnableMessageQueue(); static void Insert(int workerId, std::shared_ptr wrapper); /* @@ -143,7 +153,6 @@ class WorkerWrapper : public std::enable_shared_from_this { private: void BackgroundLooper(std::shared_ptr self); void DrainPendingTasks(); - void SignalMessageDrain(); void QuitLooper(); static int DrainCallback(int fd, int events, void* data); static void FireMessageOnParentWorkerObject(int workerId, @@ -178,9 +187,10 @@ class WorkerWrapper : public std::enable_shared_from_this { std::atomic_bool isClosing_; std::atomic_bool isTerminating_; std::atomic_bool isDisposed_; - std::atomic_bool drainRetryPending_; - int drainRetryAttempts_ = 0; - static constexpr int kMaxDrainRetryAttempts = 40; + // False until the entry script has finished evaluating + // (EnableMessageQueue); DrainPendingTasks leaves the queue untouched while + // disabled. + std::atomic_bool messagesEnabled_; ConcurrentQueue queue_; From bfd1682723e0f87f7ff87581f20fd8d5ae2005b3 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 14:45:46 -0300 Subject: [PATCH 26/36] feat(runtime): ES module app entries and a boot backstop that holds for them An app's main entry may be an ES module, top-level await included: the Java side hands over the resolved main path, routing dispatches on it, and an .mjs or HTTP main takes the module route under boot evaluation options (a 1s in-place yield locally that never throws; 60s with a throw for HTTP entries). A CJS main is byte-identical to before. Load now enters the context itself so both branches run with a current context regardless of the caller. After the entry returns, RunModule holds the process while the entry's evaluation promise is pending or graph work is in flight, pumping nestable tasks and microtask checkpoints, bounded at twice the module deadline (120s). The pending-entry probe reads the capability promise - module status cannot answer, since a TLA-parked module reports evaluated. A rejected entry and a 2x-deadline expiry with the entry still pending are named fatals in every build: Fatal: the main entry module's evaluation rejected during boot: Fatal: the main entry module '' never settled within 120s Graph-only stragglers at the deadline keep the previous log-and-continue behavior. Workers get no backstop - their settle-gated message queue covers them. --- .../runtime/src/main/cpp/ModuleInternal.cpp | 52 ++++++++++++-- .../runtime/src/main/cpp/ModuleInternal.h | 16 +++++ test-app/runtime/src/main/cpp/Runtime.cpp | 67 ++++++++++++++++--- 3 files changed, 120 insertions(+), 15 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 3392f3753..aee7e3b77 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -453,6 +453,13 @@ void ModuleInternal::Load(Local context, const string& path) { BootEvalScope() { SetBootEvaluationActive(true); } ~BootEvalScope() { SetBootEvaluationActive(false); } } bootEvalScope; + + // The ES module branch compiles and links against + // isolate->GetCurrentContext(); a caller that enters the isolate through a + // fresh Isolate::Scope has no current context, and CompileModule would + // dereference a null native context. The require branch never needed this + // because Function::Call enters the context it is handed. + Context::Scope context_scope(context); if (IsHttpModulePath(path) || IsESModule(path)) { // The entry runs before this thread's event loop does, so its graph can // only make progress from the pump inside LoadESModule. @@ -1052,9 +1059,11 @@ MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local co return MaybeLocal(); } -MaybeLocal ModuleInternal::PendingEntryEvaluation(Isolate* isolate, - const std::string& path) { - if (!IsESModule(path) && !IsHttpModulePath(path)) { +// The shared probe behind both entry-evaluation queries: a registry hit plus +// Evaluate(), which hands back the SAME capability promise rather than +// re-running anything, so it is cheap enough to call from a pump loop. +static MaybeLocal EntryEvaluationPromise(Isolate* isolate, const std::string& path) { + if (!ModuleInternal::IsESModule(path) && !IsHttpModulePath(path)) { return MaybeLocal(); } auto* registryPtr = ModuleRegistryFor(isolate); @@ -1066,24 +1075,55 @@ MaybeLocal ModuleInternal::PendingEntryEvaluation(Isolate* isolate, return MaybeLocal(); } Local mod = it->second.Get(isolate); + // A TLA-parked module reports kEvaluated while its promise is still + // pending, so the status is the gate to *having* a promise, never to its + // state. if (mod.IsEmpty() || mod->GetStatus() != Module::kEvaluated) { return MaybeLocal(); } - // Evaluate() on an already-evaluated module hands back the same capability - // promise without re-running anything. TryCatch tc(isolate); Local context = isolate->GetCurrentContext(); Local result; if (!mod->Evaluate(context).ToLocal(&result) || !result->IsPromise()) { return MaybeLocal(); } - Local promise = result.As(); + return MaybeLocal(result.As()); +} + +MaybeLocal ModuleInternal::PendingEntryEvaluation(Isolate* isolate, + const std::string& path) { + Local promise; + if (!EntryEvaluationPromise(isolate, path).ToLocal(&promise)) { + return MaybeLocal(); + } if (promise->State() != Promise::kPending) { return MaybeLocal(); } return MaybeLocal(promise); } +EntryEvaluationState ModuleInternal::PollEntryEvaluation(Isolate* isolate, const std::string& path, + std::string* rejectionReason) { + Local promise; + if (!EntryEvaluationPromise(isolate, path).ToLocal(&promise)) { + return EntryEvaluationState::kNone; + } + switch (promise->State()) { + case Promise::kPending: + return EntryEvaluationState::kPending; + case Promise::kFulfilled: + return EntryEvaluationState::kFulfilled; + case Promise::kRejected: + break; + } + if (rejectionReason != nullptr) { + Local reason = promise->Result(); + *rejectionReason = + reason.IsEmpty() ? "" : ArgConverter::ToString(isolate, reason); + } + return EntryEvaluationState::kRejected; +} + // The root entry point for an ES module graph: compile + register the root, // then instantiate and evaluate it once. Dependencies are compiled and // registered by ResolveModuleCallback while V8 walks the graph from here; diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.h b/test-app/runtime/src/main/cpp/ModuleInternal.h index 218ad05de..7ca4230a1 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.h +++ b/test-app/runtime/src/main/cpp/ModuleInternal.h @@ -31,6 +31,11 @@ inline constexpr double kModuleEvaluateDeadlineSeconds = 60.0; // kAsync - evaluate and hand the caller the capability promise. enum class ModuleEvaluationPolicy { kSyncStrict, kSyncPumping, kAsync }; +// The state of an entry module's evaluation promise. kNone means the path +// names no registered ES module — a classic script settles synchronously and +// never has one, so it needs no boot backstop. +enum class EntryEvaluationState { kNone, kPending, kFulfilled, kRejected }; + struct ModuleEvaluationOptions { enum class TimeoutBehavior { kReturnPending, kThrow }; @@ -116,6 +121,17 @@ class ModuleInternal { static v8::MaybeLocal PendingEntryEvaluation(v8::Isolate* isolate, const std::string& path); + /* + * The same probe, but reporting the promise's state rather than only + * "pending or not" — the boot backstop must tell a rejection from a + * successful settle. Cheap enough to call once per pump slice: a registry + * hit plus Evaluate(), which returns the existing capability promise. + * `rejectionReason` (when non-null) receives the reason's text on kRejected. + */ + static EntryEvaluationState PollEntryEvaluation(v8::Isolate* isolate, + const std::string& path, + std::string* rejectionReason); + static int MODULE_PROLOGUE_LENGTH; private: enum class ModulePathKind { diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index d21ae834e..4d5a0b6bf 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -355,21 +355,68 @@ void Runtime::Unlock() { #endif } -static void PumpPendingHttpModuleGraph(v8::Isolate* isolate) { - if (!tns::HasPendingAsyncModuleGraphWork()) { +// The boot backstop: hold the launching thread until boot has actually +// finished. Two independent things can leave it unfinished, and BOTH must hold +// the pump — an in-flight module-graph load, and an entry whose own evaluation +// promise is still pending (a top-level await parked on anything at all: a +// nested import() doing its own async work, a native init that completes +// later). Gating on graph work alone let the second case return to Java with +// the entry half-evaluated. +// +// A settled entry simply exits the loop — a script-style app finishing +// normally, Node-like. Only the two failures below are fatal, and both are +// reported in every build. +static void HoldBootBackstop(v8::Isolate* isolate, const std::string& entryPath) { + std::string entryRejectionReason; + bool entryPending = + ModuleInternal::PollEntryEvaluation(isolate, entryPath, &entryRejectionReason) == + EntryEvaluationState::kPending; + bool entryRejected = false; + + if (!entryPending && !tns::HasPendingAsyncModuleGraphWork()) { return; } + + const double deadlineSeconds = 2 * kModuleEvaluateDeadlineSeconds; const auto start = std::chrono::steady_clock::now(); - while (tns::HasPendingAsyncModuleGraphWork()) { + std::shared_ptr eventLoop = Runtime::GetRuntime(isolate) != nullptr + ? Runtime::GetRuntime(isolate)->GetEventLoop() + : nullptr; + + while (entryPending || tns::HasPendingAsyncModuleGraphWork()) { + if (std::chrono::duration(std::chrono::steady_clock::now() - start).count() > + deadlineSeconds) { + break; + } + if (eventLoop != nullptr) { + eventLoop->RunNestableV8Tasks(); + } isolate->PerformMicrotaskCheckpoint(); ALooper_pollOnce(10, nullptr, nullptr, nullptr); isolate->PerformMicrotaskCheckpoint(); - if (std::chrono::duration(std::chrono::steady_clock::now() - start).count() > - kModuleEvaluateDeadlineSeconds) { - DEBUG_WRITE("PumpPendingHttpModuleGraph: deadline expired with pending async module work"); - break; + + if (entryPending) { + EntryEvaluationState state = + ModuleInternal::PollEntryEvaluation(isolate, entryPath, &entryRejectionReason); + // Once it settles, stop probing for good. + entryPending = state == EntryEvaluationState::kPending; + if (state == EntryEvaluationState::kRejected) { + entryRejected = true; + break; + } } } + + if (entryRejected) { + throw NativeScriptException( + "Fatal: the main entry module's evaluation rejected during boot: " + + entryRejectionReason); + } + if (entryPending) { + throw NativeScriptException("Fatal: the main entry module '" + entryPath + + "' never settled within " + + std::to_string(static_cast(deadlineSeconds)) + "s"); + } } void Runtime::RunModule(JNIEnv* _env, jobject obj, jstring scriptFile) { @@ -378,13 +425,15 @@ void Runtime::RunModule(JNIEnv* _env, jobject obj, jstring scriptFile) { string filePath = ArgConverter::jstringToString(scriptFile); auto context = this->GetContext(); m_module.Load(context, filePath); - PumpPendingHttpModuleGraph(m_isolate); + // Java resolves package.json's `main` before handing the path over, so the + // entry the backstop probes is the very one that was just evaluated. + HoldBootBackstop(m_isolate, filePath); } void Runtime::RunModule(const char* moduleName) { auto context = this->GetContext(); m_module.Load(context, moduleName); - PumpPendingHttpModuleGraph(m_isolate); + HoldBootBackstop(m_isolate, moduleName); } void Runtime::RunWorker(const std::string& filePath) { From 52fdeef424998a5c0c899425f295500267ae3475 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 15:14:02 -0300 Subject: [PATCH 27/36] fix(runtime): fail cleanly on an unreadable ES module entry File::ReadText aborted the process on any path fopen could not open - the FILE* was fseek'd without a null check - and the ES-module entry routes (app main, worker main) reach CompileFileEsModule with the caller's specifier directly, without the resolver's existence probe, so a worker spawned with an unresolved relative .mjs path died with SIGABRT instead of an error. ReadText now returns null for an unreadable file (covering the deleted-between-stat-and-open race), and CompileFileEsModule stats its path first, throwing Cannot find module - which routes a missing worker entry to onerror and a missing main entry to the Java exception path. --- test-app/runtime/src/main/cpp/File.cpp | 15 +++++++++++++++ test-app/runtime/src/main/cpp/ModuleInternal.cpp | 9 +++++++++ 2 files changed, 24 insertions(+) diff --git a/test-app/runtime/src/main/cpp/File.cpp b/test-app/runtime/src/main/cpp/File.cpp index 21365e7d5..482893ccf 100644 --- a/test-app/runtime/src/main/cpp/File.cpp +++ b/test-app/runtime/src/main/cpp/File.cpp @@ -6,6 +6,7 @@ */ #include "File.h" +#include "NativeScriptAssert.h" #include #include #include @@ -19,6 +20,10 @@ string File::ReadText(const string& filePath) { bool isNew; const char* content = ReadText(filePath, len, isNew); + if (content == nullptr) { + return string(); + } + string s(content, len); if (isNew) { @@ -60,7 +65,17 @@ bool File::WriteBinary(const string& filePath, const void* data, int length) { } const char* File::ReadText(const string& filePath, int& charLength, bool& isNew) { + charLength = 0; + isNew = false; + FILE* file = fopen(filePath.c_str(), "rb"); + if (file == nullptr) { + // A path that never existed, or one deleted between a caller's stat and + // this open. Callers surface their own error; reading on regardless + // aborts the process on a null FILE*. + DEBUG_WRITE_FORCE("File::ReadText: cannot open %s", filePath.c_str()); + return nullptr; + } fseek(file, 0, SEEK_END); charLength = ftell(file); diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index aee7e3b77..33d223567 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -878,6 +878,15 @@ Local ModuleInternal::LoadData(Isolate* isolate, const string& path) { } MaybeLocal ModuleInternal::CompileFileEsModule(Isolate* isolate, const std::string& path) { + // The resolver only ever hands over a path it already probed, but the ENTRY + // routes (app main, worker main) reach here straight from the caller's + // specifier — so the existence check has to live here, or a missing entry + // reads a null FILE* instead of failing with a name. + struct stat st; + if (stat(path.c_str(), &st) != 0 || !S_ISREG(st.st_mode)) { + throw NativeScriptException("Cannot find module " + path); + } + string url = "file://" + path; string content = Runtime::GetRuntime(isolate)->ReadFileText(path); From 55ca718b2fb84de25ba61ce69d01879647f6ec45 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 15:14:02 -0300 Subject: [PATCH 28/36] test: port the loader suite - HTTP ESM, createRequire, interop, workers Ports the iOS loader specs onto the in-app fixture server: the module MIME gate and JSON modules over HTTP, mixed local/HTTP graphs, import-map scopes, canonical keys (mechanical-only unconfigured, client-supplied vocabulary), createRequire/createPumpingRequire (argument contract, mint-time options, the microtask re-entrancy guard), require(esm) exports interop, node:url/node:module, import.meta referrer resolution, worker vocabulary inheritance, and ES-module worker entries with top-level await, plus the esm/ fixture tree backing them. Wires in testNsModule, testNsRuntime, and testRemoteModuleSecurity, which were added earlier but never required from mainpage.js and so never ran; testNsRuntime now covers the debug category key and testRemoteModuleSecurity uses Java accessors that exist. Cleartext is permitted for 127.0.0.1/localhost only, so the fixture server is reachable while unroutable-host specs keep failing fast. 879 -> 1013 specs, all green. --- test-app/app/src/main/AndroidManifest.xml | 1 + .../createrequire/microtask-tla-guarded.mjs | 6 + .../app/esm/createrequire/microtask-tla.mjs | 5 + .../esm/createrequire/node-module-import.mjs | 9 + .../assets/app/esm/createrequire/target.js | 1 + .../app/esm/createrequire/tla-deadline.mjs | 3 + .../esm/createrequire/tla-foreground-task.mjs | 7 + .../esm/createrequire/tla-return-pending.mjs | 4 + .../src/main/assets/app/esm/fs-fallback.mjs | 2 + .../src/main/assets/app/esm/graph/bg-solo.mjs | 1 + .../src/main/assets/app/esm/graph/cycle-a.mjs | 8 + .../src/main/assets/app/esm/graph/cycle-b.mjs | 7 + .../assets/app/esm/graph/diamond-entry.mjs | 7 + .../src/main/assets/app/esm/graph/left.mjs | 6 + .../src/main/assets/app/esm/graph/right.mjs | 6 + .../src/main/assets/app/esm/graph/shared.mjs | 3 + .../assets/app/esm/hmr/test-esm-module.mjs | 24 + .../app/src/main/assets/app/esm/identity.json | 4 + .../main/assets/app/esm/interop/agreement.mjs | 3 + .../assets/app/esm/interop/default-live.mjs | 9 + .../main/assets/app/esm/interop/identity.mjs | 3 + .../assets/app/esm/interop/module-exports.mjs | 10 + .../assets/app/esm/interop/named-only.mjs | 5 + .../assets/app/esm/interop/own-esmodule.mjs | 4 + .../main/assets/app/esm/meta/nested/child.mjs | 1 + .../src/main/assets/app/esm/meta/parent.mjs | 4 + .../src/main/assets/app/esm/mixed/a-entry.mjs | 6 + .../src/main/assets/app/esm/mixed/a-mid.mjs | 7 + .../src/main/assets/app/esm/mixed/b-entry.mjs | 6 + .../src/main/assets/app/esm/mixed/b-mid.mjs | 7 + .../app/esm/nodebuiltins/importsNodePath.mjs | 5 + .../assets/app/esm/relative/dependency.mjs | 10 + .../main/assets/app/esm/relative/entry.mjs | 9 + .../src/main/assets/app/esm/relative/meta.mjs | 2 + .../assets/app/esm/scoped/inside/deep/mid.mjs | 4 + .../main/assets/app/esm/scoped/inside/mid.mjs | 7 + .../assets/app/esm/scoped/outside/mid.mjs | 4 + .../main/assets/app/esm/scoped/survivor.mjs | 5 + .../src/main/assets/app/esm/vocab/leafA.mjs | 1 + .../src/main/assets/app/esm/vocab/leafB.mjs | 1 + .../src/main/assets/app/esm/vocab/leafC.mjs | 1 + test-app/app/src/main/assets/app/mainpage.js | 12 + .../main/assets/app/tests/esmEntryHelper.mjs | 3 + .../assets/app/tests/esmEntrySyncWorker.mjs | 7 + .../assets/app/tests/esmEntryTlaWorker.mjs | 12 + .../main/assets/app/tests/importMapWorker.js | 9 + .../assets/app/tests/lateHandlerWorker.js | 8 + .../assets/app/tests/testCreateRequire.js | 291 ++++++++ .../assets/app/tests/testEsmHttpLoader.js | 688 ++++++++++++++++++ .../main/assets/app/tests/testEsmInterop.js | 74 ++ .../app/tests/testImportMetaResolution.js | 38 + .../assets/app/tests/testNodeUrlModule.js | 172 +++++ .../src/main/assets/app/tests/testNsModule.js | 98 ++- .../main/assets/app/tests/testNsRuntime.js | 82 ++- .../app/tests/testRemoteModuleSecurity.js | 45 +- .../assets/app/tests/testWorkerEsmEntry.js | 129 ++++ .../main/res/xml/network_security_config.xml | 13 + 57 files changed, 1814 insertions(+), 85 deletions(-) create mode 100644 test-app/app/src/main/assets/app/esm/createrequire/microtask-tla-guarded.mjs create mode 100644 test-app/app/src/main/assets/app/esm/createrequire/microtask-tla.mjs create mode 100644 test-app/app/src/main/assets/app/esm/createrequire/node-module-import.mjs create mode 100644 test-app/app/src/main/assets/app/esm/createrequire/target.js create mode 100644 test-app/app/src/main/assets/app/esm/createrequire/tla-deadline.mjs create mode 100644 test-app/app/src/main/assets/app/esm/createrequire/tla-foreground-task.mjs create mode 100644 test-app/app/src/main/assets/app/esm/createrequire/tla-return-pending.mjs create mode 100644 test-app/app/src/main/assets/app/esm/fs-fallback.mjs create mode 100644 test-app/app/src/main/assets/app/esm/graph/bg-solo.mjs create mode 100644 test-app/app/src/main/assets/app/esm/graph/cycle-a.mjs create mode 100644 test-app/app/src/main/assets/app/esm/graph/cycle-b.mjs create mode 100644 test-app/app/src/main/assets/app/esm/graph/diamond-entry.mjs create mode 100644 test-app/app/src/main/assets/app/esm/graph/left.mjs create mode 100644 test-app/app/src/main/assets/app/esm/graph/right.mjs create mode 100644 test-app/app/src/main/assets/app/esm/graph/shared.mjs create mode 100644 test-app/app/src/main/assets/app/esm/hmr/test-esm-module.mjs create mode 100644 test-app/app/src/main/assets/app/esm/identity.json create mode 100644 test-app/app/src/main/assets/app/esm/interop/agreement.mjs create mode 100644 test-app/app/src/main/assets/app/esm/interop/default-live.mjs create mode 100644 test-app/app/src/main/assets/app/esm/interop/identity.mjs create mode 100644 test-app/app/src/main/assets/app/esm/interop/module-exports.mjs create mode 100644 test-app/app/src/main/assets/app/esm/interop/named-only.mjs create mode 100644 test-app/app/src/main/assets/app/esm/interop/own-esmodule.mjs create mode 100644 test-app/app/src/main/assets/app/esm/meta/nested/child.mjs create mode 100644 test-app/app/src/main/assets/app/esm/meta/parent.mjs create mode 100644 test-app/app/src/main/assets/app/esm/mixed/a-entry.mjs create mode 100644 test-app/app/src/main/assets/app/esm/mixed/a-mid.mjs create mode 100644 test-app/app/src/main/assets/app/esm/mixed/b-entry.mjs create mode 100644 test-app/app/src/main/assets/app/esm/mixed/b-mid.mjs create mode 100644 test-app/app/src/main/assets/app/esm/nodebuiltins/importsNodePath.mjs create mode 100644 test-app/app/src/main/assets/app/esm/relative/dependency.mjs create mode 100644 test-app/app/src/main/assets/app/esm/relative/entry.mjs create mode 100644 test-app/app/src/main/assets/app/esm/relative/meta.mjs create mode 100644 test-app/app/src/main/assets/app/esm/scoped/inside/deep/mid.mjs create mode 100644 test-app/app/src/main/assets/app/esm/scoped/inside/mid.mjs create mode 100644 test-app/app/src/main/assets/app/esm/scoped/outside/mid.mjs create mode 100644 test-app/app/src/main/assets/app/esm/scoped/survivor.mjs create mode 100644 test-app/app/src/main/assets/app/esm/vocab/leafA.mjs create mode 100644 test-app/app/src/main/assets/app/esm/vocab/leafB.mjs create mode 100644 test-app/app/src/main/assets/app/esm/vocab/leafC.mjs create mode 100644 test-app/app/src/main/assets/app/tests/esmEntryHelper.mjs create mode 100644 test-app/app/src/main/assets/app/tests/esmEntrySyncWorker.mjs create mode 100644 test-app/app/src/main/assets/app/tests/esmEntryTlaWorker.mjs create mode 100644 test-app/app/src/main/assets/app/tests/importMapWorker.js create mode 100644 test-app/app/src/main/assets/app/tests/lateHandlerWorker.js create mode 100644 test-app/app/src/main/assets/app/tests/testCreateRequire.js create mode 100644 test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js create mode 100644 test-app/app/src/main/assets/app/tests/testEsmInterop.js create mode 100644 test-app/app/src/main/assets/app/tests/testImportMetaResolution.js create mode 100644 test-app/app/src/main/assets/app/tests/testNodeUrlModule.js create mode 100644 test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js create mode 100644 test-app/app/src/main/res/xml/network_security_config.xml diff --git a/test-app/app/src/main/AndroidManifest.xml b/test-app/app/src/main/AndroidManifest.xml index b0a7d50b3..f6896f5df 100644 --- a/test-app/app/src/main/AndroidManifest.xml +++ b/test-app/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ "; + } + + describe("surface", function () { + it("ns:module exposes both require factories", function () { + expect(typeof nsModule.createRequire).toBe("function"); + expect(typeof nsModule.createPumpingRequire).toBe("function"); + }); + + it("node:module re-exports createRequire and nothing else", function () { + var nodeModule = require("node:module"); + expect(Object.isFrozen(nodeModule)).toBe(true); + expect(Object.keys(nodeModule)).toEqual(["createRequire"]); + // The pumping flavor is a NativeScript extension with no Node + // counterpart, so it stays off the node: surface. + expect(nodeModule.createPumpingRequire).toBeUndefined(); + }); + + it("node:module is a distinct module object from ns:module", function () { + expect(require("node:module")).not.toBe(require("ns:module")); + }); + + it("exposes createRequire through a static import of node:module", function (done) { + import("~/esm/createrequire/node-module-import.mjs").then(function (ns) { + expect(ns.createRequireType).toBe("function"); + var target = ns.requireFrom(fixtureDir + "/anything.js", "./target.js"); + expect(target.tag).toBe("createrequire-target"); + done(); + }).catch(function (e) { + expect("rejected: " + String((e && e.message) || e)).toBe("resolved"); + done(); + }); + }); + + // Absent rather than present-but-throwing, so a feature check that + // guards on them takes the fallback path. + it("mints a require without resolve, cache or main", function () { + var req = nsModule.createRequire(fixtureDir + "/anything.js"); + expect(req.resolve).toBeUndefined(); + expect(req.cache).toBeUndefined(); + expect(req.main).toBeUndefined(); + }); + }); + + describe("base resolution", function () { + it("resolves ./ against the directory of the given file", function () { + var req = nsModule.createRequire(fixtureDir + "/anything.js"); + expect(req("./target.js").tag).toBe("createrequire-target"); + }); + + it("treats a trailing slash as the directory itself", function () { + var req = nsModule.createRequire(fixtureDir + "/"); + expect(req("./target.js").tag).toBe("createrequire-target"); + }); + + it("accepts a file URL string", function () { + var req = nsModule.createRequire("file://" + fixtureDir + "/anything.js"); + expect(req("./target.js").tag).toBe("createrequire-target"); + }); + + it("accepts a URL object", function () { + var req = nsModule.createRequire(new URL("file://" + fixtureDir + "/anything.js")); + expect(req("./target.js").tag).toBe("createrequire-target"); + }); + + it("still resolves ~ specifiers against the app root", function () { + var req = nsModule.createRequire(fixtureDir + "/anything.js"); + expect(req("~/esm/createrequire/target.js").tag).toBe("createrequire-target"); + }); + }); + + describe("argument validation", function () { + it("rejects a non-string, non-URL argument", function () { + expect(messageOf(function () { nsModule.createRequire(42); })).toBe(ARGUMENT_ERROR); + }); + + it("rejects a relative path string", function () { + expect(messageOf(function () { nsModule.createRequire("./tests/index.js"); })) + .toBe(ARGUMENT_ERROR); + }); + + it("rejects a non-file URL scheme", function () { + expect(messageOf(function () { nsModule.createRequire("ftp://example.com/a.js"); })) + .toBe(ARGUMENT_ERROR); + }); + + it("refuses an http base with a dev-server specific message", function () { + expect(messageOf(function () { + nsModule.createRequire("http://localhost:8080/main.js"); + })).toBe("createRequire() cannot take an http(s) URL (http://localhost:8080/main.js): " + + "require() of a dev-served module is not supported. Pass an app-root file " + + "path and use import() for remote modules."); + }); + + it("applies the same validation to createPumpingRequire", function () { + expect(messageOf(function () { nsModule.createPumpingRequire(42); })) + .toBe(ARGUMENT_ERROR); + }); + }); + + describe("evaluation policy", function () { + it("refuses a top-level-await graph strictly", function () { + var strictRequire = nsModule.createRequire(fixtureDir + "/anything.js"); + + var refusal = messageOf(function () { strictRequire("./microtask-tla.mjs"); }); + expect(refusal).toContain("require() cannot load ES module '"); + expect(refusal).toContain("': the module graph contains top-level await. " + + "Use import() or createPumpingRequire from ns:module instead."); + }); + + it("evaluates the same graph when pumping", function (done) { + onFreshTask(function () { + var pumpingRequire = nsModule.createPumpingRequire(fixtureDir + "/anything.js"); + var result = ""; + try { + result = String(pumpingRequire("./microtask-tla.mjs").value); + } catch (e) { + result = "threw: " + ((e && e.message) || e); + } + expect(result).toBe("ok"); + done(); + }); + }); + + it("refuses to pump a top-level-await graph from inside a microtask", function (done) { + var pumpingRequire = nsModule.createPumpingRequire(fixtureDir + "/anything.js"); + Promise.resolve().then(function () { + var refusal = messageOf(function () { + pumpingRequire("./microtask-tla-guarded.mjs"); + }); + expect(refusal).toContain("createPumpingRequire cannot settle module graph '"); + expect(refusal).toContain("' from inside a microtask (after an await or inside a " + + "promise callback): the event loop cannot be pumped " + + "re-entrantly. Call it from a task context, or use import()."); + done(); + }); + }); + + it("still loads a synchronous graph from inside a microtask", function (done) { + var pumpingRequire = nsModule.createPumpingRequire(fixtureDir + "/anything.js"); + Promise.resolve().then(function () { + expect(pumpingRequire("./target.js").tag).toBe("createrequire-target"); + done(); + }); + }); + + // The refusal is decided before evaluation, so the graph stays loadable. + it("still imports a graph a strict require refused", function (done) { + import("~/esm/createrequire/microtask-tla.mjs").then(function (ns) { + expect(ns.value).toBe("ok"); + done(); + }).catch(function (e) { + expect("rejected: " + String((e && e.message) || e)).toBe("resolved"); + done(); + }); + }); + + describe("pumping options", function () { + it("rejects a non-object options bag", function () { + expect(function () { + nsModule.createPumpingRequire(fixtureDir + "/x.js", 42); + }).toThrowError(TypeError, "createPumpingRequire: options must be an object"); + }); + + it("rejects an unknown option key by name", function () { + expect(function () { + nsModule.createPumpingRequire(fixtureDir + "/x.js", { deadline: 1 }); + }).toThrowError(TypeError, "createPumpingRequire: unknown option 'deadline'"); + }); + + it("rejects bad option values", function () { + var badDeadline = + "createPumpingRequire: 'deadlineSeconds' must be a positive finite number"; + expect(function () { + nsModule.createPumpingRequire(fixtureDir + "/x.js", { deadlineSeconds: 0 }); + }).toThrowError(TypeError, badDeadline); + expect(function () { + nsModule.createPumpingRequire(fixtureDir + "/x.js", { deadlineSeconds: Infinity }); + }).toThrowError(TypeError, badDeadline); + expect(function () { + nsModule.createPumpingRequire(fixtureDir + "/x.js", { onTimeout: "wait" }); + }).toThrowError(TypeError, + "createPumpingRequire: 'onTimeout' must be 'throw' or 'return-pending'"); + expect(function () { + nsModule.createPumpingRequire(fixtureDir + "/x.js", { pumpRunLoop: "yes" }); + }).toThrowError(TypeError, + "createPumpingRequire: 'pumpRunLoop' must be a boolean"); + }); + + it("refuses options on the strict createRequire", function () { + expect(function () { + nsModule.createRequire(fixtureDir + "/x.js", { deadlineSeconds: 1 }); + }).toThrowError(TypeError, "options are not supported on createRequire"); + }); + + // These reach the deadline, so they must run from a task context — + // from a microtask the guard would refuse before evaluating. + it("returns without throwing at the deadline under onTimeout return-pending", + function (done) { + onFreshTask(function () { + // The graph parks on a promise nothing settles, so the + // deadline is always what ends the wait. + var req = nsModule.createPumpingRequire(fixtureDir + "/anything.js", { + deadlineSeconds: 0.25, + onTimeout: "return-pending", + }); + var outcome = ""; + try { + var mod = req("./tla-return-pending.mjs"); + outcome = typeof mod === "object" ? "returned a namespace" + : "returned " + typeof mod; + } catch (e) { + outcome = "threw: " + ((e && e.message) || e); + } + expect(outcome).toBe("returned a namespace"); + done(); + }); + }); + + it("honors a short deadlineSeconds with the default onTimeout throw", function (done) { + onFreshTask(function () { + var req = nsModule.createPumpingRequire(fixtureDir + "/anything.js", { + deadlineSeconds: 0.25, + }); + var started = Date.now(); + expect(messageOf(function () { req("./tla-deadline.mjs"); })) + .toContain("Top-level await timed out for ES module "); + // The configured deadline governed, not the 60s default. + expect(Date.now() - started < 5000 ? "within the short deadline" + : "took too long") + .toBe("within the short deadline"); + done(); + }); + }); + + it("keeps the microtask guard unconditional even with pumpRunLoop", function (done) { + var req = nsModule.createPumpingRequire(fixtureDir + "/anything.js", { + pumpRunLoop: true, + }); + Promise.resolve().then(function () { + expect(messageOf(function () { req("./microtask-tla-guarded.mjs"); })) + .toContain("cannot be pumped re-entrantly"); + done(); + }); + }); + }); + + it("refuses a foreground-task top-level await through createRequire", function () { + var req = nsModule.createRequire(fixtureDir + "/anything.js"); + expect(messageOf(function () { req("./tla-foreground-task.mjs"); })) + .toContain("the module graph contains top-level await"); + }); + }); +}); + +// `~` marks the app root; the separator after it is optional. +describe("app-root specifiers", function () { + it("resolves ~/path", function () { + expect(require("~/esm/createrequire/target.js").tag).toBe("createrequire-target"); + }); + + it("resolves ~path without a separator", function () { + expect(require("~esm/createrequire/target.js").tag).toBe("createrequire-target"); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js b/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js new file mode 100644 index 000000000..276033b94 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js @@ -0,0 +1,688 @@ +// The loopback fixture server byte-mirrors the routes of the iOS TestRunner's +// ModuleTestServer, so these specs pin the same loader contract on both +// platforms. +var appRoot = __dirname.replace(/\/tests$/, ""); +var origin = "http://127.0.0.1:" + com.tns.tests.ModuleTestServer.ensureStarted(); + +describe("HTTP ESM Loader", function () { + + function formatError(e) { + try { + if (!e) return "(no error)"; + if (e instanceof Error) return e.message; + if (typeof e === "string") return e; + if (e && typeof e.message === "string") return e.message; + return JSON.stringify(e); + } catch (_) { + return String(e); + } + } + + // This Jasmine's fail() throws, which inside a promise reaction surfaces as + // an opaque spec timeout instead of a diff. Every rejection handler below + // reports through a non-throwing expect for that reason. + function reportRejection(error, done) { + expect("rejected: " + formatError(error)).toBe("resolved"); + done(); + } + + function withTimeout(promise, ms, label) { + return new Promise(function (resolve, reject) { + var timer = setTimeout(function () { + reject(new Error("Timeout after " + ms + "ms" + (label ? ": " + label : ""))); + }, ms); + + promise.then(function (value) { + clearTimeout(timer); + resolve(value); + }).catch(function (err) { + clearTimeout(timer); + reject(err); + }); + }); + } + + // Loopback fetches can outrun jasmine 2.0.1's 5s default on a cold + // emulator. 2.0.1 has no beforeAll, so the pair is installed per describe. + function useHttpTimeout() { + var originalTimeout; + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; + }); + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + } + + function onBackgroundThread(body) { + new java.lang.Thread(new java.lang.Runnable({ + run: body + })).start(); + } + + describe("URL Resolution", function () { + it("should handle relative imports", function (done) { + import("~/esm/relative/entry.mjs").then(function (module) { + expect(module.viaDefault).toBe("relative-import-success"); + expect(module.viaNamed).toBe("relative-import-success"); + expect(module.readDependencyPayload()).toBe(true); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + it("should surface helpful errors for unresolved bare specifiers", function (done) { + import("bare-spec-example").then(function (mod) { + // A placeholder module default-exports a Proxy whose get trap + // throws; touching a property is what surfaces the diagnostic. + var threw = false; + try { + void (mod && mod.default && mod.default.__touch__); + } catch (useErr) { + threw = true; + expect(formatError(useErr)).toContain("bare-spec-example"); + } + expect(threw).toBe(true); + done(); + }).catch(function (error) { + expect(formatError(error)).toContain("bare-spec-example"); + done(); + }); + }); + }); + + describe("HTTP Fetch Integration", function () { + + it("settles a local dynamic import issued from a background thread", function (done) { + onBackgroundThread(function () { + import("~/esm/graph/bg-solo.mjs").then(function (module) { + expect(module.name).toBe("bg-solo"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + describe("from a background thread over HTTP", function () { + useHttpTimeout(); + + it("settles an HTTP dynamic import issued from a background thread", function (done) { + // Completion delivery must not depend on the calling thread + // owning a looper, so nothing here schedules a timer. + onBackgroundThread(function () { + import(origin + "/esm/query.mjs?v=bg").then(function (module) { + expect(module).toBeDefined(); + expect(module.query).toContain("v=bg"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + }); + + it("evaluates a disk diamond graph in spec order, each module once", function (done) { + import("~/esm/graph/diamond-entry.mjs").then(function (module) { + expect(module.order).toEqual(["shared", "left", "right", "entry"]); + expect(module.names).toEqual(["left", "right"]); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + // A local root whose graph reaches an HTTP leaf. Discovery is + // scheme-agnostic, so the walk compiles the whole closure up front and + // the resolver never takes a blocking synchronous fetch. + describe("mixed local/http graphs", function () { + useHttpTimeout(); + + function configureLeaves() { + require("ns:module").configureLoader({ + importMap: { + imports: { + "ns-test-leaf-a": origin + "/esm/graph-leaf.mjs?k=a", + "ns-test-leaf-b": origin + "/esm/graph-leaf.mjs?k=b", + }, + }, + }); + } + + afterEach(function () { + require("ns:module").configureLoader({ importMap: { imports: {} } }); + }); + + it("resolves a local->local->http graph through require()", function () { + configureLeaves(); + + var req = require("ns:module").createRequire(appRoot + "/anything.js"); + var mod = req("./esm/mixed/a-entry.mjs"); + expect(mod.leaf).toBe("a"); + // Spec evaluation order, deepest first — the walk changes only + // when modules are compiled, never when they run. + expect(mod.order).toEqual(["leaf", "mid", "entry"]); + }); + + it("resolves a local->local->http graph through import()", function (done) { + configureLeaves(); + + import("~/esm/mixed/b-entry.mjs").then(function (mod) { + expect(mod.leaf).toBe("b"); + expect(mod.order).toEqual(["leaf", "mid", "entry"]); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + // The import map is process-wide, so every spec here installs its own + // and restores the empty map afterwards. + describe("import map", function () { + useHttpTimeout(); + + var nsModule = require("ns:module"); + + function setMap(map) { + nsModule.configureLoader({ importMap: map }); + } + + afterEach(function () { + setMap({ imports: {} }); + }); + + it("rejects an unknown top-level section by name", function () { + expect(function () { + setMap({ imports: {}, integrity: {} }); + }).toThrowError(TypeError, /unsupported import-map section 'integrity'/); + }); + + it("rejects a trailing-slash key whose target does not end in '/'", function () { + expect(function () { + setMap({ imports: { "pkg/": "http://example.com/pkg" } }); + }).toThrowError(TypeError, /must end with '\/'/); + }); + + it("rejects a trailing-slash key inside a scope map too", function () { + expect(function () { + setMap({ scopes: { "/a/": { "pkg/": "http://example.com/pkg" } } }); + }).toThrowError(TypeError, /must end with '\/'/); + }); + + it("rejects a null or non-string target", function () { + expect(function () { + setMap({ imports: { "pkg": null } }); + }).toThrowError(TypeError, /must be a string/); + expect(function () { + setMap({ imports: { "pkg": 42 } }); + }).toThrowError(TypeError, /must be a string/); + }); + + it("rejects a non-object scope map", function () { + expect(function () { + setMap({ scopes: { "/a/": "not-an-object" } }); + }).toThrowError(TypeError, /must be an object/); + }); + + it("prefixes every validation failure with 'configureLoader: '", function () { + expect(function () { + setMap({ imports: {}, integrity: {} }); + }).toThrowError(TypeError, /^configureLoader: /); + }); + + it("keeps the previous map when an update is rejected", function (done) { + setMap({ imports: { "ns-survivor": origin + "/esm/graph-leaf.mjs?k=surv" } }); + + expect(function () { + nsModule.configureLoader({ importMap: "{ this is not json" }); + }).toThrowError(TypeError, /valid JSON/); + + // The rejected update changed nothing, so the module installed + // by the previous map still resolves. + import("~/esm/scoped/survivor.mjs").then(function (mod) { + expect(mod.leaf).toBe("surv"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + // The vocabulary is per-isolate; a worker gets a copy taken on the + // parent's thread as it spawns. + it("gives a worker spawned after configureLoader the parent's map", function (done) { + setMap({ imports: { "ns-worker-leaf": origin + "/esm/graph-leaf.mjs?k=wa" } }); + + var worker = new Worker("./importMapWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data.ok ? "resolved" : "failed: " + msg.data.error).toBe("resolved"); + expect(msg.data.name).toBe("wa"); + worker.terminate(); + done(); + }; + worker.postMessage("ns-worker-leaf"); + }); + + it("leaves a running worker on the map it was spawned with", function (done) { + setMap({ imports: { "ns-worker-leaf": origin + "/esm/graph-leaf.mjs?k=wb" } }); + + var worker = new Worker("./importMapWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data.ok ? "resolved" : "failed: " + msg.data.error).toBe("resolved"); + expect(msg.data.name).toBe("wb"); + worker.terminate(); + done(); + }; + + // Reconfigure the parent only after the worker exists, then ask + // it to resolve. The parent's own isolate does see the update. + setMap({ imports: { "ns-worker-leaf": origin + "/esm/graph-leaf.mjs?k=wc" } }); + worker.postMessage("ns-worker-leaf"); + }); + + it("resolves through the scope cascade for every referrer", function (done) { + // A scope key is prefix-matched against the referrer's canonical + // registry key, which for a disk module is a bare absolute path. + var insideScope = appRoot + "/esm/scoped/inside/"; + var deepScope = appRoot + "/esm/scoped/inside/deep/"; + var scopes = {}; + scopes[insideScope] = { "ns-scoped-leaf": origin + "/esm/graph-leaf.mjs?k=in" }; + scopes[deepScope] = { "ns-scoped-leaf": origin + "/esm/graph-leaf.mjs?k=deep" }; + setMap({ + imports: { + "ns-scoped-leaf": origin + "/esm/graph-leaf.mjs?k=top", + "ns-scoped-fallthrough": origin + "/esm/graph-leaf.mjs?k=fall", + }, + scopes: scopes, + }); + + Promise.all([ + import("~/esm/scoped/inside/mid.mjs"), + import("~/esm/scoped/inside/deep/mid.mjs"), + import("~/esm/scoped/outside/mid.mjs"), + ]).then(function (mods) { + // A scope wins over the top-level entry for a referrer inside it. + expect(mods[0].leaf).toBe("in"); + // ...and a specifier the scope does not define falls through. + expect(mods[0].fallthrough).toBe("fall"); + // Two scopes match; the more specific one wins. + expect(mods[1].leaf).toBe("deep"); + // No scope matches this referrer. + expect(mods[2].leaf).toBe("top"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + // Module scripts are strict about MIME on the web, and so is the + // loader: the response policy lives in one classifier shared by the + // synchronous fallback and the graph walk. + describe("module MIME gate", function () { + useHttpTimeout(); + + function rejectionOf(url, callback) { + import(url).then(function () { + callback(""); + }).catch(function (error) { + callback(String((error && error.message) || error)); + }); + } + + it("rejects an SPA fallback that answers with text/html", function (done) { + var url = origin + "/esm/html-fallback.mjs"; + rejectionOf(url, function (message) { + // The DX win: the cause is the MIME type, not a syntax + // error from HTML reaching the JS parser. + expect(message.indexOf("text/html") >= 0 ? "names the MIME" : message) + .toBe("names the MIME"); + expect(message.indexOf(url) >= 0 ? "names the URL" : message) + .toBe("names the URL"); + expect(message.indexOf("Unexpected token") >= 0 ? message : "no parse error") + .toBe("no parse error"); + done(); + }); + }); + + it("rejects a response that carries no MIME type", function (done) { + var url = origin + "/esm/no-mime.mjs"; + rejectionOf(url, function (message) { + expect(message.indexOf("no MIME type") >= 0 ? "names the missing MIME" : message) + .toBe("names the missing MIME"); + expect(message.indexOf(url) >= 0 ? "names the URL" : message) + .toBe("names the URL"); + done(); + }); + }); + + it("names the status for a non-2xx response", function (done) { + var url = origin + "/esm/nonexistent-module-404.mjs"; + rejectionOf(url, function (message) { + expect(message).toBe("HTTP import failed: " + url + " (status=404)"); + done(); + }); + }); + + it("still serves an empty 200 with a JS MIME as the empty module", function (done) { + // Type-only modules transform to zero runtime code; dev servers + // serve them as empty 200s and they must stay valid. + import(origin + "/esm/empty.mjs").then(function (mod) { + expect(typeof mod).toBe("object"); + expect(Object.keys(mod)).toEqual([]); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + it("routes a served JSON module through the JSON path, with stable identity", + function (done) { + var url = origin + "/esm/data.json"; + import(url).then(function (first) { + expect(first.default.kind).toBe("json-module"); + expect(first.default.n).toBe(41); + return import(url).then(function (second) { + expect(second).toBe(first); + expect(second.default).toBe(first.default); + done(); + }); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + // Re-importing from inside the first import's own resolution is + // the case that exposed stale waiter routing: the reaction runs + // while the first settle is still unwinding, so the loader must + // already have cleared the state that would park this import on a + // waiter list nothing will flush. + it("settles a re-entrant re-import issued from the first import's handler", + function (done) { + var url = origin + "/esm/data.json?reentrant=1"; + var settled = "never settled"; + import(url).then(function (first) { + import(url).then(function (second) { + settled = second === first ? "same namespace" : "different namespace"; + }, function (error) { + settled = "re-import rejected: " + ((error && error.message) || error); + }); + }, function (error) { + settled = "first import rejected: " + ((error && error.message) || error); + }); + __ns__setTimeout(function () { + expect(settled).toBe("same namespace"); + done(); + }, 1500); + }); + }); + + it("links and evaluates cyclic disk imports", function (done) { + import("~/esm/graph/cycle-a.mjs").then(function (module) { + expect(module.aValue).toBe("a"); + expect(module.roundTrip).toBe("b-saw-a"); + expect(module.describeB()).toBe("a-saw-b"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + it("gives nested disk modules a correct import.meta", function (done) { + import("~/esm/relative/meta.mjs").then(function (module) { + expect(typeof module.metaUrl).toBe("string"); + expect(module.metaUrl.indexOf("file://")).toBe(0); + expect(module.metaUrl).toContain("esm/relative/meta.mjs"); + expect(typeof module.metaDirname).toBe("string"); + expect(module.metaDirname).toContain("esm/relative"); + expect(module.metaDirname).not.toContain("meta.mjs"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + it("returns one module identity for repeated JSON imports", function (done) { + var spec = "~/esm/identity.json"; + Promise.all([import(spec), import(spec)]).then(function (results) { + expect(results[0]).toBe(results[1]); + expect(results[0].default.name).toBe("esm-identity-fixture"); + expect(results[0].default.value).toBe(42); + return import(spec).then(function (third) { + expect(third).toBe(results[0]); + expect(third.default).toBe(results[0].default); + done(); + }); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + it("should fall back to filesystem when HTTP fetch fails", function (done) { + import("~/esm/fs-fallback.mjs").then(function (module) { + expect(module).toBeDefined(); + expect(module.ok || (module.default && module.default.ok)).toBe(true); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + describe("Module Compilation", function () { + + it("should compile filesystem-backed ES modules successfully", function (done) { + import("~/esm/hmr/test-esm-module.mjs").then(function (module) { + expect(module).toBeDefined(); + expect(module.testValue).toBe("http-esm-loaded"); + expect(typeof module.default).toBe("function"); + expect(module.default()).toContain("HTTP ESM loader working"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + + it("should reuse compiled modules across multiple dynamic imports", function (done) { + var spec = "~/esm/hmr/test-esm-module.mjs"; + Promise.all([import(spec), import(spec)]).then(function (results) { + expect(results[0]).toBeDefined(); + expect(results[1]).toBeDefined(); + expect(results[0].timestamp).toBe(results[1].timestamp); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + describe("Error Handling", function () { + useHttpTimeout(); + + it("surfaces the real compile error for a served module with a syntax error", function (done) { + var url = origin + "/esm/syntax-error.mjs"; + withTimeout(import(url), 10000, "import " + url) + .then(function () { + expect("resolved").toBe("rejected"); + done(); + }) + .catch(function (error) { + // The parse error itself, not a generic "compile failed" / + // instantiation failure that names no cause. + var message = String((error && error.message) || error); + expect(message.indexOf("Unexpected token") >= 0 ? "names the parse error" : message) + .toBe("names the parse error"); + expect(message.indexOf("syntax-error.mjs") >= 0 ? "names the module" : message) + .toBe("names the module"); + done(); + }); + }); + + describe("unreachable and slow endpoints", function () { + it("rejects an unreachable host as a network error", function (done) { + // A closed loopback port refuses instantly, so this pins the + // network-error wording without waiting out a real timeout. + var url = "http://127.0.0.1:59999/unreachable.mjs"; + import(url).then(function () { + expect("resolved").toBe("rejected"); + done(); + }).catch(function (error) { + expect(String((error && error.message) || error)) + .toBe("HTTP import failed: " + url + " (network error)"); + done(); + }); + }); + + it("waits out a slow response instead of aborting it early", function (done) { + var url = origin + "/esm/timeout.mjs?delayMs=1500"; + import(url).then(function (mod) { + expect(typeof mod.evaluatedAt).toBe("number"); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + it("should handle malformed URLs gracefully", function () { + // The rejection is swallowed deliberately: the contract under test + // is only that a malformed http specifier throws nothing inline. + expect(function () { + import("http://").catch(function () { }); + }).not.toThrow(); + }); + }); + + describe("Integration with HMR", function () { + + it("should NOT attach a native import.meta.hot (hot contexts are injected by the dev server)", function (done) { + // The runtime owns no HMR policy: `import.meta.hot` is only present + // when the @nativescript/vite dev server injects a JS hot context + // into the served module source. + import("~/esm/hmr/test-esm-module.mjs").then(function (module) { + expect(module.getHotContext()).toBeUndefined(); + expect(module.callInvalidateSafe()).toBe(false); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + describe("URL Key Canonicalization", function () { + useHttpTimeout(); + + it("preserves query for non-dev/public URLs", function (done) { + var u1 = origin + "/esm/query.mjs?v=1"; + var u2 = origin + "/esm/query.mjs?v=2"; + + withTimeout(import(u1), 10000, "import " + u1) + .then(function (m1) { + return withTimeout(import(u2), 10000, "import " + u2).then(function (m2) { + expect(m1.query).toContain("v=1"); + expect(m2.query).toContain("v=2"); + expect(m1.query).not.toBe(m2.query); + done(); + }); + }) + .catch(function (error) { + reportRejection(error, done); + }); + }); + + // Collapsing cache-busters onto one registry key needs a vocabulary; + // the runtime ships none, so these specs install one. Canonicalization + // config is process-wide, hence the restore. (Jasmine 2.0.1 has no + // beforeAll/afterAll.) + describe("with a dev-endpoint vocabulary configured", function () { + beforeEach(function () { + require("ns:module").configureLoader({ + canonicalization: { + stripParams: ["t", "v", "import"], + forPathPrefixes: ["/ns/"], + preserveQueryFor: [], + }, + }); + }); + + afterEach(function () { + require("ns:module").configureLoader({ + canonicalization: { stripParams: [], forPathPrefixes: [], preserveQueryFor: [] }, + }); + }); + + it("drops the configured cache-busters for dev endpoints", function (done) { + var u1 = origin + "/ns/m/query.mjs?v=1"; + var u2 = origin + "/ns/m/query.mjs?v=2"; + + withTimeout(import(u1), 10000, "import " + u1) + .then(function (m1) { + return withTimeout(import(u2), 10000, "import " + u2).then(function (m2) { + // Both URLs map to one cache key, so the second + // import reuses the first evaluated module. + expect(m2.evaluatedAt).toBe(m1.evaluatedAt); + expect(m2.query).toBe(m1.query); + done(); + }); + }) + .catch(function (error) { + reportRejection(error, done); + }); + }); + + it("sorts query params for dev endpoints", function (done) { + var u1 = origin + "/ns/m/query.mjs?b=2&a=1"; + var u2 = origin + "/ns/m/query.mjs?a=1&b=2"; + + withTimeout(import(u1), 10000, "import " + u1) + .then(function (m1) { + return withTimeout(import(u2), 10000, "import " + u2).then(function (m2) { + expect(m2.evaluatedAt).toBe(m1.evaluatedAt); + expect(m2.query).toBe(m1.query); + done(); + }); + }) + .catch(function (error) { + reportRejection(error, done); + }); + }); + }); + + it("ignores URL fragments for cache identity", function (done) { + var u1 = origin + "/esm/query.mjs#one"; + var u2 = origin + "/esm/query.mjs#two"; + + withTimeout(import(u1), 10000, "import " + u1) + .then(function (m1) { + return withTimeout(import(u2), 10000, "import " + u2).then(function (m2) { + expect(m2.evaluatedAt).toBe(m1.evaluatedAt); + done(); + }); + }) + .catch(function (error) { + reportRejection(error, done); + }); + }); + }); +}); + +// A bare `@` is not a specifier the runtime knows: it resolves through the +// normal path and fails, naming itself, instead of being swallowed into a +// fabricated empty module. +describe("invalid module specifiers", function () { + it("rejects a dynamic import of '@' with an error naming the specifier", function (done) { + import("@").then(function () { + expect("resolved").toBe("rejected"); + done(); + }).catch(function (e) { + var message = String((e && e.message) || e); + expect(message.indexOf("Cannot find module '@'") >= 0 ? "names the specifier" : message) + .toBe("names the specifier"); + expect(message.indexOf("tried " + appRoot + "/@") >= 0 ? "names the path tried" : message) + .toBe("names the path tried"); + done(); + }); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testEsmInterop.js b/test-app/app/src/main/assets/app/tests/testEsmInterop.js new file mode 100644 index 000000000..a834fe92d --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testEsmInterop.js @@ -0,0 +1,74 @@ +// require() of an ES module follows Node's populateCJSExportsFromESM cascade: +// an explicit 'module.exports' export wins, a namespace without a default or +// with its own __esModule passes through, and everything else is wrapped in a +// facade that adds __esModule while keeping the target's live bindings. +describe("require(esm) exports interop", function () { + it("wraps a default-exporting module in an __esModule facade", function () { + var mod = require("~/esm/interop/default-live.mjs"); + expect(mod.__esModule).toBe(true); + expect(mod.default).toBe(1); + }); + + it("keeps the facade's default binding live", function () { + var mod = require("~/esm/interop/default-live.mjs"); + var before = mod.default; + mod.bump(); + expect(mod.default).toBe(before + 1); + }); + + it("returns the 'module.exports' export verbatim", function () { + var mod = require("~/esm/interop/module-exports.mjs"); + expect(typeof mod).toBe("function"); + expect(mod.marker).toBe("module.exports fixture"); + expect(mod(2, 3)).toBe(5); + }); + + it("passes the namespace through when the module declares __esModule", function () { + var mod = require("~/esm/interop/own-esmodule.mjs"); + expect(mod.__esModule).toBe("mine"); + expect(mod.default.tag).toBe("own-esmodule"); + }); + + it("passes the namespace through when there is no default export", function () { + var mod = require("~/esm/interop/named-only.mjs"); + expect(mod.alpha).toBe("a"); + expect(mod.beta()).toBe("b"); + expect(mod.default).toBeUndefined(); + expect(mod.__esModule).toBeUndefined(); + }); + + it("returns the same exports object for repeated requires", function () { + var first = require("~/esm/interop/identity.mjs"); + var second = require("~/esm/interop/identity.mjs"); + expect(first).toBe(second); + expect(first.__esModule).toBe(true); + }); + + it("re-exports the very same default the namespace holds", function (done) { + var required = require("~/esm/interop/agreement.mjs"); + import("~/esm/interop/agreement.mjs").then(function (ns) { + expect(required.default).toBe(ns.default); + expect(required.named).toBe(ns.named); + // The facade is a distinct namespace: only it carries __esModule. + expect(required).not.toBe(ns); + expect(ns.__esModule).toBeUndefined(); + done(); + }).catch(function (e) { + expect("rejected: " + String((e && e.message) || e)).toBe("resolved"); + done(); + }); + }); + + // import() keeps observing the raw namespace whichever side loaded first. + it("still gives import() the raw namespace after a require()", function (done) { + import("~/esm/interop/default-live.mjs").then(function (ns) { + expect(ns.__esModule).toBeUndefined(); + expect(typeof ns.bump).toBe("function"); + expect(require("~/esm/interop/default-live.mjs").default).toBe(ns.default); + done(); + }).catch(function (e) { + expect("rejected: " + String((e && e.message) || e)).toBe("resolved"); + done(); + }); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testImportMetaResolution.js b/test-app/app/src/main/assets/app/tests/testImportMetaResolution.js new file mode 100644 index 000000000..4d9e6850f --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testImportMetaResolution.js @@ -0,0 +1,38 @@ +// `import.meta` is populated by identifying the module in the loader registry, +// so each module in a graph must get its own — not the entry's, and not the +// importer's. +describe("import.meta resolution", function () { + function loadGraph() { + return import("~/esm/meta/parent.mjs"); + } + + function rejected(done) { + return function (error) { + expect("rejected: " + String((error && error.message) || error)).toBe("resolved"); + done(); + }; + } + + it("gives every module in a graph its own url and dirname", function (done) { + loadGraph().then(function (graph) { + var parent = graph.parentMeta; + var child = graph.childMeta; + + expect(child).not.toBe(parent); + // `dirname` is a filesystem path, `url` is a file: URL over it. + expect(parent.url).toBe("file://" + parent.dirname + "/parent.mjs"); + expect(child.url).toBe("file://" + child.dirname + "/child.mjs"); + expect(child.dirname).toBe(parent.dirname + "/nested"); + done(); + }, rejected(done)); + }); + + it("returns the identical import.meta object on a repeated import", function (done) { + Promise.all([loadGraph(), loadGraph()]).then(function (results) { + expect(results[1]).toBe(results[0]); + expect(results[1].parentMeta).toBe(results[0].parentMeta); + expect(results[1].childMeta).toBe(results[0].childMeta); + done(); + }, rejected(done)); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testNodeUrlModule.js b/test-app/app/src/main/assets/app/tests/testNodeUrlModule.js new file mode 100644 index 000000000..b41081c0d --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testNodeUrlModule.js @@ -0,0 +1,172 @@ +describe("node:url", function () { + function capture(fn) { + try { + fn(); + } catch (e) { + return e; + } + return null; + } + + it("resolves to one frozen object through both require and import", function (done) { + var required = require("node:url"); + + expect(Object.isFrozen(required)).toBe(true); + expect(Object.keys(required).sort()).toEqual(["fileURLToPath", "pathToFileURL"]); + // The shim converts between paths and file URLs; it is not a place to + // reach the URL intrinsic from. + expect(required.URL).toBeUndefined(); + expect(require("node:url")).toBe(required); + + Promise.all([import("node:url"), import("node:url")]).then(function (results) { + expect(results[1]).toBe(results[0]); + expect(results[0].default).toBe(required); + expect(results[0].fileURLToPath).toBe(required.fileURLToPath); + expect(results[0].pathToFileURL).toBe(required.pathToFileURL); + done(); + }).catch(function (error) { + expect("rejected: " + String((error && error.message) || error)).toBe("resolved"); + done(); + }); + }); + + it("converts file URLs to paths the way Node does", function () { + var fileURLToPath = require("node:url").fileURLToPath; + + expect(fileURLToPath("file:///foo/bar.txt")).toBe("/foo/bar.txt"); + expect(fileURLToPath(new URL("file:///foo/bar.txt"))).toBe("/foo/bar.txt"); + // The URL spec folds a "localhost" authority to no host at all. + expect(fileURLToPath("file://localhost/foo/bar.txt")).toBe("/foo/bar.txt"); + // Query and fragment are URL syntax, never part of the path. + expect(fileURLToPath("file:///foo/bar.txt?x=1#frag")).toBe("/foo/bar.txt"); + expect(fileURLToPath("file:///foo/a%20b.txt")).toBe("/foo/a b.txt"); + expect(fileURLToPath("file:///foo/100%25.txt")).toBe("/foo/100%.txt"); + }); + + it("rejects file URLs it cannot honestly convert", function () { + var fileURLToPath = require("node:url").fileURLToPath; + + var wrongScheme = capture(function () { fileURLToPath("http://example.com/x.js"); }); + expect(wrongScheme instanceof TypeError).toBe(true); + expect(wrongScheme.message).toBe("The URL must be of scheme file"); + + var remoteHost = capture(function () { fileURLToPath("file://otherhost/foo.txt"); }); + expect(remoteHost instanceof TypeError).toBe(true); + expect(remoteHost.message).toBe('File URL host must be "localhost" or empty'); + + // %2F would decode into a separator and change the path's shape. + var encodedSlash = capture(function () { fileURLToPath("file:///foo%2Fbar.txt"); }); + expect(encodedSlash instanceof TypeError).toBe(true); + expect(encodedSlash.message).toBe("File URL path must not include encoded / characters"); + + var expectedArgMessage = + 'The "path" argument must be of type string or an instance of URL.'; + var notAString = capture(function () { fileURLToPath(42); }); + expect(notAString instanceof TypeError).toBe(true); + expect(notAString.message).toBe(expectedArgMessage); + + var notAUrl = capture(function () { fileURLToPath("not a url"); }); + expect(notAUrl instanceof TypeError).toBe(true); + expect(notAUrl.message).toBe(expectedArgMessage); + }); + + it("converts paths to file URLs and round-trips them", function () { + var nodeUrl = require("node:url"); + var url = nodeUrl.pathToFileURL("/foo/bar.txt"); + + expect(url instanceof URL).toBe(true); + expect(url.protocol).toBe("file:"); + expect(url.pathname).toBe("/foo/bar.txt"); + + // The characters that would otherwise be read as URL syntax. + var paths = ["/foo/bar.txt", "/foo/a b.txt", "/foo/100%.txt", + "/foo/q?x.txt", "/foo/h#x.txt", "/foo/dir/"]; + for (var i = 0; i < paths.length; i++) { + expect(nodeUrl.fileURLToPath(nodeUrl.pathToFileURL(paths[i]))).toBe(paths[i]); + } + + var notAString = capture(function () { nodeUrl.pathToFileURL(42); }); + expect(notAString instanceof TypeError).toBe(true); + expect(notAString.message).toBe('The "path" argument must be of type string.'); + + // No process working directory here, so a relative path has no answer. + var relative = capture(function () { nodeUrl.pathToFileURL("foo/bar.txt"); }); + expect(relative instanceof TypeError).toBe(true); + expect(relative.message).toBe('The "path" argument must be an absolute path.'); + }); +}); + +describe("node:module", function () { + it("exposes exactly createRequire, frozen", function () { + var nodeModule = require("node:module"); + + expect(Object.isFrozen(nodeModule)).toBe(true); + expect(Object.keys(nodeModule)).toEqual(["createRequire"]); + expect(typeof nodeModule.createRequire).toBe("function"); + expect(require("node:module")).toBe(nodeModule); + }); + + it("is a distinct module object from ns:module sharing one createRequire", function () { + var nodeModule = require("node:module"); + var nsModule = require("ns:module"); + + expect(nodeModule).not.toBe(nsModule); + expect(nodeModule.createRequire).toBe(nsModule.createRequire); + }); + + it("omits createPumpingRequire, which has no Node counterpart", function () { + expect(require("node:module").createPumpingRequire).toBeUndefined(); + expect(typeof require("ns:module").createPumpingRequire).toBe("function"); + }); + + it("resolves to the same object through dynamic import", function (done) { + var nodeModule = require("node:module"); + import("node:module").then(function (ns) { + expect(ns.default).toBe(nodeModule); + expect(ns.createRequire).toBe(nodeModule.createRequire); + done(); + }).catch(function (error) { + expect("rejected: " + String((error && error.message) || error)).toBe("resolved"); + done(); + }); + }); +}); + +// node:path had an in-resolver polyfill once; it is not a registered builtin, +// so it must now fail exactly like any other unshimmed node: specifier. +describe("unregistered node: specifiers", function () { + var NOT_FOUND = "No such built-in module: node:path"; + + it("fails on require", function () { + var error = null; + try { + require("node:path"); + } catch (e) { + error = e; + } + expect(error instanceof Error).toBe(true); + expect(error.message).toBe(NOT_FOUND); + }); + + it("fails on dynamic import", function (done) { + import("node:path").then(function () { + expect("resolved").toBe("rejected with " + NOT_FOUND); + done(); + }, function (error) { + expect(error instanceof Error).toBe(true); + expect(error.message).toBe(NOT_FOUND); + done(); + }); + }); + + it("fails on a static import from a module", function (done) { + import("~/esm/nodebuiltins/importsNodePath.mjs").then(function () { + expect("resolved").toBe("rejected with " + NOT_FOUND); + done(); + }, function (error) { + // The instantiation failure wraps the resolver's message. + expect(String((error && error.message) || error)).toContain(NOT_FOUND); + done(); + }); + }); +}); diff --git a/test-app/app/src/main/assets/app/tests/testNsModule.js b/test-app/app/src/main/assets/app/tests/testNsModule.js index c5a5d70e8..7d4d75866 100644 --- a/test-app/app/src/main/assets/app/tests/testNsModule.js +++ b/test-app/app/src/main/assets/app/tests/testNsModule.js @@ -29,7 +29,9 @@ describe("ns:module", function () { expect(ns.configureLoader).toBe(nsModule.configureLoader); done(); }).catch(function (error) { - fail("import('ns:module') rejected: " + error.message); + // fail() throws in this Jasmine, which inside a promise chain + // surfaces as an opaque timeout instead of the real reason. + expect("rejected: " + String((error && error.message) || error)).toBe("resolved"); done(); }); }); @@ -66,44 +68,70 @@ describe("HTTP canonical key (ns:module canonicalizeHttpUrlKey)", function () { expect(typeof canon).toBe("function"); }); - // Unconfigured, canonicalization is purely mechanical: the fragment goes - // and the query stays. Which params are cache-busters and which paths are - // dev endpoints is client vocabulary the runtime no longer guesses. - it("unconfigured: strips the fragment and nothing else", function () { - checkKey("http://h/app/foo.js#frag", "http://h/app/foo.js"); - checkKey("http://h/app/foo.js?t=123&v=9#frag", "http://h/app/foo.js?t=123&v=9"); - }); + // Unconfigured, the runtime knows no client vocabulary: it strips the + // fragment and nothing else. Which params are cache-busters and which + // paths are dev endpoints arrives through configureLoader. + describe("unconfigured (mechanical only)", function () { + it("keeps every query param, cache-buster-looking or not", function () { + checkKey("http://h/app/core?p=x&t=123&v=9&import=1", + "http://h/app/core?p=x&t=123&v=9&import=1"); + }); - it("unconfigured: leaves every query param in the key", function () { - checkKey("http://h/app/foo.js?t=123&v=9&import=1", "http://h/app/foo.js?t=123&v=9&import=1"); - checkKey("https://cdn.example.com/lib.js?token=abc", "https://cdn.example.com/lib.js?token=abc"); - }); + it("leaves public URLs untouched", function () { + checkKey("https://cdn.example.com/lib.js?token=abc", + "https://cdn.example.com/lib.js?token=abc"); + }); + + it("treats module identity as literally the URL — no path-tag collapses", function () { + checkKey("http://h/app/m/foo.js", "http://h/app/m/foo.js"); + checkKey("http://h/app/rt", "http://h/app/rt"); + }); - it("treats module identity as literally the URL — no path-tag collapses", function () { - checkKey("http://h/app/m/foo.js", "http://h/app/m/foo.js"); - checkKey("http://h/app/rt", "http://h/app/rt"); + it("still drops the fragment", function () { + checkKey("http://h/app/m/foo.js#frag", "http://h/app/m/foo.js"); + checkKey("https://cdn.example.com/lib.js?token=abc#frag", + "https://cdn.example.com/lib.js?token=abc"); + }); }); - it("honors a client-supplied canonicalization vocabulary via configureLoader", function () { - var canon = getCanon(); - if (typeof canon !== "function") { - pending("ns:module.canonicalizeHttpUrlKey not exposed (release build)"); - return; - } - // Neutral vocabulary: the mechanics under test are the runtime's, the - // strings are the client's to choose. - require("ns:module").configureLoader({ - canonicalization: { - stripParams: ["cachebust", "rev"], - forPathPrefixes: ["/dev/"], - preserveQueryFor: ["/dev/metadata"], - }, + // The canonicalization vocabulary is per-isolate loader state, so each spec + // installs it and restores the unconfigured shape afterwards. (Jasmine + // 2.0.1 has no beforeAll/afterAll.) + describe("with a client-supplied vocabulary", function () { + beforeEach(function () { + if (typeof getCanon() !== "function") { + return; + } + require("ns:module").configureLoader({ + canonicalization: { + stripParams: ["t", "v", "import"], + forPathPrefixes: ["/dev/"], + preserveQueryFor: ["/dev/metadata"], + }, + }); + }); + + afterEach(function () { + if (typeof getCanon() !== "function") { + return; + } + require("ns:module").configureLoader({ + canonicalization: { stripParams: [], forPathPrefixes: [], preserveQueryFor: [] }, + }); + }); + + it("strips the configured cache-busters under a configured prefix", function () { + checkKey("http://h/dev/core?p=x&t=123&v=9&import=1", "http://h/dev/core?p=x"); + }); + + it("lets preserveQueryFor win under a configured prefix", function () { + checkKey("http://h/dev/metadata?c=a&t=42", "http://h/dev/metadata?c=a&t=42"); + }); + + it("leaves paths outside the configured prefixes alone", function () { + checkKey("http://h/app/core?p=x&t=123", "http://h/app/core?p=x&t=123"); + checkKey("https://cdn.example.com/lib.js?token=abc", + "https://cdn.example.com/lib.js?token=abc"); }); - // Under a configured dev prefix, the named params drop and the rest sort. - expect(canon("http://h/dev/core?p=x&cachebust=123&rev=9")).toBe("http://h/dev/core?p=x"); - // preserveQueryFor wins over the dev prefix: the query IS the identity. - expect(canon("http://h/dev/metadata?c=a&cachebust=42")).toBe("http://h/dev/metadata?c=a&cachebust=42"); - // Outside every configured prefix, the query is untouched. - expect(canon("https://cdn.example.com/lib.js?cachebust=abc")).toBe("https://cdn.example.com/lib.js?cachebust=abc"); }); }); diff --git a/test-app/app/src/main/assets/app/tests/testNsRuntime.js b/test-app/app/src/main/assets/app/tests/testNsRuntime.js index dd9984815..f57d1c5bd 100644 --- a/test-app/app/src/main/assets/app/tests/testNsRuntime.js +++ b/test-app/app/src/main/assets/app/tests/testNsRuntime.js @@ -7,6 +7,8 @@ describe("ns:runtime", function () { expect(typeof runtime.getConfig).toBe("function"); }); + // The export set is public API, declared alongside docs/ns-builtin-modules.md + // — all of them change together. it("exposes exactly the declared surface", function () { expect(Object.keys(runtime).sort()).toEqual(["getConfig", "setConfig"]); }); @@ -20,32 +22,66 @@ describe("ns:runtime", function () { }).toThrowError(TypeError, /Unknown runtime config key/); }); - it("defaults logScriptLoading and httpFetchUrlLog from app config", function () { - expect(runtime.getConfig("logScriptLoading")).toBe(false); - expect(runtime.getConfig("httpFetchUrlLog")).toBe(false); + it("is a singleton across require calls", function () { + expect(require("ns:runtime")).toBe(runtime); }); - it("round-trips logScriptLoading and httpFetchUrlLog", function () { - runtime.setConfig("logScriptLoading", true); - expect(runtime.getConfig("logScriptLoading")).toBe(true); - runtime.setConfig("logScriptLoading", false); - expect(runtime.getConfig("logScriptLoading")).toBe(false); + describe("debug categories", function () { + afterEach(function () { + runtime.setConfig("debug", ""); + }); + + it("starts disabled", function () { + expect(runtime.getConfig("debug")).toBe(""); + }); + + it("round-trips a category list canonically", function () { + runtime.setConfig("debug", "esm,fetch"); + expect(runtime.getConfig("debug")).toBe("esm,fetch"); + }); + + it("canonicalizes order and whitespace", function () { + runtime.setConfig("debug", " fetch , esm "); + expect(runtime.getConfig("debug")).toBe("esm,fetch"); + }); + + it("replaces the whole set rather than adding to it", function () { + runtime.setConfig("debug", "esm,fetch"); + runtime.setConfig("debug", "registry"); + expect(runtime.getConfig("debug")).toBe("registry"); + }); - runtime.setConfig("httpFetchUrlLog", true); - expect(runtime.getConfig("httpFetchUrlLog")).toBe(true); - runtime.setConfig("httpFetchUrlLog", false); - expect(runtime.getConfig("httpFetchUrlLog")).toBe(false); + it("ignores unknown categories but keeps the known ones", function () { + runtime.setConfig("debug", "esm,nosuchcategory"); + expect(runtime.getConfig("debug")).toBe("esm"); + }); + + it("accepts every declared category", function () { + runtime.setConfig("debug", "esm,fetch,registry"); + expect(runtime.getConfig("debug")).toBe("esm,fetch,registry"); + }); + + it("disables everything on an empty string", function () { + runtime.setConfig("debug", "esm,fetch,registry"); + runtime.setConfig("debug", ""); + expect(runtime.getConfig("debug")).toBe(""); + }); + + it("rejects a non-string value and keeps the current set", function () { + runtime.setConfig("debug", "esm"); + expect(function () { + runtime.setConfig("debug", true); + }).toThrowError(TypeError, /comma-separated category string/); + expect(runtime.getConfig("debug")).toBe("esm"); + }); }); - it("rejects non-boolean log flag values and keeps the current one", function () { - expect(function () { - runtime.setConfig("logScriptLoading", "yes"); - }).toThrowError(TypeError, /must be a boolean/); - expect(runtime.getConfig("logScriptLoading")).toBe(false); - expect(function () { - runtime.setConfig("httpFetchUrlLog", 1); - }).toThrowError(TypeError, /must be a boolean/); - expect(runtime.getConfig("httpFetchUrlLog")).toBe(false); + it("no longer registers the removed log flags", function () { + ["logScriptLoading", "httpFetchUrlLog"].forEach(function (key) { + expect(function () { + runtime.getConfig(key); + }).toThrowError(TypeError, /Unknown runtime config key/); + }); }); it("does not expose remote-module security through getConfig or setConfig", function () { @@ -59,7 +95,9 @@ describe("ns:runtime", function () { }); }); - it("does not expose releasedObjectPolicy (iOS-only)", function () { + // releasedObjectPolicy is an iOS-only key; the GC teardown policy it names + // has no Android counterpart. + it("does not expose releasedObjectPolicy", function () { expect(function () { runtime.getConfig("releasedObjectPolicy"); }).toThrowError(TypeError, /Unknown runtime config key/); diff --git a/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js b/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js index 62d9153a6..a53474d9b 100644 --- a/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js +++ b/test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js @@ -38,8 +38,11 @@ describe("Remote Module Security", function() { }); it("should allow HTTPS module imports in debug mode", function(done) { - // Test HTTPS URL - should be allowed in debug mode - import("https://192.0.2.1:5173/test-module.js").then(function(module) { + // A closed loopback port, not an unroutable host: HTTPS bypasses the + // cleartext policy that makes the plain-HTTP cases fail instantly, so + // an unroutable address would burn the transport's 15s connect + // timeout twice (once per retry) and blow the spec timeout. + import("https://127.0.0.1:1/test-module.js").then(function(module) { expect(module).toBeDefined(); done(); }).catch(function(error) { @@ -55,30 +58,13 @@ describe("Remote Module Security", function() { describe("Security Configuration", function() { it("should have security configuration in package.json", function() { - var context = com.tns.Runtime.getCurrentRuntime().getContext(); - var assetManager = context.getAssets(); - - try { - var inputStream = assetManager.open("app/package.json"); - var reader = new java.io.BufferedReader(new java.io.InputStreamReader(inputStream)); - var sb = new java.lang.StringBuilder(); - var line; - - while ((line = reader.readLine()) !== null) { - sb.append(line); - } - reader.close(); - - var jsonString = sb.toString(); - var config = JSON.parse(jsonString); - - // Verify security config structure - expect(config.security).toBeDefined(); - expect(typeof config.security.allowRemoteModules).toBe("boolean"); - expect(Array.isArray(config.security.remoteModuleAllowlist)).toBe(true); - } catch (e) { - fail("Failed to read package.json: " + e.message); - } + // require() of a .json goes through the loader's own JSON route, so + // this reads the same file the native security gate was seeded from. + var config = require("~/package.json"); + + expect(config.security).toBeDefined(); + expect(typeof config.security.allowRemoteModules).toBe("boolean"); + expect(Array.isArray(config.security.remoteModuleAllowlist)).toBe(true); }); it("should parse security allowRemoteModules from package.json", function() { @@ -88,9 +74,11 @@ describe("Remote Module Security", function() { }); it("should parse security remoteModuleAllowlist from package.json", function() { + // A Java String[], not a JS Array — it indexes and reports a length + // but fails Array.isArray. var allowlist = com.tns.Runtime.getSecurityRemoteModuleAllowlist(); expect(allowlist).not.toBeNull(); - expect(Array.isArray(allowlist)).toBe(true); + expect(typeof allowlist.length).toBe("number"); expect(allowlist.length).toBeGreaterThan(0); // Verify our test allowlist entries are present @@ -177,7 +165,8 @@ describe("Remote Module Security", function() { // Test dynamic imports (ImportModuleDynamicallyCallback path) it("should attempt to load HTTPS module dynamically in debug mode", function(done) { - var url = "https://10.255.255.1:5173/dynamic-module.js"; + // Closed loopback port — see the HTTPS note above. + var url = "https://127.0.0.1:1/dynamic-module.js"; import(url).then(function(module) { expect(module).toBeDefined(); diff --git a/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js b/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js new file mode 100644 index 000000000..ca8e3d82f --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js @@ -0,0 +1,129 @@ +// An ES module worker entry takes the same RunModule branch — and the same +// boot evaluation options — the app's main entry takes, so these pin that +// destination even though the suite cannot re-drive the app's own boot. +// The `.mjs` entries are spawned app-root-absolute on purpose: a relative +// worker path is resolved against the caller's directory only on the CommonJS +// route, so an ES module entry needs a path that already stands on its own. +describe("worker ES module entries", function () { + var originalTimeout; + + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; + }); + + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + }); + + it("runs a synchronous ES module worker entry, statics and all", function (done) { + var worker = new Worker("~/tests/esmEntrySyncWorker.mjs"); + worker.onmessage = function (msg) { + expect(msg.data).toBe("esm-entry:ping"); + worker.terminate(); + done(); + }; + worker.postMessage("ping"); + }); + + it("runs an ES module worker entry whose top-level await parks past the yield window", + function (done) { + // The park is non-nestable, so the in-place window cannot settle it: + // the entry finishes from the real event loop afterwards, and the + // message queue enables on settle rather than being lost. + var worker = new Worker("~/tests/esmEntryTlaWorker.mjs"); + worker.onmessage = function (msg) { + expect(msg.data).toBe("tla-entry:ok:ping"); + worker.terminate(); + done(); + }; + worker.postMessage("ping"); + }); + + // WHATWG parity: the worker's message queue is enabled when its entry + // script finishes evaluating, and from then on messages dispatch whether + // or not a handler exists. A handler registered later (from a timer) + // misses messages delivered in between — exactly as on the web. + it("drops messages dispatched before a late-registered onmessage, like the web", function (done) { + var worker = new Worker("./lateHandlerWorker.js"); + var received = []; + worker.onmessage = function (msg) { + received.push(msg.data); + if (msg.data === "ready") { + worker.postMessage("second"); + } else { + expect(received).toEqual(["ready", "late:second"]); + worker.terminate(); + done(); + } + }; + // Posted before the entry finishes evaluating: buffered, then + // dispatched into a global with no handler yet — dropped. + worker.postMessage("early"); + }); +}); + +// A worker inherits a copy of its parent's loader vocabulary, taken on the +// parent's thread as the worker is constructed and installed before the worker +// loads any module. +describe("worker loader-vocabulary inheritance", function () { + var originalTimeout; + + function setLeaf(target) { + require("ns:module").configureLoader({ + importMap: { imports: { "ns-worker-leaf": target } }, + }); + } + + beforeEach(function () { + originalTimeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; + }); + + afterEach(function () { + jasmine.DEFAULT_TIMEOUT_INTERVAL = originalTimeout; + // The map is isolate-wide state, so it must not outlive this describe. + require("ns:module").configureLoader({ importMap: { imports: {} } }); + }); + + it("gives a worker spawned after configureLoader the parent's map", function (done) { + setLeaf("~/esm/vocab/leafA.mjs"); + + var worker = new Worker("./importMapWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data.ok ? "resolved" : "failed: " + msg.data.error).toBe("resolved"); + expect(msg.data.name).toBe("vocab-a"); + worker.terminate(); + done(); + }; + worker.postMessage("ns-worker-leaf"); + }); + + it("leaves a running worker on the map it was spawned with", function (done) { + setLeaf("~/esm/vocab/leafB.mjs"); + + var worker = new Worker("./importMapWorker.js"); + worker.onmessage = function (msg) { + expect(msg.data.ok ? "resolved" : "failed: " + msg.data.error).toBe("resolved"); + expect(msg.data.name).toBe("vocab-b"); + worker.terminate(); + done(); + }; + + // Reconfigure the parent only after the worker exists, then ask it to + // resolve: the worker answers from the copy taken at its spawn. + setLeaf("~/esm/vocab/leafC.mjs"); + worker.postMessage("ns-worker-leaf"); + }); + + it("still applies a later configureLoader on the parent's own isolate", function (done) { + setLeaf("~/esm/vocab/leafC.mjs"); + import("ns-worker-leaf").then(function (mod) { + expect(mod.name).toBe("vocab-c"); + done(); + }).catch(function (error) { + expect("rejected: " + String((error && error.message) || error)).toBe("resolved"); + done(); + }); + }); +}); diff --git a/test-app/app/src/main/res/xml/network_security_config.xml b/test-app/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..ad81d9e18 --- /dev/null +++ b/test-app/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,13 @@ + + + + + 127.0.0.1 + localhost + + From e3ab969604070f20ffdf0ca95c4ff1a1b73a216c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 15:19:04 -0300 Subject: [PATCH 29/36] docs: sync ns-builtin-modules.md with the loader overhaul The full cross-runtime contract: createRequire/createPumpingRequire, import maps with scopes and atomic installation, per-isolate vocabulary with worker copy-at-spawn, the node:url and node:module shims, the debug trace categories mapped to their logcat tags, ES-module app entries and the boot backstop's fatal strings, and Android implementation notes replacing the stale pre-overhaul ones. setDevBootComplete and the node: polyfill notes are gone with the mechanisms they described. --- docs/ns-builtin-modules.md | 206 +++++++++++++++++++++++++++---------- 1 file changed, 154 insertions(+), 52 deletions(-) diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index e60ad64ef..7a91e16ac 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -74,8 +74,8 @@ values are read once from nativescript.config / package.json the first time the HTTP loader gates a fetch, and they cannot be inspected or changed through `getConfig` / `setConfig`. -iOS additionally registers `releasedObjectPolicy`; Android does not (it has -no released-native-counterpart machinery). +iOS additionally registers `releasedObjectPolicy`; Android does not, because it +has no released-native-counterpart machinery for that key to govern. `debug` turns on the runtime's category-scoped trace logs. Categories: @@ -93,23 +93,120 @@ are ignored, with one warning line naming the valid ones. The same list can be given before boot as the `NS_DEBUG` environment variable (`NS_DEBUG=esm,fetch`), which is the only way to trace boot itself. Traces are compiled into release builds as well: a release build that cannot be traced is -a release build that cannot be diagnosed. Each category writes to its own -logcat tag (`TNS.esm`, `TNS.fetch`, `TNS.registry`), so `adb logcat -s TNS.esm` -filters them without matching message text. +a release build that cannot be diagnosed. Each category writes under its own +logcat tag — `TNS.esm`, `TNS.fetch`, `TNS.registry` — so `adb logcat -s TNS.esm` +can filter them without matching message text. ### `ns:module` (v1) The module-loader control surface consumed by development tooling (`@nativescript/vite`). Mechanism only: every policy concern (boot orchestration, `import.meta.hot`, full reload, CSS apply, worker teardown, -WebSocket protocol) lives in the tooling. +WebSocket protocol) lives in the tooling. See `HMR_RUNTIME_BOUNDARY.md` for +the full contract rationale. | export | description | |---|---| -| `configureLoader(config)` | Install loader policy before the session imports anything: `importMap` (bare specifier → URL, consulted inside the synchronous resolver), `volatilePatterns` (URL substrings always re-fetched), `canonicalization` (`stripParams`/`forPathPrefixes`/`preserveQueryFor` vocabulary for registry keying). Each present section replaces its state wholesale. | +| `configureLoader(config)` | Install loader policy before the session imports anything: `importMap` (`imports` + `scopes`, consulted inside the synchronous resolver — see below), `volatilePatterns` (URL substrings always re-fetched), `canonicalization` (`stripParams`/`forPathPrefixes`/`preserveQueryFor` vocabulary for registry keying). Each present section replaces its state wholesale. An invalid `importMap` throws a `TypeError` and leaves the previously installed map untouched. **Configures the calling isolate.** A worker inherits a copy of its parent's vocabulary taken at spawn, so a worker started after `configureLoader` resolves through it; a worker already running does **not** see a later reconfiguration — the dev client restarts workers when the vocabulary changes. | | `invalidateModules(urls)` | Evict the given URLs (canonicalized) from the module registry and mark them bust-next-fetch, so the next network fetch bypasses every HTTP cache layer. | | `getLoadedModuleUrls()` | URL-like keys currently in the module registry (used to compute full-reload eviction sets). | -| `setDevBootComplete(value?)` | Flip the dev-boot-complete signal (defaults to `true`); disarms cold-boot-only behaviors. | +| `createRequire(filenameOrURL)` | A `require` resolving against `filenameOrURL`'s directory (a trailing slash names the directory itself). Accepts an absolute path string, a `file:` URL string, or a URL object; anything else throws a `TypeError`, and an `http(s)` base is refused outright because `require()` of a dev-served module is not supported — import those. ES module graphs load under Node's `require(esm)` rule: a graph containing top-level await is refused before it evaluates. | +| `createPumpingRequire(filenameOrURL, options?)` | Same argument contract and same resolution, but an ES module graph with top-level await is evaluated by driving V8's nestable tasks and microtasks until it settles, instead of being refused. **Callable only from a task context** — see below. `options` (validated at mint time; unknown keys throw `TypeError`): `deadlineSeconds` (positive finite, default 60), `onTimeout` (`"throw"` default, or `"return-pending"`), `pumpRunLoop` (default `false`). They govern the evaluation-settle phase only — the graph walk's fetch deadline is separate. Passing `options` to `createRequire` throws. | + +`createPumpingRequire` pumps the loop, and the loop cannot be pumped +re-entrantly: V8 ignores a microtask checkpoint while the isolate is already +draining the microtask queue. A top-level await resumes through a promise +reaction — a microtask — so such a graph can never settle from inside a +microtask turn. Requiring one from after an `await` or inside a `.then` +callback therefore throws immediately, before evaluation, leaving the graph +instantiated so `import()` can still load it. Call it from a task context +instead — a native boundary, an event handler, a timer callback, or module +evaluation itself. A **synchronous** graph needs no pumping and stays legal +from anywhere, microtask turns included. + +### Import maps and scopes + +`importMap` takes the WHATWG shape: + +```js +configureLoader({ + importMap: { + imports: { "lodash": "http://host/vendor/lodash.mjs", "@scope/pkg/": "http://host/pkg/" }, + scopes: { "http://host/legacy/": { "lodash": "http://host/vendor/lodash-3.mjs" } }, + }, +}); +``` + +Within any one section, a specifier matches exactly first, then against the +longest trailing-slash key, whose remainder is appended to the target. A key +ending in `/` must have a target ending in `/`. + +A **scope key is matched as a plain prefix of the importing module's canonical +registry key** — an absolute `http(s)` URL for a served module, or a canonical +absolute path for a file on disk. That key is this runtime's analogue of the +web's resolved referrer URL, which is what scope prefixes match in a browser. +End a scope key with `/` to keep it on a directory boundary. Resolution +consults the most specific matching scope first, then progressively less +specific ones, then `imports` — so a scope can override a global mapping for +one subtree and fall through to it everywhere else. The resolver, the graph +walk, and `import()` all resolve through the same cascade. + +The vocabulary is per-isolate: `configureLoader` writes the isolate that +calls it, and nothing is shared between isolates, so no lock guards it. A +worker receives a copy captured on the parent's thread while it spawns and +installed before the worker loads its first module. That copy is a snapshot — +reconfiguring the parent afterwards leaves running workers on the vocabulary +they started with, which is why the dev client restarts workers on an update. + +The whole map is parsed and validated before any of it is installed. A +malformed map, a non-string target, an unknown top-level section, or a +trailing-slash key with a non-trailing-slash target throws a `TypeError` +naming the offending key or section, and the previously installed map keeps +resolving — a typo in an update cannot empty a live session's vocabulary. + +#### Booting an ESM entry from a CJS bootstrap + +The supported way to give an ESM app its loader vocabulary before any ESM +traffic — which closes the pre-configure window described in the +canonicalization notes — is a small CommonJS bootstrap as the app entry: + +```js +const { configureLoader, createPumpingRequire } = require("ns:module"); + +configureLoader({ importMap: { imports: { /* … */ } } }); + +createPumpingRequire(__filename, { + pumpRunLoop: true, + onTimeout: "return-pending", + deadlineSeconds: 1, +})("./entry.mjs"); +``` + +Two warnings, both load-bearing: + +- `pumpRunLoop: true` is sane **only while boot owns the looper**. After boot + the looper belongs to the app, and slicing it from inside a require + re-enters arbitrary looper sources — including UI callbacks — underneath JS + frames. +- With `onTimeout: "return-pending"` the returned namespace may still be + evaluating. A bootstrap must **discard it** and never read a binding off it; + reading one is a TDZ error at best. + +The bootstrap is not the only option: **an ES module main entry is supported +directly**, top-level await included. When the app's `main` resolves to a +`.mjs`, the entry is evaluated as a module rather than `require()`d — so +`import`/`export` are legal there — under the boot evaluation options: a one +second in-place yield that never throws, after which the boot backstop holds +the process while the entry's evaluation promise is still pending, bounded at +twice the module deadline. The trade-off is that the entry's own static imports +resolve *before* its body runs, so anything the entry needs `configureLoader` +to have configured must be reached through a dynamic `import()` after that +call. The CommonJS bootstrap above avoids that constraint by being synchronous; +pick whichever fits the app. + +Not implemented on either require: `require.resolve`, `require.cache`, and +`require.main`. They are absent rather than throwing, so a feature check +works; adding them is a spec change here first. Debug builds additionally carry `canonicalizeHttpUrlKey(url)`, a pure test diagnostic; release builds omit it. Missing members are simply absent — @@ -161,6 +258,8 @@ unmodified where a shim exists: | module | exports | notes | |---|---|---| | `node:util` | `inspect`, `format` | Re-exports `ns:util`'s members unchanged (`nodeUtil.inspect === nsUtil.inspect`) from a **distinct, separately frozen module object**. Documented as partial. | +| `node:url` | `fileURLToPath`, `pathToFileURL` | Node-strict converters between `file:` URLs and paths. Parsing goes through the URL intrinsic, so `file://localhost/x` is accepted (the URL spec folds a `localhost` authority to none) while any other host throws, and the query and fragment are not part of the path. `fileURLToPath` rejects a non-`file:` scheme and rejects `%2F` in the path rather than decoding a separator into it. `pathToFileURL` returns a real `URL` and requires an **absolute** path: Node resolves a relative one against the process working directory, and there is no such thing here. Documented as partial — no `URL`/`URLSearchParams` re-exports (both are globals), no legacy `url.parse`/`format`/`resolve`. | +| `node:module` | `createRequire` | Re-exports `ns:module`'s `createRequire` unchanged from a **distinct, separately frozen module object**. `createPumpingRequire` is deliberately absent: it has no Node counterpart, so code written against this shim keeps running on Node. `require.resolve`/`.cache`/`.main` are not implemented, and neither is any other `node:module` member (`Module`, `builtinModules`, `isBuiltin`, `register`, `syncBuiltinESMExports`). Documented as partial. | Candidates for future shims, in rough order of ecosystem demand: `node:events` (EventEmitter), `node:path` (pure JS), `node:buffer`, @@ -203,50 +302,53 @@ builtin modules (Node's `kSourceTextModule`) are justified only by a concrete need for live module semantics (TLA, live bindings, cyclic imports), which no current or planned builtin has. Revisit here before building either. -## iOS implementation notes (non-normative) - -Builtin modules are function-body builtins (`NativeScript/runtime/js/`, -see the README there) compiled via the RuntimeBuiltins table. The `ns:` -resolver intercepts specifiers in the CommonJS require path and in the ES -module resolve/dynamic-import callbacks; ESM consumption is served by a -synthetic module whose exports are populated from the same per-realm exports -object. The internal require is a fixed parameter of the builtin function -wrapper (`exports`, `require`, `module`, `binding`, `primordials`). - -iOS also keeps a pre-registry `node:url` polyfill (`fileURLToPath`, -`pathToFileURL`) that predates this document. It is compiled from module -source inside the ES module resolver and is therefore reachable through -`import` only, not through `require()`. - ## Android implementation notes (non-normative) Builtin modules are function-body builtins -(`test-app/runtime/src/main/cpp/js/`, see the README there) compiled via the -RuntimeBuiltins table. The registry lives in -`test-app/runtime/src/main/cpp/NsBuiltinModules.{h,cpp}` and intercepts -specifiers in the CommonJS require path (`ModuleInternal::RequireCallbackImpl`) -and in the ES module resolve and dynamic-import callbacks -(`ModuleInternalCallbacks.cpp`); ESM consumption is served by a synthetic -module whose exports are populated from the same per-realm exports object. The -internal require is a fixed parameter of the builtin function wrapper -(`exports`, `require`, `module`, `binding`, `primordials`). - -Android has no `Caches` class, so every per-realm cache — exports objects, -synthetic modules, the in-progress set, the cached `format` and the builtin -`require` — lives in an isolate-keyed map released from `disposeIsolate`. -The ES module registry app modules land in (`g_moduleRegistry`) is -process-global and shared by every isolate; the builtin caches deliberately do -not use it, so workers get their own instances as the spec requires. - -Android also keeps three pre-registry `node:` polyfills that predate this -document: `node:url` (`fileURLToPath`, `pathToFileURL`), `node:module` -(`createRequire`) and `node:path` (`sep`, `delimiter`, `basename`, `dirname`, -`extname`, `join`, `resolve`, `isAbsolute`). They are compiled from module -source inside the ES module resolver and are therefore reachable through -`import` only; `require("node:path")` reaches the registry and fails with the -not-found message. - -There is deliberately no `node:fs` polyfill and no catch-all for unshimmed -`node:` names: a stub whose members throw on use violates the -absent-not-present-but-throwing rule above, and an empty-default fallback lets -an import that cannot work succeed. +(`test-app/runtime/src/main/cpp/js/`, see the README there), compiled through +`BuiltinLoader`: the first compile in the process runs eagerly and produces a +code cache, and every later realm — including every worker — consumes that +process-wide bytecode cache instead of recompiling. The registry lives in +`NsBuiltinModules.{h,cpp}` and intercepts specifiers in the CommonJS require +path (`ModuleInternal::RequireCallbackImpl`) and in the ES module resolve and +dynamic-import callbacks (`ModuleInternalCallbacks.cpp`); ESM consumption is +served by a synthetic module whose exports are populated from the same +per-realm exports object. The internal require is a fixed parameter of the +builtin function wrapper (`exports`, `require`, `module`, `binding`, +`primordials`). + +Per-realm builtin state — the exports objects, the synthetic modules, the +in-progress set, the cached `format`, the builtin `require` — and the loader's +`ModuleLoaderState` (module registry, loader vocabulary, in-flight graph loads) +live in `RuntimeState` slots rather than in isolate-keyed shared maps. A slot +is reached with an isolate data-slot read and a vector index, needs no lock, +and is destroyed with its isolate, so a worker gets its own instances as the +spec requires and teardown cannot leave a stale entry behind. + +The ES module pipeline is a three-phase module map: a graph walk starting from +the entry discovers the transitive closure and compiles + registers every +module in it, so that by `InstantiateModule` time V8's synchronous +`ResolveModuleCallback` is a pure registry lookup — compile-and-register only, +never a fetch. Discovery is scheme-agnostic and every edge goes through the +same `ResolveSpecifierToPath` the resolver uses, so both agree on a module's +registry key; only the fetch differs per scheme. `http(s)` edges are fetched +concurrently off-thread and their completions hop back to the isolate's home +thread as **nestable** V8 foreground tasks on that isolate's event loop, so +`RunNestableV8Tasks` can drain them with JS frames already on the stack. + +The boot backstop lives inside `Runtime::RunModule` (`HoldBootBackstop` in +`Runtime.cpp`). It holds the launching thread while either the entry's own +evaluation promise is still pending or async module-graph work is in flight, +pumping nestable V8 tasks, microtask checkpoints and `ALooper_pollOnce` until +both settle, bounded at twice `kModuleEvaluateDeadlineSeconds` (120s). A +settled entry simply exits the loop; only two outcomes are fatal, and both are +reported in every build: +`Fatal: the main entry module's evaluation rejected during boot: ` and +`Fatal: the main entry module '' never settled within 120s`. + +Workers copy the loader vocabulary from the parent at spawn +(`CaptureLoaderVocabulary` on the parent's thread, `InstallLoaderVocabulary` +before the worker's first module load) and, for WHATWG parity, keep the +implicit port's message queue disabled until the worker entry finishes +evaluating — including after a pending top-level await settles. Messages sent +before that stay buffered. From 671144701065ecbe5f0abde5a0ea685a64815666 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 15:24:56 -0300 Subject: [PATCH 30/36] refactor(runtime): reserve the g_ prefix for actual process globals The loader state moved into per-isolate RuntimeState slots earlier in this series, but the access sites kept g_-named local aliases so bodies read unchanged. Rename them to what they are - registry, modulesInFlight, httpDynamicWaiters - matching the iOS bindings in the counterpart functions, and drop the comment that excused the aliases. The surviving g_ identifiers are genuinely process-wide: the cache-bust set, the fetch-yield hook, the trace mask, the in-flight graph counter, the shared allocator, the error-id counter, and the crash-breadcrumb store. --- .../runtime/src/main/cpp/ModuleInternal.cpp | 16 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 141 +++++++++--------- .../runtime/src/main/cpp/NsBuiltinModules.cpp | 4 +- 3 files changed, 79 insertions(+), 82 deletions(-) diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 33d223567..0de6c5586 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -1182,10 +1182,10 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p if (registryPtr == nullptr) { return Local(); } - auto& g_moduleRegistry = *registryPtr; + auto& registry = *registryPtr; - auto existingIt = g_moduleRegistry.find(canonicalPath); - if (existingIt != g_moduleRegistry.end()) { + auto existingIt = registry.find(canonicalPath); + if (existingIt != registry.end()) { Local existing = existingIt->second.Get(isolate); Module::Status status = existing.IsEmpty() ? Module::kErrored : existing->GetStatus(); if (status == Module::kErrored) { @@ -1216,8 +1216,8 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p // call and pays no wait. RunModuleGraphLoadPumped(isolate, context, canonicalPath, kModuleEvaluateDeadlineSeconds); - auto walkedIt = g_moduleRegistry.find(canonicalPath); - if (walkedIt != g_moduleRegistry.end()) { + auto walkedIt = registry.find(canonicalPath); + if (walkedIt != registry.end()) { Local walked = walkedIt->second.Get(isolate); if (!walked.IsEmpty() && walked->GetStatus() != Module::kErrored) { module = walked; @@ -1236,11 +1236,11 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p } UnindexModuleForIsolate(isolate, canonicalPath); - auto it = g_moduleRegistry.find(canonicalPath); - if (it != g_moduleRegistry.end()) { + auto it = registry.find(canonicalPath); + if (it != registry.end()) { it->second.Reset(); } - g_moduleRegistry[canonicalPath].Reset(isolate, module); + registry[canonicalPath].Reset(isolate, module); IndexModuleForIsolate(isolate, canonicalPath, module); } } diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 38f9a168f..d986c55b3 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -615,7 +615,7 @@ static v8::MaybeLocal CompileModuleForResolveRegisterOnly( if (moduleState == nullptr) { return v8::MaybeLocal(); } - auto& g_moduleRegistry = moduleState->registry; + auto& registry = moduleState->registry; const std::string registryKey = CanonicalizeRegistryKey(urlStr); if (LogCategoryEnabled(LogCategory::Esm) && ShouldTraceRegistryKey(urlStr, registryKey)) { @@ -625,8 +625,8 @@ static v8::MaybeLocal CompileModuleForResolveRegisterOnly( // Checked before compiling: recompiling a key that is already registered // would mint a second module identity while importers hold the first. - auto itExisting = g_moduleRegistry.find(registryKey); - if (itExisting != g_moduleRegistry.end()) { + auto itExisting = registry.find(registryKey); + if (itExisting != registry.end()) { v8::Local existing = itExisting->second.Get(isolate); if (!existing.IsEmpty()) { return hs.Escape(existing); @@ -655,15 +655,12 @@ static v8::MaybeLocal CompileModuleForResolveRegisterOnly( } } UnindexRegistryKey(*moduleState, isolate, registryKey); - g_moduleRegistry[registryKey].Reset(isolate, mod); + registry[registryKey].Reset(isolate, mod); IndexRegisteredModule(*moduleState, registryKey, mod); return hs.Escape(mod); } -// Each access site binds a local reference (e.g. -// `auto& g_moduleRegistry = moduleState->registry;`) so the bodies below read -// as though the maps were plain globals. Accessors return null once teardown -// has begun. +// Returns null once teardown has begun. ModuleHandleMap* ModuleRegistryFor(v8::Isolate* isolate) { auto* state = ModuleLoaderStateFor(isolate); return state == nullptr ? nullptr : &state->registry; @@ -737,14 +734,14 @@ v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, if (moduleState == nullptr) { return v8::MaybeLocal(); } - auto& g_moduleRegistry = moduleState->registry; + auto& registry = moduleState->registry; const std::string registryKey = CanonicalizeHttpUrlKey(requestedUrl); TNS_DEBUG(Esm, "[http-esm][load][begin] request=%s key=%s", requestedUrl.c_str(), registryKey.c_str()); - auto itExisting = g_moduleRegistry.find(registryKey); - if (itExisting != g_moduleRegistry.end()) { + auto itExisting = registry.find(registryKey); + if (itExisting != registry.end()) { v8::Local existing = itExisting->second.Get(isolate); if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { TNS_DEBUG(Esm, "[http-esm][load][cache-hit] key=%s", registryKey.c_str()); @@ -1740,9 +1737,9 @@ static void AsyncGraphEnqueue(const std::shared_ptr& load, v8::Isolate* isolate = load->isolate; auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) return; - auto& g_moduleRegistry = moduleState->registry; - auto it = g_moduleRegistry.find(key); - if (it != g_moduleRegistry.end()) { + auto& registry = moduleState->registry; + auto it = registry.find(key); + if (it != registry.end()) { v8::Local existing = it->second.Get(isolate); if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { if (existing->GetStatus() == v8::Module::kUninstantiated) { @@ -1904,7 +1901,7 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { v8::Isolate* isolate = v8::Isolate::GetCurrent(); auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) return; - auto& g_moduleRegistry = moduleState->registry; + auto& registry = moduleState->registry; const std::string registryKey = CanonicalizeRegistryKey(canonicalPath); const LoaderVocabulary& vocabulary = moduleState->vocabulary; @@ -1930,10 +1927,10 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { classify(registryKey)); } - size_t regPre = g_moduleRegistry.size(); + size_t regPre = registry.size(); - auto it = g_moduleRegistry.find(registryKey); - if (it != g_moduleRegistry.end()) { + auto it = registry.find(registryKey); + if (it != registry.end()) { bool isHttpKey = StartsWith(registryKey, "http://") || StartsWith(registryKey, "https://"); if (!isHttpKey) { @@ -1941,14 +1938,14 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) { } UnindexRegistryKey(*moduleState, isolate, registryKey); it->second.Reset(); - g_moduleRegistry.erase(it); + registry.erase(it); } else { TNS_DEBUG(Esm, "[resolver][remove:miss] key not found (%s)", registryKey.c_str()); } TNS_DEBUG(Esm, "[resolver][remove:post] reg %lu->%lu", (unsigned long)regPre, - (unsigned long)g_moduleRegistry.size()); + (unsigned long)registry.size()); } std::vector GetLoadedModuleUrls() { @@ -1956,10 +1953,10 @@ std::vector GetLoadedModuleUrls() { v8::Isolate* isolate = v8::Isolate::GetCurrent(); auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) return urls; - auto& g_moduleRegistry = moduleState->registry; - urls.reserve(g_moduleRegistry.size()); + auto& registry = moduleState->registry; + urls.reserve(registry.size()); - for (const auto& entry : g_moduleRegistry) { + for (const auto& entry : registry) { const std::string& key = entry.first; if (key.empty()) continue; if (StartsWith(key, "blob:") || key.find("://") != std::string::npos) { @@ -1975,7 +1972,7 @@ void InvalidateModules(v8::Isolate* isolate, v8::Local context, const std::vector& urls) { auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) return; - auto& g_moduleRegistry = moduleState->registry; + auto& registry = moduleState->registry; if (urls.empty()) return; robin_hood::unordered_set seen; @@ -1992,7 +1989,7 @@ void InvalidateModules(v8::Isolate* isolate, v8::Local context, size_t hits = 0, misses = 0; for (const auto& url : uniqueUrls) { - bool present = g_moduleRegistry.find(url) != g_moduleRegistry.end(); + bool present = registry.find(url) != registry.end(); if (present) hits++; else misses++; TNS_DEBUG(Registry, "invalidate %s key=%s", present ? "HIT " : "MISS", @@ -2012,7 +2009,7 @@ void InvalidateModules(v8::Isolate* isolate, v8::Local context, TNS_DEBUG(Registry, "invalidate summary unique=%lu hits=%lu misses=%lu " "(registry now=%lu)", (unsigned long)uniqueUrls.size(), (unsigned long)hits, - (unsigned long)misses, (unsigned long)g_moduleRegistry.size()); + (unsigned long)misses, (unsigned long)registry.size()); } // ───────────────────────────────────────────────────────────── @@ -2073,10 +2070,10 @@ static bool QueueHttpDynamicWaiterIfInFlight( v8::Local module, v8::Local resolver) { auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) return false; - auto& g_modulesInFlight = moduleState->modulesInFlight; + auto& modulesInFlight = moduleState->modulesInFlight; if (registryKey.empty() || module.IsEmpty() || !IsModuleEvaluationInProgress(module->GetStatus()) || - g_modulesInFlight.find(registryKey) == g_modulesInFlight.end()) { + modulesInFlight.find(registryKey) == modulesInFlight.end()) { return false; } moduleState->httpDynamicWaiters[registryKey].emplace_back(isolate, resolver); @@ -2127,11 +2124,11 @@ static void ResolveHttpDynamicWaiters(v8::Isolate* isolate, // So every piece of state that can route a new import onto the waiter list // is cleared FIRST; a re-entrant import then takes the registry-hit path. std::vector> resolvers; - auto& g_httpDynamicWaiters = moduleState->httpDynamicWaiters; - auto waitIt = g_httpDynamicWaiters.find(registryKey); - if (waitIt != g_httpDynamicWaiters.end()) { + auto& httpDynamicWaiters = moduleState->httpDynamicWaiters; + auto waitIt = httpDynamicWaiters.find(registryKey); + if (waitIt != httpDynamicWaiters.end()) { resolvers.swap(waitIt->second); - g_httpDynamicWaiters.erase(waitIt); + httpDynamicWaiters.erase(waitIt); } moduleState->modulesInFlight.erase(registryKey); @@ -2149,11 +2146,11 @@ static void RejectHttpDynamicWaiters(v8::Isolate* isolate, // rejection handler that retries this URL must not join a flushed waiter // list. std::vector> resolvers; - auto& g_httpDynamicWaiters = moduleState->httpDynamicWaiters; - auto waitIt = g_httpDynamicWaiters.find(registryKey); - if (waitIt != g_httpDynamicWaiters.end()) { + auto& httpDynamicWaiters = moduleState->httpDynamicWaiters; + auto waitIt = httpDynamicWaiters.find(registryKey); + if (waitIt != httpDynamicWaiters.end()) { resolvers.swap(waitIt->second); - g_httpDynamicWaiters.erase(waitIt); + httpDynamicWaiters.erase(waitIt); } moduleState->modulesInFlight.erase(registryKey); @@ -2182,14 +2179,14 @@ static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate, const std::string& registryKey) { auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) return; - auto& g_httpDynamicWaiters = moduleState->httpDynamicWaiters; + auto& httpDynamicWaiters = moduleState->httpDynamicWaiters; moduleState->modulesInFlight.erase(registryKey); - auto dynamicWaitIt = g_httpDynamicWaiters.find(registryKey); - if (dynamicWaitIt != g_httpDynamicWaiters.end()) { + auto dynamicWaitIt = httpDynamicWaiters.find(registryKey); + if (dynamicWaitIt != httpDynamicWaiters.end()) { std::vector> resolvers; resolvers.swap(dynamicWaitIt->second); - g_httpDynamicWaiters.erase(dynamicWaitIt); + httpDynamicWaiters.erase(dynamicWaitIt); RejectResolversForInvalidation(isolate, context, resolvers, registryKey); } TNS_DEBUG(Esm, "[resolver][invalidate-state] cleared in-flight state for %s", @@ -2212,20 +2209,20 @@ static v8::MaybeLocal CompileJsonTextAsEsModule( if (moduleState == nullptr) { return v8::MaybeLocal(); } - auto& g_moduleRegistry = moduleState->registry; + auto& registry = moduleState->registry; // JSON modules are compiled eagerly to kEvaluated, so a registered entry is // complete and must be reused — recompiling would mint a second module // identity (and namespace) for the same source on every resolve. - auto existingIt = g_moduleRegistry.find(registryAbsPath); - if (existingIt != g_moduleRegistry.end()) { + auto existingIt = registry.find(registryAbsPath); + if (existingIt != registry.end()) { v8::Local existing = existingIt->second.Get(isolate); if (!existing.IsEmpty() && existing->GetStatus() == v8::Module::kEvaluated) { return v8::MaybeLocal(existing); } UnindexRegistryKey(*moduleState, isolate, registryAbsPath); existingIt->second.Reset(); - g_moduleRegistry.erase(existingIt); + registry.erase(existingIt); } TNS_DEBUG(Esm, "[json] wrapping %s", displayUrl.c_str()); @@ -2263,9 +2260,9 @@ static v8::MaybeLocal CompileJsonTextAsEsModule( if (evalResult.IsEmpty()) return v8::MaybeLocal(); UnindexRegistryKey(*moduleState, isolate, registryAbsPath); - auto it = g_moduleRegistry.find(registryAbsPath); - if (it != g_moduleRegistry.end()) it->second.Reset(); - g_moduleRegistry[registryAbsPath].Reset(isolate, jsonModule); + auto it = registry.find(registryAbsPath); + if (it != registry.end()) it->second.Reset(); + registry[registryAbsPath].Reset(isolate, jsonModule); IndexRegisteredModule(*moduleState, registryAbsPath, jsonModule); return v8::MaybeLocal(jsonModule); } @@ -2295,7 +2292,7 @@ v8::MaybeLocal ResolveModuleCallback( if (moduleState == nullptr) { return v8::MaybeLocal(); } - auto& g_moduleRegistry = moduleState->registry; + auto& registry = moduleState->registry; v8::String::Utf8Value specUtf8(isolate, specifier); const std::string rawSpec = *specUtf8 ? *specUtf8 : ""; @@ -2349,8 +2346,8 @@ v8::MaybeLocal ResolveModuleCallback( // simply rejoins the graph V8 is currently linking — that is how import // cycles terminate, the same way Node/Blink break them with the module-map // self-insert. - auto it = g_moduleRegistry.find(registryAbsPath); - if (it != g_moduleRegistry.end()) { + auto it = registry.find(registryAbsPath); + if (it != registry.end()) { v8::Local existing = it->second.Get(isolate); if (!existing.IsEmpty() && existing->GetStatus() != v8::Module::kErrored) { TNS_DEBUG(Esm, "[resolver] cache hit %s (status=%s)", absPath.c_str(), @@ -2374,7 +2371,7 @@ v8::MaybeLocal ResolveModuleCallback( return v8::MaybeLocal(); } UnindexRegistryKey(*moduleState, isolate, registryAbsPath); - g_moduleRegistry[registryAbsPath].Reset(isolate, mod); + registry[registryAbsPath].Reset(isolate, mod); IndexRegisteredModule(*moduleState, registryAbsPath, mod); return v8::MaybeLocal(mod); } catch (NativeScriptException& ex) { @@ -2543,9 +2540,9 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( if (moduleState == nullptr) { return v8::MaybeLocal(); } - auto& g_moduleRegistry = moduleState->registry; - auto& g_modulesInFlight = moduleState->modulesInFlight; - auto& g_httpDynamicWaiters = moduleState->httpDynamicWaiters; + auto& registry = moduleState->registry; + auto& modulesInFlight = moduleState->modulesInFlight; + auto& httpDynamicWaiters = moduleState->httpDynamicWaiters; v8::String::Utf8Value specUtf8(isolate, specifier); const char* cSpec = (*specUtf8) ? *specUtf8 : ""; @@ -2647,8 +2644,8 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( TNS_DEBUG(Esm, "[dyn-import][blob] trying blob URL %s key=%s", normalizedSpec.c_str(), blobRegistryKey.c_str()); - auto existingIt = g_moduleRegistry.find(blobRegistryKey); - if (existingIt != g_moduleRegistry.end()) { + auto existingIt = registry.find(blobRegistryKey); + if (existingIt != registry.end()) { v8::Local existing = existingIt->second.Get(isolate); if (!existing.IsEmpty()) { v8::Module::Status existingStatus = existing->GetStatus(); @@ -2658,8 +2655,8 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( if (existingStatus == v8::Module::kErrored) { RemoveModuleFromRegistry(blobRegistryKey); } else if (IsModuleEvaluationInProgress(existingStatus)) { - g_modulesInFlight.insert(blobRegistryKey); - g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); + modulesInFlight.insert(blobRegistryKey); + httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); TNS_DEBUG(Esm, "[dyn-import][blob-await] queued waiter for %s status=%s", blobRegistryKey.c_str(), ModuleStatusToString(existingStatus)); @@ -2674,15 +2671,15 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } } - if (g_modulesInFlight.find(blobRegistryKey) != g_modulesInFlight.end()) { + if (modulesInFlight.find(blobRegistryKey) != modulesInFlight.end()) { TNS_DEBUG(Esm, "[dyn-import][blob] coalesce in-flight %s", blobRegistryKey.c_str()); - g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); + httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); return scope.Escape(resolver->GetPromise()); } - g_modulesInFlight.insert(blobRegistryKey); - g_httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); + modulesInFlight.insert(blobRegistryKey); + httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); v8::TryCatch tc(isolate); v8::Local globalObj = context->Global(); @@ -3027,22 +3024,22 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( // vocabulary of its own. bool isVolatile = IsVolatileUrl(vocabulary, normalizedSpec); if (isVolatile) { - auto ex = g_moduleRegistry.find(key); - if (ex != g_moduleRegistry.end()) { + auto ex = registry.find(key); + if (ex != registry.end()) { TNS_DEBUG(Esm, "[dyn-import][http-cache] drop volatile %s", key.c_str()); RemoveModuleFromRegistry(key); } } // Coalesce concurrent dynamic imports for the same HTTP key. - auto inflight = g_modulesInFlight.find(key) != g_modulesInFlight.end(); + auto inflight = modulesInFlight.find(key) != modulesInFlight.end(); if (inflight) { TNS_DEBUG(Esm, "[dyn-import][http] coalesce in-flight %s", key.c_str()); - g_httpDynamicWaiters[key].emplace_back(isolate, resolver); + httpDynamicWaiters[key].emplace_back(isolate, resolver); return scope.Escape(resolver->GetPromise()); } // If module was already compiled, resolve immediately. - auto itExisting = g_moduleRegistry.find(key); - if (itExisting != g_moduleRegistry.end()) { + auto itExisting = registry.find(key); + if (itExisting != registry.end()) { v8::Local existing = itExisting->second.Get(isolate); if (!existing.IsEmpty()) { TNS_DEBUG(Esm, "[dyn-import][http-cache] hit %s status=%s", key.c_str(), @@ -3065,10 +3062,10 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( return scope.Escape(resolver->GetPromise()); } else { if (st != v8::Module::kEvaluated) { - g_modulesInFlight.insert(key); + modulesInFlight.insert(key); TNS_DEBUG(Esm, "[dyn-import][http-cache] awaiting evaluation %s", key.c_str()); - g_httpDynamicWaiters[key].emplace_back(isolate, resolver); + httpDynamicWaiters[key].emplace_back(isolate, resolver); if (st == v8::Module::kUninstantiated) { v8::TryCatch tcInstantiate(isolate); if (!existing->InstantiateModule(context, &ResolveModuleCallback) @@ -3178,8 +3175,8 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } } // Mark in-flight and start the async graph load. - g_modulesInFlight.insert(key); - g_httpDynamicWaiters[key].emplace_back(isolate, resolver); + modulesInFlight.insert(key); + httpDynamicWaiters[key].emplace_back(isolate, resolver); const std::string requestUrl = normalizedSpec; StartModuleGraphLoad( isolate, context, requestUrl, diff --git a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp index f2f3f3f10..b3d9e670e 100644 --- a/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp +++ b/test-app/runtime/src/main/cpp/NsBuiltinModules.cpp @@ -125,8 +125,8 @@ bool HasPrefix(const std::string& specifier, const char* prefix) { /* * A builtin module is a singleton per realm, so every cache here is per * runtime: workers get their own exports objects and their own synthetic - * modules. The process-global g_moduleRegistry deliberately holds none of - * this. Touched only from its own runtime's thread. + * modules. The ES module registry deliberately holds none of this. Touched + * only from its own runtime's thread. */ struct RealmState { robin_hood::unordered_map*> exports; From 006de1a5e65b515d9639f577c76765b87479aae2 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 16:44:46 -0300 Subject: [PATCH 31/36] fix(runtime): loader input validation, worker entry resolution, review findings Ports iOS 828d9136 and ee51d741 and closes the findings of the series review: - configureLoader validates the whole config before applying any of it: unknown keys, wrong-typed sections, and array elements by index each throw a TypeError naming the offense, and a rejected call installs nothing. volatilePatterns replaces wholesale, an explicit empty array included. invalidateModules and canonicalizeHttpUrlKey throw on malformed arguments; require() of a non-string throws Node's "id" TypeError instead of an unchecked cast, and require() of an http(s) URL is refused with the cross-platform wording. The importMap object branch keeps V8's C++ JSON API - immune to a tampered globalThis.JSON - over iOS's global lookup, with a throwing toJSON/getter propagated unchanged. - HttpLoader's JNI error handling was dead code: every wrapper call threw past it, so a body-less 4xx/5xx read as a network error and earned an unwarranted retry, and a mid-body failure leaked the stream. Locally handled JNI failures now use non-throwing calls, status truth is preserved, and cache-bust marks clear only on an ok-classified response. - Worker entries: the constructor now keeps the resolved entry path, so relative .mjs workers resolve like .js ones, extension-resolved module entries route to the module branch, and the settle gate probes the real registry key. A TLA entry rejection gets its own settle handler: the queue enables and the failure runs the web's order - the worker scope's onerror first, then the parent's Worker object - instead of being marked handled and dropped. - The boot backstop no longer swallows a rejection found on its first poll, and both backstop throws evict the entry so a reload recompiles. - require() of a failed ES module no longer caches {} forever; the three failure shapes throw distinct errors. An unreadable-but-present entry file fails instead of compiling as empty. __nativeRequire's optional arguments are validated again at the callback boundary. - Dynamic import: the catch-all rejects with the real caught exception instead of scheduling one beside a resolved promise; the builtin gate uses IsBuiltinScheme so an import map can no longer shadow unregistered node: specifiers; local evaluation errors, blob-path failures, and JSON-module compile failures carry their causes; TryCatches are reset before rejecting. - Teardown: quiesce precedes the isolate-cache erase; the rejection-reason stringification in the entry poll is guarded. - Parity hygiene: __NS_HTTP_ORIGIN__ (set by nothing, anywhere) is gone and the shared resolution seam is pure again; dead CompileModuleFromSource and ResolveFileRelative removed; ShouldTraceRegistryKey unified; RemoveModuleFromRegistry takes its isolate; import maps apply once and identically on static and dynamic paths; import.meta guards match iOS; evaluate-path tracing ported; thread_local renamed t_; stale header comments and unused includes swept. Specs pin the new validation contract end to end. --- .../app/tests/esmEntryRelativeWorker.mjs | 5 + .../app/tests/esmEntryResolvedWorker.mjs | 13 + .../assets/app/tests/testCreateRequire.js | 34 ++ .../assets/app/tests/testEsmHttpLoader.js | 174 ++++++ .../assets/app/tests/testWorkerEsmEntry.js | 31 +- .../runtime/src/main/cpp/CallbackHandlers.cpp | 15 +- test-app/runtime/src/main/cpp/File.cpp | 7 + test-app/runtime/src/main/cpp/File.h | 5 + test-app/runtime/src/main/cpp/HttpLoader.cpp | 384 +++++++++---- test-app/runtime/src/main/cpp/HttpLoader.h | 34 +- .../runtime/src/main/cpp/ModuleInternal.cpp | 464 ++++++++++++--- .../runtime/src/main/cpp/ModuleInternal.h | 48 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 536 ++++++++---------- .../src/main/cpp/ModuleInternalCallbacks.h | 33 +- test-app/runtime/src/main/cpp/Runtime.cpp | 55 +- test-app/runtime/src/main/cpp/Runtime.h | 5 + .../runtime/src/main/cpp/WorkerWrapper.cpp | 101 +++- test-app/runtime/src/main/cpp/WorkerWrapper.h | 27 +- .../runtime/src/main/cpp/js/primordials.js | 2 +- 19 files changed, 1397 insertions(+), 576 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/esmEntryRelativeWorker.mjs create mode 100644 test-app/app/src/main/assets/app/tests/esmEntryResolvedWorker.mjs diff --git a/test-app/app/src/main/assets/app/tests/esmEntryRelativeWorker.mjs b/test-app/app/src/main/assets/app/tests/esmEntryRelativeWorker.mjs new file mode 100644 index 000000000..82bb4b14c --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/esmEntryRelativeWorker.mjs @@ -0,0 +1,5 @@ +// Reached through a relative worker specifier. Module code is strict, so the +// message handler is installed on globalThis rather than by bare assignment. +globalThis.onmessage = function (msg) { + postMessage("relative-entry:" + msg.data); +}; diff --git a/test-app/app/src/main/assets/app/tests/esmEntryResolvedWorker.mjs b/test-app/app/src/main/assets/app/tests/esmEntryResolvedWorker.mjs new file mode 100644 index 000000000..7e1450cc2 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/esmEntryResolvedWorker.mjs @@ -0,0 +1,13 @@ +// Reached through an extension-less worker specifier: no `.js` sibling exists, +// so only this file can answer. The top-level await parks on a NON-nestable +// foreground task, which the in-place yield window cannot settle, so a message +// posted at construction has to wait on the settle-gated queue. +const i32 = new Int32Array(new SharedArrayBuffer(4)); +const wait = Atomics.waitAsync(i32, 0, 0); +Atomics.notify(i32, 0); + +const settled = await wait.value; + +globalThis.onmessage = function (msg) { + postMessage("resolved-entry:" + settled + ":" + msg.data); +}; diff --git a/test-app/app/src/main/assets/app/tests/testCreateRequire.js b/test-app/app/src/main/assets/app/tests/testCreateRequire.js index 0146873bf..8ddad7881 100644 --- a/test-app/app/src/main/assets/app/tests/testCreateRequire.js +++ b/test-app/app/src/main/assets/app/tests/testCreateRequire.js @@ -124,6 +124,40 @@ describe("createRequire", function () { }); }); + // The specifier itself is validated with Node's ERR_INVALID_ARG_TYPE + // wording, so a message copied out of a stack trace matches what the + // ecosystem documents. Rejected before any builtin, http or filesystem + // handling — none of which can run without a string. + describe("specifier validation", function () { + var minted = nsModule.createRequire(fixtureDir + "/"); + + it("rejects a non-string specifier with Node's wording", function () { + expect(function () { globalThis.require(42); }) + .toThrowError(TypeError, + /^The "id" argument must be of type string\. Received type number \(42\)$/); + }); + + it("rejects a missing specifier with Node's wording", function () { + expect(function () { globalThis.require(); }) + .toThrowError(TypeError, + /^The "id" argument must be of type string\. Received undefined$/); + }); + + it("names null and object arguments the way Node does", function () { + expect(messageOf(function () { globalThis.require(null); })) + .toBe('The "id" argument must be of type string. Received null'); + expect(messageOf(function () { globalThis.require({}); })) + .toBe('The "id" argument must be of type string. Received an instance of Object'); + }); + + it("applies the same validation to a minted require", function () { + expect(function () { minted(42); }) + .toThrowError(TypeError, /^The "id" argument must be of type string\./); + expect(function () { minted(); }) + .toThrowError(TypeError, /Received undefined$/); + }); + }); + describe("evaluation policy", function () { it("refuses a top-level-await graph strictly", function () { var strictRequire = nsModule.createRequire(fixtureDir + "/anything.js"); diff --git a/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js b/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js index 276033b94..c21dbf9d5 100644 --- a/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js +++ b/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js @@ -179,6 +179,46 @@ describe("HTTP ESM Loader", function () { }); }); + // Each present configureLoader section replaces its state wholesale, so + // an empty array is explicit policy — "nothing is volatile any more" — + // not a no-op. + describe("volatile patterns", function () { + useHttpTimeout(); + + var nsModule = require("ns:module"); + + afterEach(function () { + nsModule.configureLoader({ volatilePatterns: [] }); + }); + + it("stops treating a URL as volatile once the list is emptied", function (done) { + // The fixture pushes one entry per evaluation, so the bucket + // length counts how many times the module actually ran. + var url = origin + "/esm/graph-leaf.mjs?k=vol"; + function evaluations() { + return (globalThis.__nsMixedOrdervol || []).length; + } + + nsModule.configureLoader({ volatilePatterns: ["k=vol"] }); + + import(url).then(function () { + return import(url); + }).then(function () { + // Volatile: the cached module is dropped, so it re-evaluates. + expect(evaluations()).toBe(2); + + nsModule.configureLoader({ volatilePatterns: [] }); + return import(url); + }).then(function () { + // Cleared: the registry entry is reused, nothing re-runs. + expect(evaluations()).toBe(2); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + // The import map is process-wide, so every spec here installs its own // and restores the empty map afterwards. describe("import map", function () { @@ -318,6 +358,140 @@ describe("HTTP ESM Loader", function () { }); }); + // An imperative API rejects bad input loudly, the way WebIDL does on the + // web and ERR_INVALID_ARG_TYPE does in Node. Silently skipping a + // mistyped section or a typo'd key turns a caller's bug into a config + // that quietly does nothing. + describe("loader surface argument validation", function () { + useHttpTimeout(); + + var nsModule = require("ns:module"); + + it("rejects a missing or non-object config", function () { + expect(function () { + nsModule.configureLoader(); + }).toThrowError(TypeError, /configureLoader expects a config object/); + expect(function () { + nsModule.configureLoader(42); + }).toThrowError(TypeError, /configureLoader expects a config object/); + }); + + it("rejects an unknown top-level config key by name", function () { + expect(function () { + nsModule.configureLoader({ typoKey: [] }); + }).toThrowError(TypeError, /unknown option 'typoKey'/); + }); + + it("rejects a non-array volatilePatterns", function () { + expect(function () { + nsModule.configureLoader({ volatilePatterns: "x" }); + }).toThrowError(TypeError, /volatilePatterns must be an array of strings/); + }); + + it("rejects a non-string volatilePatterns element by index", function () { + expect(function () { + nsModule.configureLoader({ volatilePatterns: [1] }); + }).toThrowError(TypeError, /volatilePatterns\[0\] must be a string/); + expect(function () { + nsModule.configureLoader({ volatilePatterns: ["ok", null] }); + }).toThrowError(TypeError, /volatilePatterns\[1\] must be a string/); + }); + + it("rejects a non-object canonicalization", function () { + expect(function () { + nsModule.configureLoader({ canonicalization: "x" }); + }).toThrowError(TypeError, /canonicalization must be an object/); + }); + + it("rejects a non-array canonicalization sub-key by name", function () { + expect(function () { + nsModule.configureLoader({ canonicalization: { stripParams: "t" } }); + }).toThrowError(TypeError, + /canonicalization\.stripParams must be an array of strings/); + expect(function () { + nsModule.configureLoader({ canonicalization: { forPathPrefixes: [7] } }); + }).toThrowError(TypeError, /canonicalization\.forPathPrefixes\[0\] must be a string/); + }); + + it("rejects a non-array invalidateModules argument", function () { + expect(function () { + nsModule.invalidateModules("x"); + }).toThrowError(TypeError, /invalidateModules expects an array of URL strings/); + }); + + it("rejects a non-string invalidateModules element by index", function () { + expect(function () { + nsModule.invalidateModules([1]); + }).toThrowError(TypeError, /urls\[0\] must be a string/); + }); + + it("rejects a non-string canonicalizeHttpUrlKey argument", function () { + // Debug-only diagnostic; release builds omit the member entirely. + if (typeof nsModule.canonicalizeHttpUrlKey !== "function") { + pending("canonicalizeHttpUrlKey is debug-only; absent in this build"); + return; + } + expect(function () { + nsModule.canonicalizeHttpUrlKey(42); + }).toThrowError(TypeError, /canonicalizeHttpUrlKey expects a URL string/); + }); + + // Validate-before-apply: the whole config is checked before any of + // it is installed, so a call that throws leaves every section on the + // state it already had. + it("applies no section when any part of the config is invalid", function () { + if (typeof nsModule.canonicalizeHttpUrlKey !== "function") { + pending("canonicalizeHttpUrlKey is debug-only; absent in this build"); + return; + } + var url = "http://h/dev/core?p=x&t=123"; + var before = nsModule.canonicalizeHttpUrlKey(url); + + // A well-formed canonicalization section paired with a typo'd key. + expect(function () { + nsModule.configureLoader({ + canonicalization: { stripParams: ["t"], forPathPrefixes: ["/dev/"] }, + typoKey: 1, + }); + }).toThrowError(TypeError, /unknown option 'typoKey'/); + + // Had the canonicalization section been applied, `t` would now + // be stripped and the key would differ. + expect(nsModule.canonicalizeHttpUrlKey(url)).toBe(before); + }); + + it("leaves volatilePatterns untouched when the same call throws", function (done) { + var url = origin + "/esm/graph-leaf.mjs?k=vpre"; + function evaluations() { + return (globalThis.__nsMixedOrdervpre || []).length; + } + + // Nothing is volatile yet, so a second import reuses the entry. + import(url).then(function () { + return import(url); + }).then(function () { + expect(evaluations()).toBe(1); + + // A valid volatilePatterns alongside an unknown key: the + // call throws and the patterns must NOT be installed. + expect(function () { + nsModule.configureLoader({ + volatilePatterns: ["k=vpre"], + typoKey: 1, + }); + }).toThrowError(TypeError, /unknown option 'typoKey'/); + + return import(url); + }).then(function () { + // Still not volatile: the rejected call installed nothing. + expect(evaluations()).toBe(1); + done(); + }).catch(function (error) { + reportRejection(error, done); + }); + }); + }); + // Module scripts are strict about MIME on the web, and so is the // loader: the response policy lives in one classifier shared by the // synchronous fallback and the graph walk. diff --git a/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js b/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js index ca8e3d82f..f055104ee 100644 --- a/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js +++ b/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js @@ -1,9 +1,9 @@ // An ES module worker entry takes the same RunModule branch — and the same // boot evaluation options — the app's main entry takes, so these pin that // destination even though the suite cannot re-drive the app's own boot. -// The `.mjs` entries are spawned app-root-absolute on purpose: a relative -// worker path is resolved against the caller's directory only on the CommonJS -// route, so an ES module entry needs a path that already stands on its own. +// A worker specifier is resolved through Java resolvePath whatever route the +// entry ends up taking, so app-root-absolute, relative and extension-less +// paths all reach an `.mjs` entry. describe("worker ES module entries", function () { var originalTimeout; @@ -40,6 +40,31 @@ describe("worker ES module entries", function () { worker.postMessage("ping"); }); + it("runs an ES module worker entry spawned through a relative path", function (done) { + var worker = new Worker("./esmEntryRelativeWorker.mjs"); + worker.onmessage = function (msg) { + expect(msg.data).toBe("relative-entry:ping"); + worker.terminate(); + done(); + }; + worker.postMessage("ping"); + }); + + // Extension resolution tries `.js` before `.mjs`, and no `.js` sibling + // exists, so the ES module entry is what answers. Its top-level await also + // parks past the yield window, so the message posted here proves the + // settle-gated queue engages on a resolved specifier too. + it("runs an extension-less ES module worker entry past its top-level await", + function (done) { + var worker = new Worker("./esmEntryResolvedWorker"); + worker.onmessage = function (msg) { + expect(msg.data).toBe("resolved-entry:ok:ping"); + worker.terminate(); + done(); + }; + worker.postMessage("ping"); + }); + // WHATWG parity: the worker's message queue is enabled when its entry // script finishes evaluating, and from then on messages dispatch whether // or not a handler exists. A handler registered later (from a timer) diff --git a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp index cf8b5188d..a6506aa2c 100644 --- a/test-app/runtime/src/main/cpp/CallbackHandlers.cpp +++ b/test-app/runtime/src/main/cpp/CallbackHandlers.cpp @@ -1231,16 +1231,21 @@ void CallbackHandlers::NewThreadCallback(const v8::FunctionCallbackInfo(isolate, workerId, resolvedPath, + auto wrapper = std::make_shared(isolate, workerId, entryPath, currentDir, priority, thiz); WorkerWrapper::Insert(workerId, wrapper); diff --git a/test-app/runtime/src/main/cpp/File.cpp b/test-app/runtime/src/main/cpp/File.cpp index 482893ccf..83aa17b0a 100644 --- a/test-app/runtime/src/main/cpp/File.cpp +++ b/test-app/runtime/src/main/cpp/File.cpp @@ -16,10 +16,17 @@ using namespace std; namespace tns { string File::ReadText(const string& filePath) { + bool ok; + return ReadText(filePath, ok); +} + +string File::ReadText(const string& filePath, bool& ok) { int len; bool isNew; const char* content = ReadText(filePath, len, isNew); + ok = content != nullptr; + if (content == nullptr) { return string(); } diff --git a/test-app/runtime/src/main/cpp/File.h b/test-app/runtime/src/main/cpp/File.h index 258e75988..0691a3090 100644 --- a/test-app/runtime/src/main/cpp/File.h +++ b/test-app/runtime/src/main/cpp/File.h @@ -15,6 +15,11 @@ class File { public: static const char* ReadText(const std::string& filePath, int& length, bool& isNew); static std::string ReadText(const std::string& filePath); + /* + * `ok` distinguishes a file that could not be opened from one that is + * genuinely empty — the plain overload renders both as "". + */ + static std::string ReadText(const std::string& filePath, bool& ok); static bool WriteBinary(const std::string& filePath, const void* inData, int length); static void* ReadBinary(const std::string& filePath, int& length); private: diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp index ff72d0a57..fc71759f7 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.cpp +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -9,17 +9,16 @@ #include #include #include -#include #include #include #include +#include #include #include "ArgConverter.h" #include "JEnv.h" #include "ModuleInternal.h" #include "ModuleInternalCallbacks.h" -#include "NativeScriptAssert.h" #include "NativeScriptException.h" #include "Runtime.h" #include "TraceLog.h" @@ -301,34 +300,39 @@ static void ClearAllCacheBustMarks() { // ───────────────────────────────────────────────────────────── // JNI fetch diagnostics + request builder -static thread_local std::string g_lastHttpFetchErrorReason; +static thread_local std::string t_lastHttpFetchErrorReason; static void RecordLastHttpFetchError(const char* stage, const std::string& excClass, const std::string& excMsg) { - g_lastHttpFetchErrorReason.assign("stage="); - g_lastHttpFetchErrorReason.append(stage ? stage : "?"); - g_lastHttpFetchErrorReason.append(" class="); - g_lastHttpFetchErrorReason.append(excClass); - g_lastHttpFetchErrorReason.append(" msg="); - g_lastHttpFetchErrorReason.append(excMsg); + t_lastHttpFetchErrorReason.assign("stage="); + t_lastHttpFetchErrorReason.append(stage ? stage : "?"); + t_lastHttpFetchErrorReason.append(" class="); + t_lastHttpFetchErrorReason.append(excClass); + t_lastHttpFetchErrorReason.append(" msg="); + t_lastHttpFetchErrorReason.append(excMsg); } static void ClearLastHttpFetchErrorReason() { - g_lastHttpFetchErrorReason.clear(); + t_lastHttpFetchErrorReason.clear(); } std::string TakeLastHttpFetchErrorReason() { - std::string out = std::move(g_lastHttpFetchErrorReason); - g_lastHttpFetchErrorReason.clear(); + std::string out = std::move(t_lastHttpFetchErrorReason); + t_lastHttpFetchErrorReason.clear(); return out; } +// Describes and clears a pending Java exception. The introspection calls go +// through the raw JNIEnv: JEnv's wrappers turn a pending Java exception into a +// thrown NativeScriptException, which here would replace the exception being +// described with the failure to describe it. static bool DrainPendingJniException(JEnv& env, std::string& outClassName, std::string& outMessage) { outClassName.clear(); outMessage.clear(); - jthrowable th = env.ExceptionOccurred(); + JNIEnv* raw = env; + jthrowable th = raw->ExceptionOccurred(); if (!th) return false; - env.ExceptionClear(); + raw->ExceptionClear(); jclass clsThrowable = env.GetObjectClass(th); if (clsThrowable) { @@ -336,8 +340,8 @@ static bool DrainPendingJniException(JEnv& env, std::string& outClassName, std:: if (clsClass) { jmethodID getName = env.GetMethodID(clsClass, "getName", "()Ljava/lang/String;"); if (getName) { - jstring jName = static_cast(env.CallObjectMethod(clsThrowable, getName)); - env.ExceptionClear(); + jstring jName = static_cast(raw->CallObjectMethod(clsThrowable, getName)); + raw->ExceptionClear(); if (jName) { outClassName = ArgConverter::jstringToString(jName); } @@ -345,19 +349,20 @@ static bool DrainPendingJniException(JEnv& env, std::string& outClassName, std:: } jmethodID toString = env.GetMethodID(clsThrowable, "toString", "()Ljava/lang/String;"); if (toString) { - jstring jMsg = static_cast(env.CallObjectMethod(th, toString)); - env.ExceptionClear(); + jstring jMsg = static_cast(raw->CallObjectMethod(th, toString)); + raw->ExceptionClear(); if (jMsg) { outMessage = ArgConverter::jstringToString(jMsg); } } } - env.ExceptionClear(); + raw->ExceptionClear(); return true; } static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& canonicalKey, - std::string& out, std::string& contentType, int& status); + std::string& out, std::string& contentType, int& status, + bool& bustApplied); static void MaybePumpJSThreadDuringBoot(); static inline void InvokeHttpFetchYield(); @@ -581,18 +586,28 @@ bool HttpFetchModule(const std::string& url, ModuleFetchResult& result) { std::string body; std::string contentType; int status = 0; - bool transportOk = PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status); + bool bustApplied = false; + bool transportOk = + PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status, bustApplied); if (!transportOk) { // One retry, and only for a transport error: an HTTP status is an // answer, not a failure to communicate, so asking again would just // repeat it. TNS_DEBUG(Esm, "[http-loader] retrying %s after initial fetch error", url.c_str()); usleep(120 * 1000); - transportOk = PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status); + transportOk = + PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status, bustApplied); } ClassifyModuleResponse(url, transportOk, status, contentType, body, result); + // A cache-bust mark is only satisfied by a response the loader can actually + // use: a 404, or a 200 that classified as something other than a module, + // leaves it armed for the next attempt. + if (result.ok && bustApplied) { + ClearCacheBustForUrl(canonicalKey); + } + if (!result.ok) { TNS_DEBUG(Esm, "[http-loader][fetch-sync][reject] %s", result.failureReason.c_str()); return false; @@ -616,18 +631,33 @@ bool HttpFetchModule(const std::string& url, ModuleFetchResult& result) { // Runs on whichever thread drives the fetch — the JS thread for the sync path, // a detached background thread for the async one. `canonicalKey` is computed // by the caller on its isolate's thread; nothing here may canonicalize. +// +// The network calls below go through the raw JNIEnv rather than JEnv's +// wrappers: a wrapper converts a pending Java exception into a thrown +// NativeScriptException, which would unwind past the per-stage handling that +// tells a status-bearing answer (an empty 404) apart from a transport failure, +// and past the InputStream close. The wrappers stay on the setup calls, whose +// failures have no per-stage verdict and are caught below. static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& canonicalKey, - std::string& out, std::string& contentType, int& status) { + std::string& out, std::string& contentType, int& status, + bool& bustApplied) { out.clear(); contentType.clear(); status = 0; TNS_DEBUG(Esm, "[http-esm][fetch][enter] url=%s", url.c_str()); - bool bustRequested = false; - const std::string fetchUrl = ApplyCacheBustNonce(url, canonicalKey, &bustRequested); + const std::string fetchUrl = ApplyCacheBustNonce(url, canonicalKey, &bustApplied); + + auto recordStageFailure = [&url](const char* stage, const std::string& excClass, + const std::string& excMsg) { + RecordLastHttpFetchError(stage, excClass, excMsg); + TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=%s url=%s class=%s msg=%s", stage, + url.c_str(), excClass.c_str(), excMsg.c_str()); + }; try { JEnv env; + JNIEnv* raw = env; DisableHttpKeepAliveOnce(env); PermitAllStrictMode(env); @@ -637,26 +667,21 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& jmethodID openConnection = env.GetMethodID(clsURL, "openConnection", "()Ljava/net/URLConnection;"); jstring jUrlStr = env.NewStringUTF(fetchUrl.c_str()); - jobject urlObj = env.NewObject(clsURL, urlCtor, jUrlStr); + jobject urlObj = raw->NewObject(clsURL, urlCtor, jUrlStr); { std::string excClass, excMsg; if (DrainPendingJniException(env, excClass, excMsg)) { - RecordLastHttpFetchError("url-ctor", excClass, excMsg); - TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=url-ctor url=%s class=%s msg=%s", - url.c_str(), excClass.c_str(), excMsg.c_str()); + recordStageFailure("url-ctor", excClass, excMsg); return false; } } - jobject conn = env.CallObjectMethod(urlObj, openConnection); + jobject conn = raw->CallObjectMethod(urlObj, openConnection); { std::string excClass, excMsg; if (DrainPendingJniException(env, excClass, excMsg)) { - RecordLastHttpFetchError("open-connection", excClass, excMsg); - TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=open-connection url=%s class=%s " - "msg=%s", - url.c_str(), excClass.c_str(), excMsg.c_str()); + recordStageFailure("open-connection", excClass, excMsg); return false; } } @@ -703,14 +728,12 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& // the status and earn a pointless retry. bool haveStatus = false; if (isHttp && getResponseCode) { - status = env.CallIntMethod(conn, getResponseCode); + status = raw->CallIntMethod(conn, getResponseCode); std::string excClass, excMsg; if (DrainPendingJniException(env, excClass, excMsg)) { - RecordLastHttpFetchError("get-response-code", excClass, excMsg); - TNS_DEBUG(Esm, - "[http-esm][fetch][exception] stage=get-response-code url=%s class=%s " - "msg=%s", - url.c_str(), excClass.c_str(), excMsg.c_str()); + // The return value of a JNI call that threw is undefined. + status = 0; + recordStageFailure("get-response-code", excClass, excMsg); return false; } haveStatus = status > 0; @@ -720,21 +743,17 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& env.GetMethodID(clsConn, "getInputStream", "()Ljava/io/InputStream;"); jobject inStream = nullptr; if (isHttp && status >= 400 && getErrorStream) { - inStream = env.CallObjectMethod(conn, getErrorStream); - env.ExceptionClear(); + inStream = raw->CallObjectMethod(conn, getErrorStream); + raw->ExceptionClear(); } if (!inStream) { // On an error status with no error body, getInputStream throws // FileNotFoundException rather than returning null. - inStream = env.CallObjectMethod(conn, getInputStream); + inStream = raw->CallObjectMethod(conn, getInputStream); std::string excClass, excMsg; if (DrainPendingJniException(env, excClass, excMsg)) { if (!haveStatus) { - RecordLastHttpFetchError("get-input-stream", excClass, excMsg); - TNS_DEBUG(Esm, - "[http-esm][fetch][exception] stage=get-input-stream url=%s class=%s " - "msg=%s", - url.c_str(), excClass.c_str(), excMsg.c_str()); + recordStageFailure("get-input-stream", excClass, excMsg); return false; } inStream = nullptr; @@ -748,6 +767,20 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& jmethodID readMethod = env.GetMethodID(clsIS, "read", "([B)I"); jmethodID closeIS = env.GetMethodID(clsIS, "close", "()V"); + // The stream holds a socket fd, so nothing between here and the + // end of this scope — a failed read, or a Java exception escaping + // one of the checked wrappers — may leave it open. + struct StreamCloser { + JNIEnv* jni; + jobject stream; + jmethodID closeMethod; + ~StreamCloser() { + if (closeMethod == nullptr) return; + jni->CallVoidMethod(stream, closeMethod); + jni->ExceptionClear(); + } + } streamCloser{raw, inStream, closeIS}; + jclass clsBAOS = env.FindClass("java/io/ByteArrayOutputStream"); jmethodID baosCtor = env.GetMethodID(clsBAOS, "", "()V"); jmethodID baosWrite = env.GetMethodID(clsBAOS, "write", "([BII)V"); @@ -756,23 +789,24 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& jobject baos = env.NewObject(clsBAOS, baosCtor); jbyteArray buffer = env.NewByteArray(8192); + std::string excClass, excMsg; while (true) { - jint n = env.CallIntMethod(inStream, readMethod, buffer); - std::string excClass, excMsg; + jint n = raw->CallIntMethod(inStream, readMethod, buffer); if (DrainPendingJniException(env, excClass, excMsg)) { - RecordLastHttpFetchError("read-body", excClass, excMsg); - TNS_DEBUG(Esm, - "[http-esm][fetch][exception] stage=read-body url=%s class=%s msg=%s", - url.c_str(), excClass.c_str(), excMsg.c_str()); + recordStageFailure("read-body", excClass, excMsg); readFailed = true; break; } if (n < 0) break; if (n == 0) continue; - env.CallVoidMethod(baos, baosWrite, buffer, 0, n); + raw->CallVoidMethod(baos, baosWrite, buffer, 0, n); + if (DrainPendingJniException(env, excClass, excMsg)) { + recordStageFailure("read-body", excClass, excMsg); + readFailed = true; + break; + } } - env.CallVoidMethod(inStream, closeIS); if (!readFailed) { jbyteArray bytes = static_cast(env.CallObjectMethod(baos, baosToByteArray)); @@ -803,11 +837,9 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& } if (status == 0) status = 200; - // A cache-bust mark is only satisfied by a response that actually - // carried the new body; a 404 leaves it armed for the next attempt. - if (status >= 200 && status < 300 && bustRequested) { - ClearCacheBustForUrl(canonicalKey); - } + // Keeps TakeLastHttpFetchErrorReason's contract across a recovered + // retry: a reason belongs to the attempt that failed, not to the fetch. + ClearLastHttpFetchErrorReason(); // Pure transport: true means a response arrived. Whether that response // is a usable module — status, MIME, emptiness — is // ClassifyModuleResponse's call, so both fetch paths answer it the @@ -878,19 +910,26 @@ void FetchModuleBodyAsync(const std::string& url, std::string contentType; int status = 0; const auto start = std::chrono::steady_clock::now(); - bool transportOk = PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status); + bool bustApplied = false; + bool transportOk = + PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status, bustApplied); if (!transportOk) { // Transport error → one retry, the same single-retry policy the // sync path applies. TNS_DEBUG(Esm, "[http-loader][fetch-async] retrying %s after transport error", url.c_str()); usleep(120 * 1000); - transportOk = PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status); + transportOk = PerformHttpFetchOnceSync(url, canonicalKey, body, contentType, status, + bustApplied); } ModuleFetchResult result; ClassifyModuleResponse(url, transportOk, status, contentType, body, result); + if (result.ok && bustApplied) { + ClearCacheBustForUrl(canonicalKey); + } + if (!result.ok) { TNS_DEBUG(Esm, "[http-loader][fetch-async][reject] %s", result.failureReason.c_str()); } else if (LogCategoryEnabled(LogCategory::Fetch)) { @@ -946,86 +985,191 @@ void InstallDevFunction(v8::Isolate* isolate, v8::Local context, target->CreateDataProperty(context, ToV8String(isolate, name), fn).Check(); } +// The only sections configureLoader understands. An unlisted key is a typo the +// caller hears about rather than a setting that silently does nothing. +constexpr const char* kLoaderConfigKeys[] = {"importMap", "volatilePatterns", "canonicalization"}; + void ConfigureLoaderCallback(const v8::FunctionCallbackInfo& info) { v8::Isolate* isolate = info.GetIsolate(); v8::HandleScope scope(isolate); v8::Local ctx = isolate->GetCurrentContext(); + auto throwTypeError = [&](const std::string& message) { + isolate->ThrowException(v8::Exception::TypeError(ToV8String(isolate, message))); + }; + if (info.Length() < 1 || !info[0]->IsObject()) { - TNS_DEBUG(Esm, "[ns:module configureLoader] expected config object argument"); + throwTypeError("configureLoader expects a config object"); return; } v8::Local config = info[0].As(); - v8::Local importMapKey = ToV8String(isolate, "importMap"); + // ── Validation phase ───────────────────────────────────────────────── + // Nothing below mutates the vocabulary. The whole config is checked first + // so a rejected call leaves every section exactly as it was — the + // atomicity the import map alone used to have, now covering the entire + // call. + + // Unknown top-level keys. + v8::Local configKeys; + if (!config->GetOwnPropertyNames(ctx, v8::PropertyFilter::ONLY_ENUMERABLE, + v8::KeyConversionMode::kConvertToString) + .ToLocal(&configKeys)) { + return; // pending exception + } + for (uint32_t i = 0; i < configKeys->Length(); i++) { + v8::Local keyVal; + if (!configKeys->Get(ctx, i).ToLocal(&keyVal)) { + return; + } + std::string key = ArgConverter::ToString(isolate, keyVal); + bool known = false; + for (const char* candidate : kLoaderConfigKeys) { + if (key == candidate) { + known = true; + break; + } + } + if (!known) { + throwTypeError("configureLoader: unknown option '" + key + "'"); + return; + } + } + + // Reads `obj[key]` as an array of strings into `out`. `label` names the + // section in any error. Returns false with an exception pending on a type + // failure; `present` distinguishes "absent" from "present and valid". + auto readStringArray = [&](v8::Local obj, const char* key, + const std::string& label, std::vector& out, + bool* present) -> bool { + *present = false; + v8::Local val; + if (!obj->Get(ctx, ToV8String(isolate, key)).ToLocal(&val)) { + return false; + } + if (val->IsUndefined()) { + return true; + } + if (!val->IsArray()) { + throwTypeError("configureLoader: " + label + " must be an array of strings"); + return false; + } + v8::Local arr = val.As(); + for (uint32_t i = 0; i < arr->Length(); i++) { + v8::Local elem; + if (!arr->Get(ctx, i).ToLocal(&elem)) { + return false; + } + if (!elem->IsString()) { + throwTypeError("configureLoader: " + label + "[" + std::to_string(i) + + "] must be a string"); + return false; + } + out.push_back(ArgConverter::ToString(isolate, elem)); + } + *present = true; + return true; + }; + + // importMap: an object or a JSON string. Validated here, installed below. + std::string importMapJson; + bool haveImportMap = false; v8::Local importMapVal; - if (config->Get(ctx, importMapKey).ToLocal(&importMapVal) && !importMapVal->IsUndefined()) { + if (!config->Get(ctx, ToV8String(isolate, "importMap")).ToLocal(&importMapVal)) { + return; + } + if (!importMapVal->IsUndefined()) { std::string jsonStr; if (importMapVal->IsString()) { - v8::String::Utf8Value utf8(isolate, importMapVal); - if (*utf8) jsonStr = *utf8; + jsonStr = ArgConverter::ToString(isolate, importMapVal); } else if (importMapVal->IsObject()) { v8::Local stringified; - if (v8::JSON::Stringify(ctx, importMapVal).ToLocal(&stringified)) { - v8::String::Utf8Value utf8(isolate, stringified); - if (*utf8) jsonStr = *utf8; + if (!v8::JSON::Stringify(ctx, importMapVal).ToLocal(&stringified)) { + return; // a throwing toJSON / getter propagates unchanged + } + // JSON.stringify answers `undefined` for a function or a + // symbol-valued object, which is not a JSON string. + if (stringified->IsString()) { + jsonStr = ArgConverter::ToString(isolate, stringified); } } if (jsonStr.empty()) { - isolate->ThrowException(v8::Exception::TypeError(ToV8String( - isolate, "configureLoader: importMap must be an object or a JSON string"))); + throwTypeError("configureLoader: importMap must be an object or a JSON string"); return; } std::string importMapError; - if (!SetImportMap(jsonStr, &importMapError)) { + if (!ValidateImportMapJson(jsonStr, &importMapError)) { // The previous map is still installed: a rejected update changes // nothing, so a typo cannot empty a live session's vocabulary. - isolate->ThrowException(v8::Exception::TypeError( - ToV8String(isolate, "configureLoader: " + importMapError))); + throwTypeError("configureLoader: " + importMapError); return; } - TNS_DEBUG(Esm, "[ns:module configureLoader] import map set (%zu bytes)", - jsonStr.size()); + importMapJson = std::move(jsonStr); + haveImportMap = true; } - auto readStringArray = [&](v8::Local obj, const char* key, - std::vector& out) -> bool { - v8::Local val; - if (!obj->Get(ctx, ToV8String(isolate, key)).ToLocal(&val) || !val->IsArray()) { - return false; + // volatilePatterns: array of strings. Presence of the array decides, not + // its contents — an empty one is explicit policy meaning "nothing is + // volatile any more", the same rule canonicalization follows, and the only + // reading under which a present section replaces its state wholesale. + std::vector patterns; + bool havePatterns = false; + if (!readStringArray(config, "volatilePatterns", "volatilePatterns", patterns, &havePatterns)) { + return; + } + + // canonicalization: { stripParams, forPathPrefixes, preserveQueryFor } — + // the URL vocabulary CanonicalizeHttpUrlKey applies (see its doc block). + // Presence of the object marks the vocabulary as configured, replacing the + // built-in fallback entirely (empty arrays are honored as explicit policy). + CanonicalizationConfig canon; + bool haveCanon = false; + v8::Local canonVal; + if (!config->Get(ctx, ToV8String(isolate, "canonicalization")).ToLocal(&canonVal)) { + return; + } + if (!canonVal->IsUndefined()) { + if (!canonVal->IsObject()) { + throwTypeError("configureLoader: canonicalization must be an object"); + return; } - v8::Local arr = val.As(); - for (uint32_t i = 0; i < arr->Length(); i++) { - v8::Local elem; - if (arr->Get(ctx, i).ToLocal(&elem) && elem->IsString()) { - v8::String::Utf8Value utf8(isolate, elem); - if (*utf8) out.push_back(*utf8); - } + v8::Local canonObj = canonVal.As(); + bool ignored = false; + if (!readStringArray(canonObj, "stripParams", "canonicalization.stripParams", + canon.stripParams, &ignored) || + !readStringArray(canonObj, "forPathPrefixes", "canonicalization.forPathPrefixes", + canon.devPathPrefixes, &ignored) || + !readStringArray(canonObj, "preserveQueryFor", "canonicalization.preserveQueryFor", + canon.preserveQueryPrefixes, &ignored)) { + return; } - return true; - }; + haveCanon = true; + } - { - std::vector patterns; - if (readStringArray(config, "volatilePatterns", patterns) && !patterns.empty()) { - SetVolatilePatterns(patterns); - TNS_DEBUG(Esm, "[ns:module configureLoader] %zu volatile patterns set", - patterns.size()); + // ── Apply phase ────────────────────────────────────────────────────── + // Everything validated; from here nothing can fail on the caller's input. + + if (haveImportMap) { + // The re-parse inside SetImportMap is deterministic and already + // succeeded above, so the only failure left is the isolate shutting + // down. + std::string installError; + if (!SetImportMap(importMapJson, &installError)) { + throwTypeError("configureLoader: " + installError); + return; } + TNS_DEBUG(Esm, "[ns:module configureLoader] import map set (%zu bytes)", + importMapJson.size()); } - { - v8::Local canonVal; - if (config->Get(ctx, ToV8String(isolate, "canonicalization")).ToLocal(&canonVal) && - canonVal->IsObject()) { - v8::Local canonObj = canonVal.As(); - CanonicalizationConfig canon; - readStringArray(canonObj, "stripParams", canon.stripParams); - readStringArray(canonObj, "forPathPrefixes", canon.devPathPrefixes); - readStringArray(canonObj, "preserveQueryFor", canon.preserveQueryPrefixes); - SetCanonicalizationConfig(std::move(canon)); - } + if (havePatterns) { + SetVolatilePatterns(patterns); + TNS_DEBUG(Esm, "[ns:module configureLoader] %zu volatile patterns set", patterns.size()); + } + + if (haveCanon) { + SetCanonicalizationConfig(std::move(canon)); } } @@ -1035,7 +1179,8 @@ void InvalidateModulesCallback(const v8::FunctionCallbackInfo& info) v8::Local ctx = isolate->GetCurrentContext(); if (info.Length() < 1 || !info[0]->IsArray()) { - DEBUG_WRITE_FORCE("[ns:module invalidateModules] expected array of URL strings"); + isolate->ThrowException(v8::Exception::TypeError( + ToV8String(isolate, "invalidateModules expects an array of URL strings"))); return; } @@ -1044,13 +1189,16 @@ void InvalidateModulesCallback(const v8::FunctionCallbackInfo& info) urls.reserve(urlsArray->Length()); for (uint32_t index = 0; index < urlsArray->Length(); index++) { v8::Local value; - if (!urlsArray->Get(ctx, index).ToLocal(&value) || !value->IsString()) { - continue; + if (!urlsArray->Get(ctx, index).ToLocal(&value)) { + return; } - v8::String::Utf8Value utf8(isolate, value); - if (*utf8) { - urls.emplace_back(*utf8); + if (!value->IsString()) { + isolate->ThrowException(v8::Exception::TypeError(ToV8String( + isolate, + "invalidateModules: urls[" + std::to_string(index) + "] must be a string"))); + return; } + urls.push_back(ArgConverter::ToString(isolate, value)); } if (tns::LogCategoryEnabled(tns::LogCategory::Registry)) { @@ -1102,11 +1250,11 @@ bool BuildNsModuleBinding(v8::Local context, v8::Local auto canonicalizeCb = [](const v8::FunctionCallbackInfo& info) { v8::Isolate* iso = info.GetIsolate(); if (info.Length() < 1 || !info[0]->IsString()) { - info.GetReturnValue().SetEmptyString(); + iso->ThrowException(v8::Exception::TypeError( + ToV8String(iso, "canonicalizeHttpUrlKey expects a URL string"))); return; } - v8::String::Utf8Value u(iso, info[0]); - std::string key = CanonicalizeHttpUrlKey(*u ? std::string(*u) : std::string()); + std::string key = CanonicalizeHttpUrlKey(ArgConverter::ToString(iso, info[0])); info.GetReturnValue().Set(ToV8String(iso, key)); }; v8::Local fn; diff --git a/test-app/runtime/src/main/cpp/HttpLoader.h b/test-app/runtime/src/main/cpp/HttpLoader.h index eca16fbf5..939e6dcc1 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.h +++ b/test-app/runtime/src/main/cpp/HttpLoader.h @@ -47,8 +47,10 @@ namespace tns { // client via ns:module `configureLoader({ canonicalization: {...} })`. It is // per-isolate loader vocabulary — installed through SetCanonicalizationConfig // in ModuleInternalCallbacks.h — so CanonicalizeHttpUrlKey runs on the -// isolate's own thread only. The transport never canonicalizes; it carries -// keys computed for it. +// isolate's own thread only. The transport canonicalizes at its JS-thread +// entry points (HttpFetchModule, FetchModuleBodyAsync, MarkUrlsForCacheBust) +// and nowhere else; background fetch threads only ever carry keys computed +// for them. // // When unconfigured, canonicalization is purely mechanical (fragment strip). struct CanonicalizationConfig { @@ -58,11 +60,13 @@ struct CanonicalizationConfig { }; // Normalize an HTTP(S) URL into a stable module registry/cache key. -// - Always strips URL fragments. -// - For NativeScript dev endpoints, drops known cache busters (t/v/import) -// and sorts remaining query params for stability. -// - For non-dev/public URLs, preserves the full query string as part of the -// cache key. +// - Anything that is not HTTP(S) comes back unchanged, after unwrapping a +// `file://` prefix the resolver may have put in front of an http(s) URL. +// - The fragment is always stripped. +// - The query survives unless the isolate's canonicalization vocabulary says +// otherwise: a path matching `preserveQueryFor` keeps its query verbatim; +// a path under a `forPathPrefixes` prefix drops every `stripParams` name and +// sorts what remains, for stability. Unconfigured, every query is kept. // Module identity IS the (canonical) URL — the dev server serves every // module under exactly one URL and never varies it for freshness. std::string CanonicalizeHttpUrlKey(const std::string& url); @@ -100,8 +104,8 @@ bool HttpFetchModule(const std::string& url, ModuleFetchResult& result); // Same response policy as HttpFetchModule, minus the JS-thread block: // - security gate (IsRemoteUrlAllowed) checked up front, // - a JNI HttpURLConnection GET on a background thread with the same -// request shape as the sync path (cache-bust nonce, zero-cache headers, -// no cookies) and one retry on transport error. +// request shape as the sync path (cache-bust nonce, zero-cache headers) +// and one retry on transport error. // `completion(result)` is invoked exactly once, on an arbitrary thread — // callers must hop to their JS thread before touching V8. void FetchModuleBodyAsync( @@ -115,12 +119,14 @@ void FetchModuleBodyAsync( // errors when the transport never reached an HTTP status. std::string TakeLastHttpFetchErrorReason(); -// Register a "yield" callback that `HttpFetchModule` should invoke around its -// synchronous network turn so the caller can pump its own runloop (e.g. the -// JS-thread looper so a placeholder UI can repaint during cold-boot). +// Register a "yield" callback that `HttpFetchModule` invokes once, after a +// successful fetch, so the caller can pump its own runloop (e.g. the JS-thread +// looper so a placeholder UI can repaint during cold-boot). // -// Default: a built-in pump that no-ops outside the JS thread / after the -// dev boot completes (see `MaybePumpJSThreadDuringBoot` in HttpLoader.cpp). +// Default: a built-in pump that no-ops unless the calling thread has an +// isolate and is inside an entry-module evaluation window opened by +// SetBootEvaluationActive (see `MaybePumpJSThreadDuringBoot` in +// HttpLoader.cpp). // // Pass `nullptr` to disable any yielding (used by hosts that drive their own // run loop or by tests that want bit-for-bit deterministic fetch timing). diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 0de6c5586..0d7c47079 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -11,7 +11,6 @@ #include "HttpLoader.h" #include "JniLocalRef.h" #include "ArgConverter.h" -#include "V8GlobalHelpers.h" #include "NativeScriptAssert.h" #include "Constants.h" #include "CrashBreadcrumbs.h" @@ -21,12 +20,11 @@ #include "napi/NapiModules.h" #include "Util.h" #include "SimpleProfiler.h" -#include "include/v8.h" #include "CallbackHandlers.h" #include "ManualInstrumentation.h" #include "Runtime.h" +#include "TraceLog.h" #include -#include #include #include #include @@ -35,6 +33,7 @@ #include #include #include +#include #include using namespace v8; @@ -47,22 +46,49 @@ static bool IsHttpModulePath(const std::string& path) { } static std::string NormalizeHttpModuleUrl(const std::string& path) { - if (path.rfind("file://http://", 0) == 0 || path.rfind("file://https://", 0) == 0) { - return path.substr(strlen("file://")); + if (path.empty()) { + return path; } - return path; + + std::string normalized = path; + if (normalized.rfind("file://http://", 0) == 0 || normalized.rfind("file://https://", 0) == 0) { + normalized = normalized.substr(strlen("file://")); + } + + // A path normalizer that collapses `//` into `/` (Java's, or a URL that + // travelled through one) leaves the scheme separator one slash short. + if (normalized.rfind("http:/", 0) == 0 && normalized.rfind("http://", 0) != 0) { + normalized.insert(5, "/"); + } else if (normalized.rfind("https:/", 0) == 0 && normalized.rfind("https://", 0) != 0) { + normalized.insert(6, "/"); + } + + return normalized; } +// What a rejected evaluation promise says about itself. +struct RejectionDetail { + // The reason's own text: an Error's `message`, or the reason stringified. + std::string message; + // A bounded rendering of the reason's `stack`, filled only when asked for. + std::string stackPreview; +}; + +// `detail`, when non-null, receives the reason's parts unjoined; reading the +// stack costs a property get plus a copy, so callers pass null unless a trace +// is actually going to be emitted. static std::string PromiseRejectionMessage(Isolate* isolate, Local promise, - const std::string& path) { + const std::string& path, + RejectionDetail* detail = nullptr) { std::string errorMessage = "Module evaluation promise rejected: " + path; TryCatch tc(isolate); Local reason = promise->Result(); if (reason.IsEmpty()) { return errorMessage; } + std::string reasonText; + Local context = isolate->GetCurrentContext(); if (reason->IsObject()) { - Local context = isolate->GetCurrentContext(); Local errorObj = reason.As(); Local messageVal; if (errorObj->Get(context, ArgConverter::ConvertToV8String(isolate, "message")) @@ -70,21 +96,36 @@ static std::string PromiseRejectionMessage(Isolate* isolate, Local prom messageVal->IsString()) { v8::String::Utf8Value messageUtf8(isolate, messageVal); if (*messageUtf8) { - errorMessage.append(" — "); - errorMessage.append(*messageUtf8); + reasonText.assign(*messageUtf8); + } + } + Local stackVal; + if (detail != nullptr && + errorObj->Get(context, ArgConverter::ConvertToV8String(isolate, "stack")) + .ToLocal(&stackVal) && + stackVal->IsString()) { + v8::String::Utf8Value stackUtf8(isolate, stackVal); + if (*stackUtf8) { + std::string stack(*stackUtf8); + detail->stackPreview = stack.size() > 240 ? stack.substr(0, 240) + "…" : stack; } } } else { - Local context = isolate->GetCurrentContext(); auto maybeReasonStr = reason->ToString(context); if (!maybeReasonStr.IsEmpty()) { v8::String::Utf8Value reasonUtf8(isolate, maybeReasonStr.ToLocalChecked()); if (*reasonUtf8) { - errorMessage.append(" — "); - errorMessage.append(*reasonUtf8); + reasonText.assign(*reasonUtf8); } } } + if (!reasonText.empty()) { + errorMessage.append(" — "); + errorMessage.append(reasonText); + } + if (detail != nullptr) { + detail->message = std::move(reasonText); + } if (tc.HasCaught()) { tc.Reset(); } @@ -169,11 +210,21 @@ void ModuleInternal::Init(Isolate* isolate, const string& baseDir) { m_requireFactoryFunction = new Persistent(isolate, requireFactoryFunction); - auto requireFuncTemplate = FunctionTemplate::New(isolate, RequireCallback, External::New(isolate, this, v8::kExternalPointerTypeTagDefault)); + auto external = External::New(isolate, this, v8::kExternalPointerTypeTagDefault); + + // Only the require factory receives this one, so the evaluation options it + // forwards were validated at mint time. + auto requireFuncTemplate = FunctionTemplate::New(isolate, RequireCallback, external); auto requireFunc = requireFuncTemplate->GetFunction(context).ToLocalChecked(); - global->Set(context, ArgConverter::ConvertToV8String(isolate, "__nativeRequire"), requireFunc); m_requireFunction = new Persistent(isolate, requireFunc); + // App code can reach this one, so it reads nothing but the specifier and the + // calling directory: a caller must not be able to hand itself a pumping + // policy, an unbounded deadline or a looper-slicing require. + auto publicRequireTemplate = FunctionTemplate::New(isolate, RequirePublicCallback, external); + global->Set(context, ArgConverter::ConvertToV8String(isolate, "__nativeRequire"), + publicRequireTemplate->GetFunction(context).ToLocalChecked()); + Local globalRequire; if (!baseDir.empty()) { @@ -304,7 +355,12 @@ void ModuleInternal::CreateRequireCallback(const v8::FunctionCallbackInfo 2 && args[2]->IsNumber()) { - options.deadlineSeconds = args[2].As()->Value(); + double deadlineSeconds = args[2].As()->Value(); + // A NaN or infinite window makes the pump's deadline arithmetic + // undefined, and a non-positive one is no window at all. + if (std::isfinite(deadlineSeconds) && deadlineSeconds > 0.0) { + options.deadlineSeconds = deadlineSeconds; + } } if (args.Length() > 3 && args[3]->IsBoolean()) { options.timeoutBehavior = args[3]->BooleanValue(isolate) @@ -331,10 +387,79 @@ bool ModuleInternal::InstallCreateRequireBinding(Local context, Local& args) { +// Node's `determineSpecificType` (lib/internal/errors.js), so an +// ERR_INVALID_ARG_TYPE-shaped message reads the same here as it does there. +// Deliberately side-effect free: no getter, no user `toString`, no `inspect`. +static std::string DescribeValueForTypeError(Isolate* isolate, Local value) { + if (value.IsEmpty() || value->IsUndefined()) { + return "undefined"; + } + if (value->IsNull()) { + return "null"; + } + + if (value->IsFunction()) { + std::string name = ArgConverter::ToString(isolate, value.As()->GetName()); + return name.empty() ? "an instance of Function" : "function " + name; + } + + if (value->IsObject()) { + std::string ctorName = ArgConverter::ToString(isolate, + value.As()->GetConstructorName()); + return ctorName.empty() ? "an object" : "an instance of " + ctorName; + } + + // A primitive: `type ()`. + const char* typeName = "object"; + std::string rendered; + if (value->IsBoolean()) { + typeName = "boolean"; + rendered = value->IsTrue() ? "true" : "false"; + } else if (value->IsNumber()) { + typeName = "number"; + double number = value.As()->Value(); + // String(-0) is "0", but Node renders the sign, and losing it here would + // hide exactly the distinction the message is meant to surface. + rendered = (number == 0 && std::signbit(number)) ? "-0" + : ArgConverter::ToString(isolate, value); + } else if (value->IsBigInt()) { + typeName = "bigint"; + rendered = ArgConverter::ToString(isolate, value) + "n"; + } else if (value->IsSymbol()) { + typeName = "symbol"; + Local description = value.As()->Description(isolate); + rendered = "Symbol(" + (description->IsUndefined() + ? std::string() + : ArgConverter::ToString(isolate, description)) + + ")"; + } else { + rendered = ArgConverter::ToString(isolate, value); + } + + if (rendered.size() > 28) { + rendered = rendered.substr(0, 25) + "..."; + } + return "type " + std::string(typeName) + " (" + rendered + ")"; +} + +void ModuleInternal::DispatchRequire(const v8::FunctionCallbackInfo& args, + bool honorEvaluationOptions) { + auto isolate = args.GetIsolate(); + + // Every path below assumes a string specifier — the builtin probe, the + // http(s) guard and the filesystem resolution all read it — so reject a + // non-string before any of them rather than casting one unchecked. + if (args.Length() < 1 || !args[0]->IsString()) { + Local received = args.Length() < 1 ? Local() : args[0]; + isolate->ThrowException(Exception::TypeError(ArgConverter::ConvertToV8String( + isolate, "The \"id\" argument must be of type string. Received " + + DescribeValueForTypeError(isolate, received)))); + return; + } + try { auto thiz = static_cast(args.Data().As()->Value(v8::kExternalPointerTypeTagDefault)); - thiz->RequireCallbackImpl(args); + thiz->RequireCallbackImpl(args, honorEvaluationOptions); } catch (NativeScriptException& e) { e.ReThrowToV8(); } catch (std::exception e) { @@ -348,7 +473,16 @@ void ModuleInternal::RequireCallback(const v8::FunctionCallbackInfo& } } -void ModuleInternal::RequireCallbackImpl(const v8::FunctionCallbackInfo& args) { +void ModuleInternal::RequireCallback(const v8::FunctionCallbackInfo& args) { + DispatchRequire(args, true /* honorEvaluationOptions */); +} + +void ModuleInternal::RequirePublicCallback(const v8::FunctionCallbackInfo& args) { + DispatchRequire(args, false /* honorEvaluationOptions */); +} + +void ModuleInternal::RequireCallbackImpl(const v8::FunctionCallbackInfo& args, + bool honorEvaluationOptions) { auto isolate = args.GetIsolate(); if (args.Length() < 2) { @@ -393,29 +527,45 @@ void ModuleInternal::RequireCallbackImpl(const v8::FunctionCallbackInfo()); auto isData = false; - // The require factory forwards the options its require was minted with; an - // absent policy is the strict default every ordinary require uses. - ModuleEvaluationPolicy policy = ModuleEvaluationPolicy::kSyncStrict; - if (args.Length() > 2 && args[2]->IsInt32() && - args[2].As()->Value() == static_cast(ModuleEvaluationPolicy::kSyncPumping)) { - policy = ModuleEvaluationPolicy::kSyncPumping; - } - ModuleEvaluationOptions evaluationOptions = RequireEvaluationOptions(policy); - if (args.Length() > 3 && args[3]->IsNumber()) { - evaluationOptions.deadlineSeconds = args[3].As()->Value(); - } - if (args.Length() > 4 && args[4]->IsBoolean()) { - evaluationOptions.timeoutBehavior = - args[4]->BooleanValue(isolate) - ? ModuleEvaluationOptions::TimeoutBehavior::kThrow - : ModuleEvaluationOptions::TimeoutBehavior::kReturnPending; - } - if (args.Length() > 5 && args[5]->IsBoolean()) { - evaluationOptions.pumpRunLoop = args[5]->BooleanValue(isolate); + ModuleEvaluationOptions evaluationOptions = + RequireEvaluationOptions(ModuleEvaluationPolicy::kSyncStrict); + if (honorEvaluationOptions) { + // The require factory forwards the options its require was minted with; + // an absent policy is the strict default every ordinary require uses. + ModuleEvaluationPolicy policy = ModuleEvaluationPolicy::kSyncStrict; + if (args.Length() > 2 && args[2]->IsInt32() && + args[2].As()->Value() == + static_cast(ModuleEvaluationPolicy::kSyncPumping)) { + policy = ModuleEvaluationPolicy::kSyncPumping; + } + evaluationOptions = RequireEvaluationOptions(policy); + if (args.Length() > 3 && args[3]->IsNumber()) { + double deadlineSeconds = args[3].As()->Value(); + // A NaN or infinite window makes the pump's deadline arithmetic + // undefined, and a non-positive one is no window at all. + if (std::isfinite(deadlineSeconds) && deadlineSeconds > 0.0) { + evaluationOptions.deadlineSeconds = deadlineSeconds; + } + } + if (args.Length() > 4 && args[4]->IsBoolean()) { + evaluationOptions.timeoutBehavior = + args[4]->BooleanValue(isolate) + ? ModuleEvaluationOptions::TimeoutBehavior::kThrow + : ModuleEvaluationOptions::TimeoutBehavior::kReturnPending; + } + if (args.Length() > 5 && args[5]->IsBoolean()) { + evaluationOptions.pumpRunLoop = args[5]->BooleanValue(isolate); + } } auto moduleObj = LoadImpl(isolate, moduleName, callingModuleDirName, isData, evaluationOptions); @@ -460,10 +610,17 @@ void ModuleInternal::Load(Local context, const string& path) { // dereference a null native context. The require branch never needed this // because Function::Call enters the context it is handed. Context::Scope context_scope(context); - if (IsHttpModulePath(path) || IsESModule(path)) { + const bool isHttpModule = IsHttpModulePath(path); + if (isHttpModule || IsESModule(path)) { + if (isHttpModule) { + TNS_DEBUG(Esm, "run-module http-esm begin %s", NormalizeHttpModuleUrl(path).c_str()); + } // The entry runs before this thread's event loop does, so its graph can // only make progress from the pump inside LoadESModule. - LoadESModule(isolate, path, BootEntryEvaluationOptions(IsHttpModulePath(path))); + LoadESModule(isolate, path, BootEntryEvaluationOptions(isHttpModule)); + if (isHttpModule) { + TNS_DEBUG(Esm, "run-module http-esm ok %s", NormalizeHttpModuleUrl(path).c_str()); + } return; } auto globalObject = context->Global(); @@ -492,12 +649,32 @@ void ModuleInternal::LoadWorker(Local context, const string& path) { } } -void ModuleInternal::CheckFileExists(Isolate* isolate, const std::string& path, const std::string& baseDir) { +std::string ModuleInternal::CheckFileExists(Isolate* isolate, const std::string& path, const std::string& baseDir) { JEnv env; JniLocalRef jsModulename(env.NewStringUTF(path.c_str())); JniLocalRef jsBaseDir(env.NewStringUTF(baseDir.c_str())); - env.CallStaticObjectMethod(MODULE_CLASS, RESOLVE_PATH_METHOD_ID, (jstring) jsModulename, (jstring) jsBaseDir); + // Throws a NativeScriptException (through JEnv's pending-exception check) + // when nothing resolves, so the conversion below only ever sees a hit. + JniLocalRef jsModulePath(env.CallStaticObjectMethod(MODULE_CLASS, RESOLVE_PATH_METHOD_ID, + (jstring) jsModulename, + (jstring) jsBaseDir)); + + return ArgConverter::jstringToString((jstring) jsModulePath); +} + +// The trailing extension without its dot, or an empty string when the last +// segment has none. A leading dot names a hidden file, not an extension. +static std::string PathExtension(const std::string& path) { + size_t dot = path.find_last_of('.'); + if (dot == std::string::npos || dot + 1 >= path.size()) { + return std::string(); + } + size_t slash = path.find_last_of('/'); + if (slash != std::string::npos && (dot < slash || dot == slash + 1)) { + return std::string(); + } + return path.substr(dot + 1); } Local ModuleInternal::LoadImpl(Isolate* isolate, const string& moduleName, @@ -565,7 +742,7 @@ Local ModuleInternal::LoadImpl(Isolate* isolate, const string& moduleNam isData = true; result = LoadData(isolate, path); } else { - string errMsg = "Unsupported file extension: " + path; + string errMsg = "Unsupported file extension: " + PathExtension(path); throw NativeScriptException(errMsg); } } else { @@ -674,20 +851,23 @@ Local ModuleInternal::LoadModule(Isolate* isolate, const string& moduleP // async graph is refused rather than pumped. Local moduleNamespace = LoadESModule(isolate, modulePath, options); - // `module.exports` is what Node's populateCJSExportsFromESM produces for - // this namespace, not the namespace itself. A namespace can still be - // empty when the load bailed on a torn-down isolate; nothing to interop. - Local esmExports = moduleNamespace; - if (!moduleNamespace.IsEmpty() && moduleNamespace->IsObject()) { - esmExports = RequireExportsForNamespace(isolate, context, - moduleNamespace.As(), - CanonicalizeRegistryKey(modulePath)); + // A load that produced no namespace produced no module either — an + // isolate torn down mid-load, or a graph that never settled. Caching the + // empty exports object would intern that failure for the process. + if (moduleNamespace.IsEmpty()) { + throw NativeScriptException("ES module load returned empty value " + modulePath); } - if (!esmExports.IsEmpty()) { - moduleObj->Set(context, ArgConverter::ConvertToV8String(isolate, "exports"), - esmExports); + if (!moduleNamespace->IsObject()) { + throw NativeScriptException("Failed to load ES module " + modulePath); } + // `module.exports` is what Node's populateCJSExportsFromESM produces for + // this namespace, not the namespace itself. + Local esmExports = RequireExportsForNamespace(isolate, context, + moduleNamespace.As(), + CanonicalizeRegistryKey(modulePath)); + moduleObj->Set(context, ArgConverter::ConvertToV8String(isolate, "exports"), esmExports); + tempModule.SaveToCache(); result = moduleObj; return result; @@ -755,7 +935,7 @@ Local ModuleInternal::LoadModule(Isolate* isolate, const string& moduleP } moduleFunc = maybeFunc.ToLocalChecked(); } else { - string errMsg = "Unsupported file extension: " + modulePath; + string errMsg = "Unsupported file extension: " + PathExtension(modulePath); throw NativeScriptException(errMsg); } @@ -888,7 +1068,14 @@ MaybeLocal ModuleInternal::CompileFileEsModule(Isolate* isolate, const s } string url = "file://" + path; - string content = Runtime::GetRuntime(isolate)->ReadFileText(path); + // An exists-but-unreadable file, or one deleted between the stat above and + // the open, reads as "" — which compiles into a perfectly valid empty + // module unless the failure is told apart from an empty file here. + bool readOk = false; + string content = Runtime::GetRuntime(isolate)->ReadFileText(path, readOk); + if (!readOk) { + throw NativeScriptException("Cannot read module " + path); + } Local sourceText = ArgConverter::ConvertToV8String(isolate, content); @@ -905,11 +1092,81 @@ MaybeLocal ModuleInternal::CompileFileEsModule(Isolate* isolate, const s return ScriptCompiler::CompileModule(isolate, &source); } +// Phase diagnostics for one module's trip through the loader. +static void LogEsmPhase(const std::string& canonicalPath, const char* phase, const char* status, + const char* classification = "", const char* extra = "") { + if (classification && classification[0] != '\0') { + if (extra && extra[0] != '\0') { + TNS_DEBUG(Esm, "[%s][%s][%s] %s %s", phase, status, classification, + canonicalPath.c_str(), extra); + } else { + TNS_DEBUG(Esm, "[%s][%s][%s] %s", phase, status, classification, canonicalPath.c_str()); + } + } else { + if (extra && extra[0] != '\0') { + TNS_DEBUG(Esm, "[%s][%s] %s %s", phase, status, canonicalPath.c_str(), extra); + } else { + TNS_DEBUG(Esm, "[%s][%s] %s", phase, status, canonicalPath.c_str()); + } + } +} + +// A V8 module status as it appears in a trace line. +static const char* DescribeModuleStatus(Module::Status status) { + switch (status) { + case Module::kUninstantiated: + return "uninstantiated"; + case Module::kInstantiating: + return "instantiating"; + case Module::kInstantiated: + return "instantiated"; + case Module::kEvaluating: + return "evaluating"; + case Module::kEvaluated: + return "evaluated"; + case Module::kErrored: + return "errored"; + } + + return "unknown"; +} + +struct V8FailureRule { + const char* needle; + const char* label; +}; + +// What the message of a failed compile/link/evaluate says the failure was, for +// the trace line. Heuristic on purpose: V8 reports these as plain messages, so +// the rules are matched in order and the first hit wins. +static const char* ClassifyV8Failure(Isolate* isolate, TryCatch& tc, + std::initializer_list rules) { + if (!tc.HasCaught()) { + return "unknown"; + } + Local msg = tc.Message(); + if (msg.IsEmpty()) { + return "unknown"; + } + v8::String::Utf8Value text(isolate, msg->Get()); + if (*text == nullptr) { + return "unknown"; + } + std::string m(*text); + for (const V8FailureRule& rule : rules) { + if (m.find(rule.needle) != std::string::npos) { + return rule.label; + } + } + return "unknown"; +} + namespace { // `require()` cannot wait, so an async graph is refused rather than evaluated. // Never evicts: the module is perfectly loadable through import(). [[noreturn]] void ThrowAsyncGraphRefusal(const std::string& canonicalPath) { + LogEsmPhase(canonicalPath, "evaluate", "refused", "async-graph"); throw NativeScriptException("require() cannot load ES module '" + canonicalPath + "': the module graph contains top-level await. Use import() or " "createPumpingRequire from ns:module instead."); @@ -921,6 +1178,7 @@ namespace { // could never settle from here. Refused up front, before evaluation, so the // graph stays instantiated and import() can still load it. [[noreturn]] void ThrowMicrotaskPumpRefusal(const std::string& canonicalPath) { + LogEsmPhase(canonicalPath, "evaluate", "refused", "microtask-context"); throw NativeScriptException( "createPumpingRequire cannot settle module graph '" + canonicalPath + "' from inside a microtask (after an await or inside a promise callback): the event " @@ -932,8 +1190,17 @@ namespace { [[noreturn]] void ThrowModuleEvaluationRejection(Isolate* isolate, Local promise, TryCatch& tc, const std::string& canonicalPath) { - RemoveModuleFromRegistry(canonicalPath); - std::string detail = PromiseRejectionMessage(isolate, promise, canonicalPath); + RemoveModuleFromRegistry(isolate, canonicalPath); + LogEsmPhase(canonicalPath, "evaluate", "promise-rejected"); + const bool traceEsm = LogCategoryEnabled(LogCategory::Esm); + RejectionDetail rejection; + std::string detail = PromiseRejectionMessage(isolate, promise, canonicalPath, + traceEsm ? &rejection : nullptr); + if (traceEsm) { + TNS_DEBUG(Esm, "[evaluate][promise-rejected:detail] path=%s message=%s stack=%s", + canonicalPath.c_str(), rejection.message.c_str(), + rejection.stackPreview.c_str()); + } if (!tc.HasCaught()) { Local reason = promise->Result(); if (!reason.IsEmpty()) { @@ -974,19 +1241,27 @@ MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local co ThrowMicrotaskPumpRefusal(canonicalPath); } + LogEsmPhase(canonicalPath, "evaluate", "begin"); TryCatch tcEval(isolate); Local result; if (!module->Evaluate(context).ToLocal(&result)) { - RemoveModuleFromRegistry(canonicalPath); + RemoveModuleFromRegistry(isolate, canonicalPath); + LogEsmPhase(canonicalPath, "evaluate", "fail", + ClassifyV8Failure(isolate, tcEval, + {{"is not defined", "reference"}, + {"TypeError", "type"}, + {"Cannot read properties", "type-nullish"}})); if (tcEval.HasCaught()) { throw NativeScriptException(tcEval, "Cannot evaluate module " + canonicalPath); } throw NativeScriptException(string("Cannot evaluate module ") + canonicalPath); } + LogEsmPhase(canonicalPath, "evaluate", "ok"); if (!result->IsPromise()) { return MaybeLocal(); } + LogEsmPhase(canonicalPath, "evaluate", "promise"); Local promise = result.As(); if (options.policy == ModuleEvaluationPolicy::kAsync) { @@ -1009,6 +1284,7 @@ MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local co " left its evaluation promise pending on a graph reported " "as synchronous"); } + LogEsmPhase(canonicalPath, "evaluate", "promise-resolved"); return MaybeLocal(); } @@ -1046,6 +1322,7 @@ MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local co if (state == Promise::kRejected) { ThrowModuleEvaluationRejection(isolate, promise, promiseTc, canonicalPath); } + LogEsmPhase(canonicalPath, "evaluate", "promise-resolved"); break; } @@ -1059,10 +1336,12 @@ MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local co } } - if (!settled && promise->State() == Promise::kPending && - options.timeoutBehavior == ModuleEvaluationOptions::TimeoutBehavior::kThrow) { - RemoveModuleFromRegistry(canonicalPath); - throw NativeScriptException("Top-level await timed out for ES module " + canonicalPath); + if (!settled && promise->State() == Promise::kPending) { + LogEsmPhase(canonicalPath, "evaluate", "promise-timeout"); + if (options.timeoutBehavior == ModuleEvaluationOptions::TimeoutBehavior::kThrow) { + RemoveModuleFromRegistry(isolate, canonicalPath); + throw NativeScriptException("Top-level await timed out for ES module " + canonicalPath); + } } return MaybeLocal(); @@ -1127,8 +1406,19 @@ EntryEvaluationState ModuleInternal::PollEntryEvaluation(Isolate* isolate, const } if (rejectionReason != nullptr) { Local reason = promise->Result(); - *rejectionReason = - reason.IsEmpty() ? "" : ArgConverter::ToString(isolate, reason); + if (reason.IsEmpty()) { + *rejectionReason = ""; + } else { + // A reason whose `toString` throws — or a Symbol, which cannot be + // stringified at all — must not leave the isolate poisoned: this runs + // from the boot pump, where the caller has no exception to observe. + TryCatch tc(isolate); + *rejectionReason = ArgConverter::ToString(isolate, reason); + if (tc.HasCaught()) { + *rejectionReason = ""; + tc.Reset(); + } + } } return EntryEvaluationState::kRejected; } @@ -1147,9 +1437,15 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p const std::string canonicalPath = CanonicalizeRegistryKey(path); const std::string requestPath = isHttpModule ? NormalizeHttpModuleUrl(path) : canonicalPath; + auto logPhase = [&canonicalPath](const char* phase, const char* status, + const char* classification = "", const char* extra = "") { + LogEsmPhase(canonicalPath, phase, status, classification, extra); + }; + Local module; if (isHttpModule) { + logPhase("compile", "delegate-http"); RunModuleGraphLoadPumped(isolate, context, requestPath, kModuleEvaluateDeadlineSeconds); // The loader throws the classifier's reason (status, MIME or // transport); catch it so it lands in the message instead of staying @@ -1157,7 +1453,8 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p TryCatch tcLoad(isolate); MaybeLocal maybeMod = LoadHttpModuleForUrl(isolate, context, requestPath); if (!maybeMod.ToLocal(&module)) { - std::string message = "Cannot load ES module " + requestPath; + logPhase("compile", "fail", "http-loader"); + std::string message = "Cannot load ES module " + canonicalPath; if (tcLoad.HasCaught()) { throw NativeScriptException(tcLoad, message); } @@ -1168,6 +1465,7 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p } throw NativeScriptException(message); } + logPhase("compile", "ok", "http-loader"); if (module->GetStatus() == Module::kEvaluated) { // A top-level-await graph reports kEvaluated while its capability // promise is still pending, so the namespace here may be in its TDZ; @@ -1188,8 +1486,14 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p if (existingIt != registry.end()) { Local existing = existingIt->second.Get(isolate); Module::Status status = existing.IsEmpty() ? Module::kErrored : existing->GetStatus(); + if (existing.IsEmpty()) { + TNS_DEBUG(Esm, "[cache] dropping empty registry entry %s", canonicalPath.c_str()); + } else { + TNS_DEBUG(Esm, "[cache] hit %s status=%s", canonicalPath.c_str(), + DescribeModuleStatus(status)); + } if (status == Module::kErrored) { - RemoveModuleFromRegistry(canonicalPath); + RemoveModuleFromRegistry(isolate, canonicalPath); } else if (status == Module::kEvaluated) { // A top-level-await graph reports kEvaluated while its capability // promise is still pending, so the namespace here may be in its TDZ; @@ -1203,11 +1507,15 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p // Recompiling would mint a second module identity while importers still // hold this one; reuse it and let InstantiateModule below no-op // (kInstantiated) or link it (kUninstantiated). + logPhase("compile", "reuse-registry"); module = existing; } } + const bool reusedFromRegistry = !module.IsEmpty(); + if (module.IsEmpty()) { + logPhase("compile", "begin"); // Discovery pre-pass for local roots too: a local graph can reach HTTP // edges, and without this they hit the resolver cold and fetch serially, // one blocking request at a time. The walk compiles and registers the @@ -1228,6 +1536,12 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p if (module.IsEmpty()) { TryCatch tcCompile(isolate); if (!CompileFileEsModule(isolate, canonicalPath).ToLocal(&module)) { + logPhase("compile", "fail", + ClassifyV8Failure( + isolate, tcCompile, + {{"Unexpected token", "syntax"}, + {"SyntaxError", "syntax"}, + {"Cannot use import statement outside a module", "not-a-module"}})); if (tcCompile.HasCaught()) { throw NativeScriptException(tcCompile, "Cannot compile ES module " + canonicalPath); } else { @@ -1237,26 +1551,42 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p UnindexModuleForIsolate(isolate, canonicalPath); auto it = registry.find(canonicalPath); + if (requestPath != canonicalPath || path != canonicalPath) { + TNS_DEBUG(Esm, "[register] raw=%s request=%s canonical=%s existing=%s", path.c_str(), + requestPath.c_str(), canonicalPath.c_str(), + it != registry.end() ? "yes" : "no"); + } if (it != registry.end()) { it->second.Reset(); } registry[canonicalPath].Reset(isolate, module); IndexModuleForIsolate(isolate, canonicalPath, module); } + if (!reusedFromRegistry) { + logPhase("compile", "ok"); + } } // Instantiate (link) with ResolveModuleCallback if (module->GetStatus() < Module::kInstantiated) { + logPhase("instantiate", "begin"); TryCatch tcLink(isolate); bool linked = module->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false); if (!linked) { + logPhase("instantiate", "fail", + ClassifyV8Failure( + isolate, tcLink, + {{"Cannot find module", "resolve"}, + {"failed to resolve module specifier", "resolve"}, + {"does not provide an export named", "link-export"}})); if (tcLink.HasCaught()) { throw NativeScriptException(tcLink, "Cannot instantiate module " + canonicalPath); } else { throw NativeScriptException(string("Cannot instantiate module ") + canonicalPath); } } + logPhase("instantiate", "ok"); } // Evaluate the graph under the caller's options. diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.h b/test-app/runtime/src/main/cpp/ModuleInternal.h index 7ca4230a1..9f96d9a4c 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.h +++ b/test-app/runtime/src/main/cpp/ModuleInternal.h @@ -17,8 +17,10 @@ namespace tns { // The single deadline for every module-graph settle wait: the entry -// top-level-await pump in LoadESModule and the pumped module-graph walk. One -// knob, so the waits stay ordered — transport timeouts < this. +// top-level-await pump in LoadESModule, the pumped module-graph walk, and +// (doubled, as the outermost backstop) the app-boot handoff in Runtime.cpp. +// One knob, so the waits stay ordered: transport timeouts < this < the boot +// backstop. inline constexpr double kModuleEvaluateDeadlineSeconds = 60.0; // How a module graph's evaluation promise is settled. @@ -31,9 +33,11 @@ inline constexpr double kModuleEvaluateDeadlineSeconds = 60.0; // kAsync - evaluate and hand the caller the capability promise. enum class ModuleEvaluationPolicy { kSyncStrict, kSyncPumping, kAsync }; -// The state of an entry module's evaluation promise. kNone means the path -// names no registered ES module — a classic script settles synchronously and -// never has one, so it needs no boot backstop. +// The state of an entry module's evaluation promise. kNone covers everything +// that has no promise to report on: a path naming no registered ES module (a +// classic script settles synchronously and never has one, so it needs no boot +// backstop), a torn-down isolate with no registry left, a module that has not +// reached kEvaluated, and an Evaluate() that failed or returned a non-promise. enum class EntryEvaluationState { kNone, kPending, kFulfilled, kRejected }; struct ModuleEvaluationOptions { @@ -76,17 +80,20 @@ class ModuleInternal { void LoadWorker(v8::Local context, const std::string& path); /* - * Checks if target script exists, will throw if negative - * Used before initializing workers, to ensure a thread will not be created, when the file doesn't exist + * Resolves `path` against `baseDir` through the Java module resolver and returns the + * canonical resolved path - extension (.js then .mjs) and directory/index resolution + * included. Throws when nothing resolves. + * Used before initializing workers, both to ensure a thread will not be created when the + * file doesn't exist and to hand the worker the very file that was validated here. */ - static void CheckFileExists(v8::Isolate* isolate, const std::string& path, const std::string& baseDir); + static std::string CheckFileExists(v8::Isolate* isolate, const std::string& path, const std::string& baseDir); // Helper functions for ES module support static bool IsESModule(const std::string& path); /* - * Compile/link/evaluate an ES module; returns its namespace object. `policy` - * decides how the graph's evaluation promise is settled — see + * Compile/link/evaluate an ES module; returns its namespace object. `options` + * decide how the graph's evaluation promise is settled — see * ModuleEvaluationPolicy. */ static v8::Local LoadESModule(v8::Isolate* isolate, const std::string& path, @@ -103,7 +110,8 @@ class ModuleInternal { /* * Read + compile `path` as an ES module WITHOUT registering, instantiating or * evaluating it. On compile failure the exception is left pending on the isolate - * (or a NativeScriptException is thrown for setup failures) and the result is empty. + * and the result is empty; a NativeScriptException is thrown instead when the + * file does not exist, cannot be read, or the compile could not be set up. * This is the resolver's file loader: the resolver must only ever hand V8 a * compiled module — evaluation order belongs to V8. */ @@ -153,13 +161,29 @@ class ModuleInternal { v8::Persistent* obj; }; + /* + * The require the require factory is handed. It honours the evaluation + * options the factory forwards as trailing arguments, so it must never be + * reachable from app code — see RequirePublicCallback. + */ static void RequireCallback(const v8::FunctionCallbackInfo& args); + /* + * The require installed on the global. Reads the specifier and the calling + * directory only, and always evaluates under the strict defaults. + */ + static void RequirePublicCallback(const v8::FunctionCallbackInfo& args); + + // Argument validation and the C++/V8 exception boundary shared by both. + static void DispatchRequire(const v8::FunctionCallbackInfo& args, + bool honorEvaluationOptions); + static void RequireNativeCallback(const v8::FunctionCallbackInfo& args); static void CreateRequireCallback(const v8::FunctionCallbackInfo& args); - void RequireCallbackImpl(const v8::FunctionCallbackInfo& args); + void RequireCallbackImpl(const v8::FunctionCallbackInfo& args, + bool honorEvaluationOptions); v8::Local WrapModuleContent(const std::string& path); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index d986c55b3..8c5926010 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -9,17 +9,14 @@ #include #include #include -#include #include #include #include -#include #include #include #include #include "ArgConverter.h" -#include "Constants.h" #include "EventLoop.h" #include "HttpLoader.h" #include "JEnv.h" @@ -31,7 +28,6 @@ #include "Runtime.h" #include "RuntimeState.h" #include "TraceLog.h" -#include "Util.h" #include "robin_hood.h" using namespace v8; @@ -195,31 +191,7 @@ static std::string ResolveHttpRelative(const std::string& referrerUrl, return origin + NormalizeDotSegments(newPath) + specSuffix; } -// Resolve a relative "./" or "../" specifier against a file:// referrer URL. -// Returns an absolute file:// URL, or empty when not applicable. Preserved -// for parity with the earlier Android loader; the current resolver builds -// filesystem candidates directly against GetApplicationPath() so this helper -// is unused for now. -[[maybe_unused]] static std::string ResolveFileRelative( - const std::string& referrerUrl, const std::string& spec) { - const std::string filePrefix = "file://"; - if (!StartsWith(referrerUrl, filePrefix.c_str())) return std::string(); - if (spec.empty() || spec[0] != '.') return std::string(); - std::string refPath = referrerUrl.substr(filePrefix.size()); - size_t hashPos = refPath.find('#'); - if (hashPos != std::string::npos) refPath = refPath.substr(0, hashPos); - size_t qPos = refPath.find('?'); - if (qPos != std::string::npos) refPath = refPath.substr(0, qPos); - size_t lastSlash = refPath.find_last_of('/'); - std::string baseDir = (lastSlash == std::string::npos) - ? std::string("/") - : refPath.substr(0, lastSlash + 1); - return filePrefix + NormalizeDotSegments(baseDir + spec); -} - // Forward declarations for helpers referenced before their definitions. -static bool ShouldTraceRegistryKey(const std::string& rawKey, - const std::string& registryKey); static const char* ModuleStatusToString(v8::Module::Status status); static void KillAsyncGraphLoadsForIsolate(v8::Isolate* isolate); static bool IsCurrentIsolateWorker(v8::Isolate* isolate); @@ -546,39 +518,6 @@ static v8::MaybeLocal AdoptThenable(v8::Isolate* isolate, // ───────────────────────────────────────────────────────────── // Compile helpers -static v8::MaybeLocal CompileModuleFromSource( - v8::Isolate* isolate, v8::Local context, - const std::string& code, const std::string& urlStr) { - v8::EscapableHandleScope hs(isolate); - // NUL-preserving conversion: module source may contain embedded NUL bytes; - // the char* path would truncate. - v8::Local sourceText = ArgConverter::ConvertToV8String(isolate, code); - v8::Local urlV8; - if (!v8::String::NewFromUtf8(isolate, urlStr.c_str(), - v8::NewStringType::kNormal) - .ToLocal(&urlV8)) { - return v8::MaybeLocal(); - } - v8::ScriptOrigin origin(urlV8, 0, 0, false, -1, v8::Local(), - false, false, true /* is_module */); - v8::ScriptCompiler::Source src(sourceText, origin); - v8::Local mod; - if (!v8::ScriptCompiler::CompileModule(isolate, &src).ToLocal(&mod)) { - return v8::MaybeLocal(); - } - if (mod->GetStatus() == v8::Module::kUninstantiated) { - if (!mod->InstantiateModule(context, &ResolveModuleCallback).FromMaybe(false)) { - return v8::MaybeLocal(); - } - } - if (mod->GetStatus() != v8::Module::kEvaluated) { - if (mod->Evaluate(context).IsEmpty()) { - return v8::MaybeLocal(); - } - } - return hs.Escape(mod); -} - // "message (line L:C)" for a caught exception, or empty. The line/column are // the part no caller can reconstruct from a failure code. static std::string DescribeCaughtError(v8::Isolate* isolate, @@ -617,11 +556,6 @@ static v8::MaybeLocal CompileModuleForResolveRegisterOnly( } auto& registry = moduleState->registry; const std::string registryKey = CanonicalizeRegistryKey(urlStr); - if (LogCategoryEnabled(LogCategory::Esm) && - ShouldTraceRegistryKey(urlStr, registryKey)) { - TNS_DEBUG(Esm, "[resolver][register-resolve-only] raw=%s key=%s", - urlStr.c_str(), registryKey.c_str()); - } // Checked before compiling: recompiling a key that is already registered // would mint a second module identity while importers hold the first. @@ -680,13 +614,6 @@ static LoaderVocabulary* VocabularyForCurrentIsolate() { return state != nullptr ? &state->vocabulary : nullptr; } -static bool ShouldTraceRegistryKey(const std::string& rawKey, - const std::string& registryKey) { - if (rawKey != registryKey) return true; - return StartsWith(registryKey, "node:") || - StartsWith(registryKey, "blob:"); -} - std::string CanonicalizeRegistryKey(const std::string& key) { if (key.empty()) return key; @@ -748,7 +675,7 @@ v8::MaybeLocal LoadHttpModuleForUrl(v8::Isolate* isolate, return v8::MaybeLocal(existing); } TNS_DEBUG(Esm, "[http-esm][load][drop-errored] key=%s", registryKey.c_str()); - RemoveModuleFromRegistry(registryKey); + RemoveModuleFromRegistry(isolate, registryKey); } // Reaching this point means the graph walk did not discover this URL, so the @@ -995,7 +922,7 @@ bool SetImportMap(const std::string& json, std::string* error) { std::string localError; std::string& err = error != nullptr ? *error : localError; if (vocabulary == nullptr) { - err = "the calling isolate has no loader vocabulary"; + err = "the isolate is shutting down"; return false; } @@ -1013,6 +940,13 @@ bool SetImportMap(const std::string& json, std::string* error) { return true; } +bool ValidateImportMapJson(const std::string& json, std::string* error) { + std::string localError; + ParsedImportMap parsedMap; + return ParseImportMap(v8::Isolate::GetCurrent(), json, &parsedMap, + error != nullptr ? error : &localError); +} + void SetVolatilePatterns(const std::vector& patterns) { LoaderVocabulary* vocabulary = VocabularyForCurrentIsolate(); if (vocabulary == nullptr) return; @@ -1134,11 +1068,8 @@ static std::string LookupImportMap(const LoaderVocabulary& vocabulary, // the same registry key whichever of them reaches it first — a divergence here // mints two identities for one file. // -// It consults the import map and the filesystem but never compiles, registers, -// fetches or throws. The one V8 touch is the `__NS_HTTP_ORIGIN__` global read -// for root-absolute specifiers, which is why `context` is a parameter: both -// callers must see the same anchor or they would classify the same specifier -// differently. +// Pure computation: it consults the import map and the filesystem, but never +// compiles, registers, fetches, or throws. struct ModuleResolution { enum class Kind { kUnresolved, // nothing locatable; the caller decides how to report it @@ -1172,38 +1103,9 @@ static std::string HttpUrlEmbeddedInPath(const std::string& p) { return tail; } -// The origin the dev client is serving from, or empty. Anchors root-absolute -// specifiers imported by a module that itself came off disk. -static std::string HttpOriginAnchor(v8::Isolate* isolate, - v8::Local context) { - if (context.IsEmpty()) return std::string(); - // Reading a JS global can run a getter; resolution must stay side-effect - // free from the caller's point of view, so an exception here is swallowed - // rather than left pending on a resolver or walk frame. - v8::TryCatch tc(isolate); - v8::Local originVal; - if (!context->Global() - ->Get(context, - ArgConverter::ConvertToV8String(isolate, "__NS_HTTP_ORIGIN__")) - .ToLocal(&originVal) || - !originVal->IsString()) { - return std::string(); - } - v8::String::Utf8Value o8(isolate, originVal); - std::string origin = *o8 ? *o8 : ""; - if (origin.empty() || - !(StartsWith(origin, "http://") || StartsWith(origin, "https://"))) { - return std::string(); - } - if (origin.back() != '/') origin += '/'; - return origin; -} - // `referrerKey` is the registry key of the importing module — empty when the // importer is unknown (a dynamic import with no compiled referrer). -static ModuleResolution ResolveSpecifierToPath(v8::Isolate* isolate, - v8::Local context, - const std::string& rawSpec, +static ModuleResolution ResolveSpecifierToPath(const std::string& rawSpec, const std::string& referrerKey) { ModuleResolution result; if (rawSpec.empty()) return result; @@ -1226,14 +1128,24 @@ static ModuleResolution ResolveSpecifierToPath(v8::Isolate* isolate, spec.insert(6, "/"); } + // Query and fragment only mean something to a server, so a non-http + // specifier drops them before anything looks it up. Applied here, in the one + // seam both import forms go through, so `./x.js?v=1` names the same module + // whether it arrives as a static import or an import(). + if (!(StartsWith(spec, "http://") || StartsWith(spec, "https://"))) { + size_t cut = spec.find_first_of("?#"); + if (cut != std::string::npos) spec = spec.substr(0, cut); + if (spec.empty()) return result; + } + TNS_DEBUG(Esm, "[resolver][spec] %s", spec.c_str()); // The import map is consulted before any other resolution: bare specifiers // resolve through it to vendor or HTTP URLs. A client that rewrites // specifiers must map every form it emits — keys are matched literally. - auto* moduleState = ModuleLoaderStateFor(isolate); - if (moduleState != nullptr && !moduleState->vocabulary.importMap.empty()) { - const LoaderVocabulary& vocabulary = moduleState->vocabulary; + const LoaderVocabulary* vocabularyPtr = VocabularyForCurrentIsolate(); + if (vocabularyPtr != nullptr && !vocabularyPtr->importMap.empty()) { + const LoaderVocabulary& vocabulary = *vocabularyPtr; std::string mapped = LookupImportMap(vocabulary, spec, referrerKey); if (!mapped.empty()) { TNS_DEBUG(Esm, "[resolver][import-map] rewrite: %s -> %s", spec.c_str(), @@ -1290,18 +1202,6 @@ static ModuleResolution ResolveSpecifierToPath(v8::Isolate* isolate, result.url = resolvedHttp; return result; } - } else if (!referrerIsHttp && specIsRootAbs) { - std::string origin = HttpOriginAnchor(isolate, context); - if (!origin.empty()) { - std::string resolved = ResolveHttpRelative(origin, spec); - if (StartsWith(resolved, "http://") || StartsWith(resolved, "https://")) { - TNS_DEBUG(Esm, "[resolver][http-origin][fallback] origin=%s spec=%s -> %s", - origin.c_str(), spec.c_str(), resolved.c_str()); - result.kind = ModuleResolution::Kind::kHttp; - result.url = resolved; - return result; - } - } } // Build the filesystem candidates for this specifier shape. The specifier may @@ -1564,7 +1464,7 @@ static void AsyncGraphWalkModuleRequests( // subtree is therefore not discovered here; any HTTP edge inside it is // pathological and lands on the synchronous anomaly guard. const ModuleResolution resolution = - ResolveSpecifierToPath(isolate, context, *specUtf8, moduleKey); + ResolveSpecifierToPath(*specUtf8, moduleKey); if (resolution.kind != ModuleResolution::Kind::kHttp && resolution.kind != ModuleResolution::Kind::kFile) { continue; @@ -1635,12 +1535,16 @@ static void AsyncGraphOnFetchCompleted( v8::TryCatch tcJson(isolate); if (CompileJsonTextAsEsModule(isolate, context, fetched->body, key, url) .IsEmpty()) { + std::string reason = DescribeCaughtError(isolate, context, tcJson); if (isRoot) { load->failed = true; load->failureMessage = "JSON module failed to compile: " + url; + if (!reason.empty()) { + load->failureMessage += " — " + reason; + } } else { - TNS_DEBUG(Esm, "[graph][dep-json-fail] %s (left to sync resolver)", - url.c_str()); + TNS_DEBUG(Esm, "[graph][dep-json-fail] %s %s (left to sync resolver)", + url.c_str(), reason.c_str()); } } else { load->compiledCount++; @@ -1751,7 +1655,7 @@ static void AsyncGraphEnqueue(const std::shared_ptr& load, return; // instantiated/evaluated → its closure is already resolved } // Errored entry: drop and reload, mirroring LoadHttpModuleForUrl. - RemoveModuleFromRegistry(key); + RemoveModuleFromRegistry(isolate, key); } if (!isHttp) { @@ -1895,10 +1799,8 @@ static const char* ModuleStatusToString(v8::Module::Status status) { return "Unknown"; } -void RemoveModuleFromRegistry(const std::string& canonicalPath) { - // Only ever called on an isolate's own JS thread during module - // resolution/loading, so the entered isolate owns the maps to mutate. - v8::Isolate* isolate = v8::Isolate::GetCurrent(); +void RemoveModuleFromRegistry(v8::Isolate* isolate, + const std::string& canonicalPath) { auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) return; auto& registry = moduleState->registry; @@ -1995,7 +1897,7 @@ void InvalidateModules(v8::Isolate* isolate, v8::Local context, TNS_DEBUG(Registry, "invalidate %s key=%s", present ? "HIT " : "MISS", url.c_str()); RejectAndClearInvalidatedModuleState(isolate, context, url); - RemoveModuleFromRegistry(url); + RemoveModuleFromRegistry(isolate, url); } // Second layer: the OS HTTP cache is outside our control and may serve @@ -2065,6 +1967,13 @@ static void RejectResolversWithReason( } } +// Park `resolver` on `registryKey`'s waiter list when that key is mid-flight. +// +// A queued waiter is settled by exactly one thing: the top-level-await +// continuation the pass that began evaluating this key attached to the +// module's capability promise. Every site that queues a waiter and returns +// while the module is already kEvaluating therefore depends on such a pass +// existing for that key. static bool QueueHttpDynamicWaiterIfInFlight( v8::Isolate* isolate, const std::string& registryKey, v8::Local module, v8::Local resolver) { @@ -2283,6 +2192,10 @@ static v8::MaybeLocal CompileJsonAsEsModule( // graph walk; what stays here is the V8-facing half — serving builtins, // delegating HTTP, and compiling + registering a file. +static v8::MaybeLocal LoadResolvedModule( + v8::Isolate* isolate, v8::Local context, + const ModuleResolution& resolution); + v8::MaybeLocal ResolveModuleCallback( v8::Local context, v8::Local specifier, v8::Local /*import_assertions*/, @@ -2292,28 +2205,44 @@ v8::MaybeLocal ResolveModuleCallback( if (moduleState == nullptr) { return v8::MaybeLocal(); } - auto& registry = moduleState->registry; v8::String::Utf8Value specUtf8(isolate, specifier); const std::string rawSpec = *specUtf8 ? *specUtf8 : ""; if (rawSpec.empty()) return v8::MaybeLocal(); - const bool isWorker = IsCurrentIsolateWorker(isolate); const std::string referrerPath = FindKeyForModule(*moduleState, isolate, referrer); - const ModuleResolution resolution = - ResolveSpecifierToPath(isolate, context, rawSpec, referrerPath); + return LoadResolvedModule(isolate, context, + ResolveSpecifierToPath(rawSpec, referrerPath)); +} + +// The loading half of resolution: everything that happens once a specifier has +// become a ModuleResolution. Split out so dynamic import() can route an +// already-resolved specifier here instead of resolving the string a second +// time — resolving twice would apply the import map twice, and the second pass +// has no referrer, so the two passes need not even agree. +static v8::MaybeLocal LoadResolvedModule( + v8::Isolate* isolate, v8::Local context, + const ModuleResolution& resolution) { + auto* moduleState = ModuleLoaderStateFor(isolate); + if (moduleState == nullptr) { + return v8::MaybeLocal(); + } + auto& registry = moduleState->registry; + const bool isWorker = IsCurrentIsolateWorker(isolate); switch (resolution.kind) { case ModuleResolution::Kind::kBuiltin: { v8::Local builtin; - if (NsBuiltinModules::GetModule(context, rawSpec).ToLocal(&builtin)) { + if (NsBuiltinModules::GetModule(context, resolution.specifier) + .ToLocal(&builtin)) { return v8::MaybeLocal(builtin); } - if (!NsBuiltinModules::IsRegistered(rawSpec)) { + if (!NsBuiltinModules::IsRegistered(resolution.specifier)) { isolate->ThrowException( v8::Exception::Error(ArgConverter::ConvertToV8String( - isolate, NsBuiltinModules::NotFoundMessage(rawSpec)))); + isolate, + NsBuiltinModules::NotFoundMessage(resolution.specifier)))); } return v8::MaybeLocal(); } @@ -2354,7 +2283,7 @@ v8::MaybeLocal ResolveModuleCallback( ModuleStatusToString(existing->GetStatus())); return v8::MaybeLocal(existing); } - RemoveModuleFromRegistry(absPath); + RemoveModuleFromRegistry(isolate, absPath); } // Compile + register only — never instantiate or evaluate here. V8 is @@ -2417,12 +2346,13 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, v8::TryCatch tcInstantiate(isolate); if (!mod->InstantiateModule(context, &ResolveModuleCallback) .FromMaybe(false)) { - RemoveModuleFromRegistry(key); - RejectHttpDynamicWaiters( - isolate, context, key, + RemoveModuleFromRegistry(isolate, key); + v8::Local reason = BuildModuleFailureReason(isolate, tcInstantiate, "Instantiation failed (http-loader)", - requestUrl)); + requestUrl); + tcInstantiate.Reset(); + RejectHttpDynamicWaiters(isolate, context, key, reason); return; } } @@ -2439,12 +2369,13 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, { v8::TryCatch tcEvaluate(isolate); if (!mod->Evaluate(context).ToLocal(&evalResult)) { - RemoveModuleFromRegistry(key); - RejectHttpDynamicWaiters( - isolate, context, key, + RemoveModuleFromRegistry(isolate, key); + v8::Local reason = BuildModuleFailureReason(isolate, tcEvaluate, "Evaluation failed (http-loader)", - requestUrl)); + requestUrl); + tcEvaluate.Reset(); + RejectHttpDynamicWaiters(isolate, context, key, reason); return; } } @@ -2508,7 +2439,8 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, v8::kExternalPointerTypeTagDefault)); v8::Local thenReject2 = thenRejectTpl2->GetFunction(context).ToLocalChecked(); - p->Then(context, thenFulfill2, thenReject2).ToLocalChecked(); + p->Then(context, thenFulfill2, thenReject2) + .FromMaybe(v8::Local()); return; } } @@ -2528,13 +2460,13 @@ static void FinishHttpDynamicImport(v8::Isolate* isolate, // ───────────────────────────────────────────────────────────── // ImportModuleDynamicallyCallback — host callback for `import()` expressions. // -// Structure mirrors iOS: builtins → import-map → invalid-'@' guard → blob URL -// path → HTTP fast path (with coalescing + cache) → filesystem resolution via -// ResolveModuleCallback → instantiate/evaluate/TLA settle. +// Structure: builtins → one pass through the shared resolution seam → blob URL +// path → HTTP fast path (with coalescing + cache) → local module load → +// instantiate/evaluate/TLA settle. v8::MaybeLocal ImportModuleDynamicallyCallback( v8::Local context, v8::Local /*host_defined_options*/, v8::Local resource_name, v8::Local specifier, - v8::Local import_assertions) { + v8::Local /*import_assertions*/) { v8::Isolate* isolate = v8::Isolate::GetCurrent(); auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) { @@ -2561,8 +2493,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( // Builtin modules never touch the loader below; the namespace comes straight // from the realm's synthetic module. - if (NsBuiltinModules::IsRegistered(rawSpec) || - NsBuiltinModules::IsNsScheme(rawSpec)) { + if (NsBuiltinModules::IsBuiltinScheme(rawSpec)) { v8::EscapableHandleScope builtinScope(isolate); v8::Local builtinResolver; if (!v8::Promise::Resolver::New(context).ToLocal(&builtinResolver)) { @@ -2598,7 +2529,6 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( } } if (normalizedSpec != rawSpec) { - specifier = ArgConverter::ConvertToV8String(isolate, normalizedSpec); TNS_DEBUG(Esm, "[dyn-import][normalize] %s -> %s", rawSpec.c_str(), normalizedSpec.c_str()); } @@ -2610,28 +2540,29 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( return v8::MaybeLocal(); } - // ── Import map resolution for dynamic import() ── - // The same scoped lookup the resolver and the walk use. The referrer key - // comes from the host-supplied resource name, canonicalized the way the - // registry keys it, so a scope matches an import() exactly as it matches a - // static import from the same module. + // ── Resolution for dynamic import() ── + // The specifier goes through the shared seam exactly once, with the referrer + // key taken from the host-supplied resource name and canonicalized the way + // the registry keys it: a scope matches an import() exactly as it matches a + // static import from the same module. Everything below routes THIS + // resolution — re-resolving the string further down would run the import map + // a second time, and that pass would have no referrer. const LoaderVocabulary& vocabulary = moduleState->vocabulary; - if (!vocabulary.importMap.empty() && !normalizedSpec.empty()) { - std::string dynamicReferrerKey; - if (!resource_name.IsEmpty() && resource_name->IsString()) { - v8::String::Utf8Value resourceUtf8(isolate, resource_name); - if (*resourceUtf8) { - dynamicReferrerKey = CanonicalizeRegistryKey(*resourceUtf8); - } - } - std::string mapped = LookupImportMap(vocabulary, normalizedSpec, dynamicReferrerKey); - if (!mapped.empty()) { - normalizedSpec = mapped; - specifier = ArgConverter::ConvertToV8String(isolate, normalizedSpec); - TNS_DEBUG(Esm, "[dyn-import][import-map] rewrite: %s -> %s", - rawSpec.c_str(), normalizedSpec.c_str()); + std::string dynamicReferrerKey; + if (!resource_name.IsEmpty() && resource_name->IsString()) { + v8::String::Utf8Value resourceUtf8(isolate, resource_name); + if (*resourceUtf8) { + dynamicReferrerKey = CanonicalizeRegistryKey(*resourceUtf8); } } + const ModuleResolution dynamicResolution = + ResolveSpecifierToPath(normalizedSpec, dynamicReferrerKey); + if (!dynamicResolution.specifier.empty() && + dynamicResolution.specifier != normalizedSpec) { + normalizedSpec = dynamicResolution.specifier; + TNS_DEBUG(Esm, "[dyn-import][import-map] rewrite: %s -> %s", + rawSpec.c_str(), normalizedSpec.c_str()); + } try { // ── Blob URL support (e.g. blob:nativescript/) ── @@ -2653,7 +2584,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( blobRegistryKey.c_str(), ModuleStatusToString(existingStatus)); if (existingStatus == v8::Module::kErrored) { - RemoveModuleFromRegistry(blobRegistryKey); + RemoveModuleFromRegistry(isolate, blobRegistryKey); } else if (IsModuleEvaluationInProgress(existingStatus)) { modulesInFlight.insert(blobRegistryKey); httpDynamicWaiters[blobRegistryKey].emplace_back(isolate, resolver); @@ -2667,7 +2598,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( return scope.Escape(resolver->GetPromise()); } } else { - RemoveModuleFromRegistry(blobRegistryKey); + RemoveModuleFromRegistry(isolate, blobRegistryKey); } } @@ -2872,16 +2803,19 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( return; } - if (mod->GetStatus() == v8::Module::kUninstantiated && - !mod->InstantiateModule(ctx, &ResolveModuleCallback) - .FromMaybe(false)) { - RemoveModuleFromRegistry(d->registryKey); - RejectHttpDynamicWaiters( - iso, ctx, d->registryKey, - v8::Exception::Error(ArgConverter::ConvertToV8String( - iso, "Failed to instantiate blob module"))); - delete d; - return; + if (mod->GetStatus() == v8::Module::kUninstantiated) { + v8::TryCatch tcInstantiate(iso); + if (!mod->InstantiateModule(ctx, &ResolveModuleCallback) + .FromMaybe(false)) { + RemoveModuleFromRegistry(iso, d->registryKey); + v8::Local reason = BuildModuleFailureReason( + iso, tcInstantiate, "Failed to instantiate blob module", + d->registryKey); + tcInstantiate.Reset(); + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, reason); + delete d; + return; + } } if (IsModuleEvaluationInProgress(mod->GetStatus())) { @@ -2894,14 +2828,18 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( if (mod->GetStatus() != v8::Module::kEvaluated) { v8::Local evalResult; - if (!mod->Evaluate(ctx).ToLocal(&evalResult)) { - RemoveModuleFromRegistry(d->registryKey); - RejectHttpDynamicWaiters( - iso, ctx, d->registryKey, - v8::Exception::Error(ArgConverter::ConvertToV8String( - iso, "Failed to evaluate blob module"))); - delete d; - return; + { + v8::TryCatch tcEvaluate(iso); + if (!mod->Evaluate(ctx).ToLocal(&evalResult)) { + RemoveModuleFromRegistry(iso, d->registryKey); + v8::Local reason = BuildModuleFailureReason( + iso, tcEvaluate, "Failed to evaluate blob module", + d->registryKey); + tcEvaluate.Reset(); + RejectHttpDynamicWaiters(iso, ctx, d->registryKey, reason); + delete d; + return; + } } if (!evalResult.IsEmpty() && evalResult->IsPromise()) { @@ -2943,7 +2881,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( : v8::Exception::Error( ArgConverter::ConvertToV8String( iso, "Blob module evaluation failed")); - RemoveModuleFromRegistry(d->registryKey); + RemoveModuleFromRegistry(iso, d->registryKey); RejectHttpDynamicWaiters(iso, ctx, d->registryKey, reason); delete d; }; @@ -3027,7 +2965,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( auto ex = registry.find(key); if (ex != registry.end()) { TNS_DEBUG(Esm, "[dyn-import][http-cache] drop volatile %s", key.c_str()); - RemoveModuleFromRegistry(key); + RemoveModuleFromRegistry(isolate, key); } } // Coalesce concurrent dynamic imports for the same HTTP key. @@ -3048,7 +2986,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( if (st == v8::Module::kErrored) { TNS_DEBUG(Esm, "[dyn-import][http-cache] dropping errored module for %s", key.c_str()); - RemoveModuleFromRegistry(key); + RemoveModuleFromRegistry(isolate, key); } else if (IsModuleEvaluationInProgress(st)) { if (QueueHttpDynamicWaiterIfInFlight(isolate, key, existing, resolver)) { @@ -3070,12 +3008,12 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( v8::TryCatch tcInstantiate(isolate); if (!existing->InstantiateModule(context, &ResolveModuleCallback) .FromMaybe(false)) { - RemoveModuleFromRegistry(key); - RejectHttpDynamicWaiters( - isolate, context, key, - BuildModuleFailureReason( - isolate, tcInstantiate, - "Instantiation failed (http-cache hit)", key)); + RemoveModuleFromRegistry(isolate, key); + v8::Local reason = BuildModuleFailureReason( + isolate, tcInstantiate, + "Instantiation failed (http-cache hit)", key); + tcInstantiate.Reset(); + RejectHttpDynamicWaiters(isolate, context, key, reason); return scope.Escape(resolver->GetPromise()); } } @@ -3088,12 +3026,12 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( { v8::TryCatch tcEvaluate(isolate); if (!existing->Evaluate(context).ToLocal(&evalResult)) { - RemoveModuleFromRegistry(key); - RejectHttpDynamicWaiters( - isolate, context, key, - BuildModuleFailureReason( - isolate, tcEvaluate, - "Evaluation failed (http-cache hit)", key)); + RemoveModuleFromRegistry(isolate, key); + v8::Local reason = BuildModuleFailureReason( + isolate, tcEvaluate, "Evaluation failed (http-cache hit)", + key); + tcEvaluate.Reset(); + RejectHttpDynamicWaiters(isolate, context, key, reason); return scope.Escape(resolver->GetPromise()); } } @@ -3162,7 +3100,8 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( v8::kExternalPointerTypeTagDefault)); v8::Local thenReject = thenRejectTpl->GetFunction(context).ToLocalChecked(); - p->Then(context, thenFulfill, thenReject).ToLocalChecked(); + p->Then(context, thenFulfill, thenReject) + .FromMaybe(v8::Local()); return scope.Escape(resolver->GetPromise()); } ResolveHttpDynamicWaiters(isolate, context, key, existing); @@ -3195,70 +3134,27 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( return scope.Escape(resolver->GetPromise()); } - // ── Filesystem path ── - // For relative specs, adjust against the referrer's resource URL so - // ../-segments collapse and the resolver can find the target on disk. - v8::Local refMod; - v8::Local adjustedSpecifier = specifier; - if (!normalizedSpec.empty() && - (normalizedSpec.rfind("./", 0) == 0 || - normalizedSpec.rfind("../", 0) == 0)) { - v8::Local resName = resource_name; - if (!resName.IsEmpty() && resName->IsString()) { - v8::String::Utf8Value rn(isolate, resName); - std::string refUrl = *rn ? *rn : std::string(); - if (!refUrl.empty()) { - std::string refPath = FileURLToPath(refUrl); - size_t slash = refPath.find_last_of("/\\"); - std::string baseDir = slash == std::string::npos - ? std::string() - : refPath.substr(0, slash + 1); - TNS_DEBUG(Esm, "[dyn-import][ref] url=%s base=%s spec=%s", refUrl.c_str(), - baseDir.c_str(), normalizedSpec.c_str()); - std::string fsPath = NormalizePath(baseDir + normalizedSpec); - if (!fsPath.empty()) { - adjustedSpecifier = - ArgConverter::ConvertToV8String(isolate, fsPath); - TNS_DEBUG(Esm, "[dyn-import][normalize-rel] %s + %s -> %s", - baseDir.c_str(), normalizedSpec.c_str(), - fsPath.c_str()); - } - } - } else { - TNS_DEBUG( - Esm, - "[dyn-import][ref] missing resource name; cannot normalize relative " - "spec against referrer"); - } - } - + // ── Local path ── // Discovery pre-pass, the same one the static path runs: a local graph can // reach HTTP edges, and without the walk those meet the resolver cold and // fetch serially, one blocking round trip each. A graph with no HTTP edges // settles inside the call, so a local-only dynamic import is unchanged — // it neither waits nor touches the looper. - { - v8::String::Utf8Value adjustedUtf8(isolate, adjustedSpecifier); - const ModuleResolution rootResolution = ResolveSpecifierToPath( - isolate, context, *adjustedUtf8 ? *adjustedUtf8 : "", std::string()); - if (rootResolution.kind == ModuleResolution::Kind::kFile) { - RunModuleGraphLoadPumped(isolate, context, rootResolution.path, - kModuleEvaluateDeadlineSeconds); - } + if (dynamicResolution.kind == ModuleResolution::Kind::kFile) { + RunModuleGraphLoadPumped(isolate, context, dynamicResolution.path, + kModuleEvaluateDeadlineSeconds); } v8::TryCatch resolveTc(isolate); - v8::MaybeLocal maybeModule = ResolveModuleCallback( - context, adjustedSpecifier, import_assertions, refMod); - if (LogCategoryEnabled(LogCategory::Esm)) { - v8::String::Utf8Value adj(isolate, adjustedSpecifier); - const char* cAdj = (*adj) ? *adj : ""; - TNS_DEBUG(Esm, "[dyn-import][resolver-call] raw=%s normalized=%s adjusted=%s", - rawSpec.c_str(), normalizedSpec.c_str(), cAdj); - } + v8::MaybeLocal maybeModule = + LoadResolvedModule(isolate, context, dynamicResolution); + TNS_DEBUG(Esm, "[dyn-import][resolver-call] raw=%s resolved=%s", + rawSpec.c_str(), normalizedSpec.c_str()); if (maybeModule.IsEmpty()) { if (resolveTc.HasCaught()) { - resolver->Reject(context, resolveTc.Exception()).FromMaybe(false); + v8::Local resolveError = resolveTc.Exception(); + resolveTc.Reset(); + resolver->Reject(context, resolveError).FromMaybe(false); return scope.Escape(resolver->GetPromise()); } else { std::string msg = "Module resolution failed for dynamic import: "; @@ -3284,10 +3180,11 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( if (ictc.HasCaught()) { std::string exStr = ArgConverter::ToString(isolate, ictc.Exception()); if (!exStr.empty()) { - msg.append(" - "); + msg.append(" — "); msg.append(exStr); } } + ictc.Reset(); resolver ->Reject(context, v8::Exception::Error( ArgConverter::ConvertToV8String(isolate, msg))) @@ -3305,10 +3202,9 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( if (!module->Evaluate(context).ToLocal(&evalResult)) { TNS_DEBUG(Esm, "[dyn-import] evaluation failed %s", normalizedSpec.c_str()); - std::string msg = - std::string("Evaluation failed for module: ") + normalizedSpec; - v8::Local ex = v8::Exception::Error( - ArgConverter::ConvertToV8String(isolate, msg)); + v8::Local ex = BuildModuleFailureReason( + isolate, resolveTc, "Evaluation failed for module", normalizedSpec); + resolveTc.Reset(); resolver->Reject(context, ex).Check(); return scope.Escape(resolver->GetPromise()); } @@ -3371,7 +3267,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( v8::External::New(isolate, d, v8::kExternalPointerTypeTagDefault)); v8::Local reject = rejectTpl->GetFunction(context).ToLocalChecked(); - p->Then(context, fulfill, reject).ToLocalChecked(); + p->Then(context, fulfill, reject).FromMaybe(v8::Local()); return scope.Escape(resolver->GetPromise()); } } @@ -3387,22 +3283,32 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( TNS_DEBUG(Esm, "[dyn-import][verify] ns.default threw after eval (generic) %s", normalizedSpec.c_str()); - resolver - ->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String( - isolate, "TDZ on default after eval (generic)"))) - .Check(); + v8::Local tdzError = + tc3.HasCaught() + ? tc3.Exception() + : v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "TDZ on default after eval (generic)")); + tc3.Reset(); + resolver->Reject(context, tdzError).Check(); return scope.Escape(resolver->GetPromise()); } } resolver->Resolve(context, module->GetModuleNamespace()).Check(); TNS_DEBUG(Esm, "[dyn-import] resolved %s", normalizedSpec.c_str()); } catch (NativeScriptException& ex) { - ex.ReThrowToV8(); TNS_DEBUG(Esm, "[dyn-import] native failed %s", normalizedSpec.c_str()); - resolver - ->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String( - isolate, "Native error during dynamic import"))) - .Check(); + // v8-callbacks.h: this callback must reject the promise it returns and + // leave nothing scheduled on the isolate, so the exception is caught back + // out of ReThrowToV8 and becomes the rejection reason instead. + v8::TryCatch nativeTc(isolate); + ex.ReThrowToV8(); + v8::Local error = + nativeTc.HasCaught() + ? nativeTc.Exception() + : v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Native error during dynamic import")); + nativeTc.Reset(); + resolver->Reject(context, error).FromMaybe(false); } return scope.Escape(resolver->GetPromise()); @@ -3419,26 +3325,60 @@ void InitializeImportMetaObject(v8::Local context, auto* moduleState = ModuleLoaderStateFor(isolate); if (moduleState == nullptr) return; - std::string modulePath = FindKeyForModule(*moduleState, isolate, module); - if (modulePath.empty()) return; + const std::string modulePath = FindKeyForModule(*moduleState, isolate, module); + + // A registry key either carries a URL scheme — http(s):, blob:, node:, and + // whatever else a synthetic module is keyed under — or it is a filesystem + // path. A scheme'd key IS the module's identity and passes through untouched; + // only a path becomes a file:// URL. + auto hasUrlScheme = [](const std::string& s) -> bool { + if (s.empty()) return false; + size_t colonPos = s.find(':'); + if (colonPos == 0 || colonPos == std::string::npos) return false; + size_t slashPos = s.find('/'); + if (slashPos != std::string::npos && slashPos < colonPos) return false; + for (size_t i = 0; i < colonPos; i++) { + char c = s[i]; + const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '+' || c == '-' || + c == '.'; + if (!ok) return false; + } + return true; + }; std::string moduleUrl; - std::string moduleDirname; - if (StartsWith(modulePath, "http://") || StartsWith(modulePath, "https://")) { - moduleUrl = modulePath; - size_t slash = modulePath.find_last_of('/'); - moduleDirname = slash == std::string::npos ? modulePath - : modulePath.substr(0, slash); - } else if (StartsWith(modulePath, "blob:")) { + if (modulePath.empty()) { + moduleUrl = "file:///app/"; + } else if (hasUrlScheme(modulePath)) { moduleUrl = modulePath; - moduleDirname = modulePath; } else { - moduleUrl = StartsWith(modulePath, "file://") ? modulePath - : ("file://" + modulePath); - std::string filesystemPath = FileURLToPath(moduleUrl); - size_t slash = filesystemPath.find_last_of("/\\"); - moduleDirname = slash == std::string::npos ? filesystemPath - : filesystemPath.substr(0, slash); + moduleUrl = "file://" + modulePath; + } + + // `dirname` is a filesystem notion; a URL-backed module has no directory, so + // it gets the URL with its last path segment stripped — stable and useful in + // a log line. A key with no path beyond the host or scheme body has nothing + // to strip and keeps its identity. + std::string moduleDirname; + if (modulePath.empty()) { + moduleDirname = "/app"; + } else if (hasUrlScheme(modulePath)) { + size_t schemeEnd = modulePath.find("://"); + size_t pathStart = (schemeEnd == std::string::npos) + ? std::string::npos + : modulePath.find('/', schemeEnd + 3); + size_t lastSlash = modulePath.find_last_of('/'); + if (pathStart != std::string::npos && lastSlash != std::string::npos && + lastSlash > pathStart) { + moduleDirname = modulePath.substr(0, lastSlash); + } else { + moduleDirname = modulePath; + } + } else { + size_t lastSlash = modulePath.find_last_of("/\\"); + moduleDirname = + lastSlash == std::string::npos ? "/app" : modulePath.substr(0, lastSlash); } meta->CreateDataProperty( diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index bffcfc7d5..2d17ed516 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -22,7 +22,8 @@ namespace tns { // CaptureLoaderVocabulary / InstallLoaderVocabulary), so no synchronization is // needed anywhere: each isolate only ever reads and writes its own. A live // worker therefore does not observe a later reconfiguration — the dev client -// restarts workers on updates. +// restarts workers on updates. All of it must be set from the isolate's own +// thread (see SetImportMap and friends at the bottom of this header). // One import-map section: specifier key → target. Lookup within a section is // exact-then-trailing-slash-prefix with longest match, per the import-maps @@ -91,9 +92,9 @@ ModuleHandleMap* ModuleRegistryFor(v8::Isolate* isolate); void QuiesceModuleLoadsForIsolate(v8::Isolate* isolate); // Utility to drop modules from the registry when compilation/instantiation -// fails. Operates on the *current* isolate's maps (resolved internally); only -// ever called on the isolate's own JS thread during module resolution/loading. -void RemoveModuleFromRegistry(const std::string& canonicalPath); +// fails. Call on `isolate`'s own JS thread. +void RemoveModuleFromRegistry(v8::Isolate* isolate, + const std::string& canonicalPath); // The canonical registry key whose live entry is `mod`, or empty when the // module is not registered for `isolate`. O(1) via the loader state's @@ -123,6 +124,10 @@ v8::MaybeLocal GetOrCreateRequireFacade( // Authoritative HTTP URL loader for dev-served ESM. This compiles and // registers the module under its canonical URL key without evaluating it. +// Returns empty with a V8 exception scheduled on `isolate` — the fetch +// classifier's reason, or the compile error — so a caller that is not a V8 +// resolve callback must consume it through its own TryCatch and route the text +// into its own failure channel. v8::MaybeLocal LoadHttpModuleForUrl( v8::Isolate* isolate, v8::Local context, const std::string& requestedUrl); @@ -158,6 +163,12 @@ v8::MaybeLocal LoadHttpModuleForUrl( // root — is left unregistered for the resolver (or the caller's own load // path) to report with its own message, so the walk introduces no new failure // modes and steals no error text. +// +// `onComplete` must capture only trivially destructible state. A background +// fetch completion can drop the last reference to a load whose isolate is +// already quiesced — QuiesceModuleLoadsForIsolate Resets the load's context +// Global but nothing else — so the closure is destroyed on whichever thread +// gets there last, and a captured v8 handle would be destroyed off-thread. void StartModuleGraphLoad( v8::Isolate* isolate, v8::Local context, const std::string& root, @@ -209,15 +220,6 @@ void InitializeImportMetaObject(v8::Local context, v8::Local module, v8::Local meta); -// ── The loader vocabulary ───────────────────────────────────── -// -// Everything the dev client teaches one isolate's module loader: which bare -// specifiers resolve where, how URLs are keyed, and which URLs are never -// cached. Per-isolate, not process-wide — it lives in the isolate's loader -// state and dies with the isolate, so each isolate only ever reads and writes -// its own and nothing here needs synchronization. All of it must be set from -// the isolate's own thread. - // Import map support. // // Shape: {"imports": {"specifier": "target", ...}, @@ -230,6 +232,11 @@ void InitializeImportMetaObject(v8::Local context, // through the copy taken at spawn. bool SetImportMap(const std::string& json, std::string* error); +// Run the same parse `SetImportMap` runs and throw the result away. Lets +// `configureLoader` validate the whole call before installing any section, +// without the parsed representation leaving the loader implementation. +bool ValidateImportMapJson(const std::string& json, std::string* error); + // Set URL patterns that should bypass module cache (e.g. "?v=", "/hot/") // on the calling isolate. void SetVolatilePatterns(const std::vector& patterns); diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 4d5a0b6bf..c861822c2 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -1,10 +1,9 @@ #include "Runtime.h" #include -#include -#include #include +#include #include #include #include @@ -23,7 +22,6 @@ #include "Interop.h" #include "IsolateTracked.h" #include "JType.h" -#include "JsArgConverter.h" #include "JsArgToArrayConverter.h" #include "ManualInstrumentation.h" #include "MetadataNode.h" @@ -44,13 +42,10 @@ #include "URLPatternImpl.h" #include "URLSearchParamsImpl.h" #include "Util.h" -#include "V8GlobalHelpers.h" #include "V8StringConstants.h" #include "Version.h" #include "WeakRef.h" #include "include/libplatform/libplatform.h" -#include "include/zipconf.h" -#include "libplatform/libplatform.h" #include "sys/system_properties.h" #ifdef APPLICATION_IN_DEBUG @@ -343,6 +338,13 @@ std::string Runtime::ReadFileText(const std::string& filePath) { return File::ReadText(filePath); } +std::string Runtime::ReadFileText(const std::string& filePath, bool& ok) { +#ifdef APPLICATION_IN_DEBUG + std::lock_guard lock(m_fileWriteMutex); +#endif + return File::ReadText(filePath, ok); +} + void Runtime::Lock() { #ifdef APPLICATION_IN_DEBUG m_fileWriteMutex.lock(); @@ -367,13 +369,17 @@ void Runtime::Unlock() { // normally, Node-like. Only the two failures below are fatal, and both are // reported in every build. static void HoldBootBackstop(v8::Isolate* isolate, const std::string& entryPath) { + // The entry can already be rejected on the first poll: LoadESModule takes the + // registry-hit path for an already-evaluated module without re-entering + // EvaluateModuleGraph, so a re-run of a previously failed entry arrives here + // carrying its rejection. std::string entryRejectionReason; - bool entryPending = - ModuleInternal::PollEntryEvaluation(isolate, entryPath, &entryRejectionReason) == - EntryEvaluationState::kPending; - bool entryRejected = false; + EntryEvaluationState entryState = + ModuleInternal::PollEntryEvaluation(isolate, entryPath, &entryRejectionReason); + bool entryPending = entryState == EntryEvaluationState::kPending; + bool entryRejected = entryState == EntryEvaluationState::kRejected; - if (!entryPending && !tns::HasPendingAsyncModuleGraphWork()) { + if (!entryPending && !entryRejected && !tns::HasPendingAsyncModuleGraphWork()) { return; } @@ -383,7 +389,7 @@ static void HoldBootBackstop(v8::Isolate* isolate, const std::string& entryPath) ? Runtime::GetRuntime(isolate)->GetEventLoop() : nullptr; - while (entryPending || tns::HasPendingAsyncModuleGraphWork()) { + while (!entryRejected && (entryPending || tns::HasPendingAsyncModuleGraphWork())) { if (std::chrono::duration(std::chrono::steady_clock::now() - start).count() > deadlineSeconds) { break; @@ -400,19 +406,21 @@ static void HoldBootBackstop(v8::Isolate* isolate, const std::string& entryPath) ModuleInternal::PollEntryEvaluation(isolate, entryPath, &entryRejectionReason); // Once it settles, stop probing for good. entryPending = state == EntryEvaluationState::kPending; - if (state == EntryEvaluationState::kRejected) { - entryRejected = true; - break; - } + entryRejected = state == EntryEvaluationState::kRejected; } } + // Evict before throwing: the entry would otherwise stay registered at + // kEvaluated with a failed capability, and the registry-hit path of a later + // RunModule on this isolate would never surface the failure again. if (entryRejected) { + tns::RemoveModuleFromRegistry(isolate, tns::CanonicalizeRegistryKey(entryPath)); throw NativeScriptException( "Fatal: the main entry module's evaluation rejected during boot: " + entryRejectionReason); } if (entryPending) { + tns::RemoveModuleFromRegistry(isolate, tns::CanonicalizeRegistryKey(entryPath)); throw NativeScriptException("Fatal: the main entry module '" + entryPath + "' never settled within " + std::to_string(static_cast(deadlineSeconds)) + "s"); @@ -1087,7 +1095,6 @@ void Runtime::DestroyRuntime() { { std::lock_guard lock(s_runtimeCacheMutex); s_id2RuntimeCache.erase(m_id); - s_isolate2RuntimesCache.erase(m_isolate); } // Flag this isolate's in-flight async graph loads dead and Reset their // context Globals while the isolate is still alive, so fetch completions @@ -1098,6 +1105,14 @@ void Runtime::DestroyRuntime() { // (registries, waiters, loader vocabulary) lives in a RuntimeState slot and // is destroyed with it below. Worker isolates quiesce the same way. tns::QuiesceModuleLoadsForIsolate(m_isolate); + // The isolate->runtime mapping must outlive the quiesce: a fetch completion + // that finds GetRuntime(isolate) == nullptr bails without decrementing its + // load's accounting, so erasing first would leave a not-yet-dead load + // permanently un-completable. + { + std::lock_guard lock(s_runtimeCacheMutex); + s_isolate2RuntimesCache.erase(m_isolate); + } if (m_eventLoop != nullptr) { // runs on this runtime's own thread; children still holding a weak_ptr // and v8 teardown posts have their work dropped from now on @@ -1132,9 +1147,9 @@ void Runtime::DestroyRuntime() { CallbackHandlers::RemoveIsolateEntries(m_isolate); FrameCallbacks::RemoveIsolateEntries(m_isolate); - // The transport's process-wide state (cache-bust marks, dev-boot flag) is - // shared across isolates; only the main isolate may clear it (worker - // teardown must not wipe the main isolate's session). + // The transport's process-wide state (the cache-bust marks) is shared + // across isolates; only the main isolate may clear it (worker teardown must + // not wipe the main isolate's session). if (m_isMainThread) { tns::CleanupHttpLoaderGlobals(); } diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index 38e9a5318..f60f69e9f 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -155,6 +155,11 @@ class Runtime { static v8::Platform* platform; std::string ReadFileText(const std::string& filePath); + /* + * `ok` distinguishes an unreadable file from an empty one — callers that + * compile what they read must not treat the first as valid empty source. + */ + std::string ReadFileText(const std::string& filePath, bool& ok); /* * The main runtime's event loop, set once when the main runtime diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index 850aa5a68..9a5b0b6a6 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -26,6 +26,57 @@ using namespace v8; namespace tns { +namespace { + +/* + * Reports a worker entry that failed to evaluate, with the web's order: the + * worker scope's own `onerror` gets first refusal (a truthy return consumes the + * failure) and only an unconsumed one reaches the parent's Worker object. + * Mirrors the worker branch of the unhandled-rejection path in + * NativeScriptException.cpp, which this rejection no longer travels: attaching + * a rejection handler to the entry's evaluation promise marks it handled. + */ +void ReportEntryRejection(Isolate* isolate, Local reason, + const std::shared_ptr& wrapper) { + auto context = isolate->GetCurrentContext(); + + std::string message = "Unhandled promise rejection: "; + Local detail; + if (!reason.IsEmpty() && reason->ToDetailString(context).ToLocal(&detail)) { + message += ArgConverter::ConvertToString(detail); + } + + std::string stackTrace; + if (!reason.IsEmpty()) { + auto stack = Exception::GetStackTrace(reason); + if (!stack.IsEmpty()) { + stackTrace = NativeScriptException::GetErrorStackTrace(stack); + } + } + + Local onError; + if (context->Global() + ->Get(context, ArgConverter::ConvertToV8String(isolate, "onerror")) + .ToLocal(&onError) && + onError->IsFunction()) { + Local args[] = {ArgConverter::ConvertToV8String(isolate, message)}; + Local result; + // A handler that throws has not consumed anything - the failure falls + // through to the parent, as if no handler had been installed. + TryCatch tc(isolate); + if (onError.As() + ->Call(context, Undefined(isolate), 1, args) + .ToLocal(&result) && + !result.IsEmpty() && result->BooleanValue(isolate)) { + return; + } + } + + wrapper->PassUncaughtExceptionFromWorkerToParent(message, "", stackTrace, 0); +} + +} // namespace + WorkerWrapper::WorkerWrapper(Isolate* parentIsolate, int workerId, std::string workerPath, std::string callingDir, int priority, Local workerObject) @@ -414,28 +465,53 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { // once the entry has finished evaluating. RunWorker returns // settled for classic scripts and pumped HTTP entries; a // local top-level-await entry that outlived its settle - // window enables when its evaluation promise settles - // (fulfilled or rejected — a broken worker just dispatches - // into a listenerless global, as on the web). + // window enables when its evaluation promise settles — + // rejected included, since a broken worker still drains its + // inbox into a listenerless global, as on the web. Local pendingEntry; if (!ModuleInternal::PendingEntryEvaluation(isolate, workerPath_) .ToLocal(&pendingEntry)) { EnableMessageQueue(); } else { - auto onSettled = [](const v8::FunctionCallbackInfo& info) { - // Resolve the wrapper by id — never capture it across - // the settle; the worker may be gone by then. + // Neither handler may capture anything: they resolve the + // wrapper by id because the worker may be gone by the + // time the entry settles. Both run on this thread, in + // this isolate. + auto onFulfilled = [](const v8::FunctionCallbackInfo& info) { auto wrapper = WorkerWrapper::GetById( info.Data().As()->Value()); if (wrapper != nullptr) { wrapper->EnableMessageQueue(); } }; + // A rejection needs its own handler: sharing the fulfill + // one would mark the entry's evaluation promise handled + // and drop the failure on the floor. + auto onRejected = [](const v8::FunctionCallbackInfo& info) { + auto wrapper = WorkerWrapper::GetById( + info.Data().As()->Value()); + if (wrapper == nullptr) { + return; + } + wrapper->EnableMessageQueue(); + if (wrapper->IsTerminating() || wrapper->IsDisposed()) { + return; + } + auto isolate = info.GetIsolate(); + ReportEntryRejection(isolate, + info.Length() > 0 + ? info[0] + : Undefined(isolate).As(), + wrapper); + }; + auto workerIdData = v8::Integer::New(isolate, workerId_); Local enableFn; - if (Function::New(context, onSettled, - v8::Integer::New(isolate, workerId_)) - .ToLocal(&enableFn)) { - pendingEntry->Then(context, enableFn, enableFn) + Local reportFn; + if (Function::New(context, onFulfilled, workerIdData) + .ToLocal(&enableFn) && + Function::New(context, onRejected, workerIdData) + .ToLocal(&reportFn)) { + pendingEntry->Then(context, enableFn, reportFn) .FromMaybe(Local()); } else { EnableMessageQueue(); @@ -654,10 +730,7 @@ void WorkerWrapper::CreateInspector(Isolate* isolate) { } // Same url scheme the module loader reports in Debugger.scriptParsed. - // workerPath_ may still be relative to the caller's dir at this point - // (resolution happens in require); callingDir_ ends with '/'. - std::string url = - "file://" + (workerPath_[0] == '/' ? workerPath_ : callingDir_ + workerPath_); + std::string url = "file://" + workerPath_; auto* client = new WorkerInspectorClient(workerId_, isolate, ALooper_forThread(), url); { diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.h b/test-app/runtime/src/main/cpp/WorkerWrapper.h index 11f2ab47b..611c92ca4 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.h +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.h @@ -61,8 +61,9 @@ class WorkerWrapper : public std::enable_shared_from_this { /* * parent -> worker. Queues a serialized message and wakes the worker - * looper. Messages posted before the worker finishes bootstrapping are - * drained right after the worker script runs. + * looper. Messages posted before the worker finishes bootstrapping stay + * buffered until the entry has finished evaluating - for a module entry + * that is when its evaluation promise settles, not when the script returns. */ void PostMessage(std::shared_ptr message); @@ -94,14 +95,6 @@ class WorkerWrapper : public std::enable_shared_from_this { const std::string& stackTrace, int lineno); - /* - * Registry of live workers, keyed by workerId. Replaces the old - * CallbackHandlers::id2WorkerMap. Guarded by a mutex because the worker - * shutdown path posts cleanup from the worker thread. - */ - static int NextWorkerId(); - static std::shared_ptr GetById(int workerId); - /* * WHATWG parity: the worker's implicit port message queue starts disabled; * the worker thread calls this once the entry script has finished @@ -111,6 +104,14 @@ class WorkerWrapper : public std::enable_shared_from_this { * messages, exactly as on the web. */ void EnableMessageQueue(); + + /* + * Registry of live workers, keyed by workerId. Replaces the old + * CallbackHandlers::id2WorkerMap. Guarded by a mutex because the worker + * shutdown path posts cleanup from the worker thread. + */ + static int NextWorkerId(); + static std::shared_ptr GetById(int workerId); static void Insert(int workerId, std::shared_ptr wrapper); /* @@ -170,6 +171,9 @@ class WorkerWrapper : public std::enable_shared_from_this { Runtime* runtime_; const int workerId_; + // The entry's canonical resolved path, produced by the module resolver on + // the parent's thread: the worker has its own module registry and working + // directory, so nothing on this side can redo a relative resolution. const std::string workerPath_; const std::string callingDir_; const std::string threadName_; @@ -189,7 +193,8 @@ class WorkerWrapper : public std::enable_shared_from_this { std::atomic_bool isDisposed_; // False until the entry script has finished evaluating // (EnableMessageQueue); DrainPendingTasks leaves the queue untouched while - // disabled. + // disabled. Written and read on the worker thread only - the atomic is + // belt-and-braces, not a cross-thread channel. std::atomic_bool messagesEnabled_; ConcurrentQueue queue_; diff --git a/test-app/runtime/src/main/cpp/js/primordials.js b/test-app/runtime/src/main/cpp/js/primordials.js index 82ac503d3..912d00200 100644 --- a/test-app/runtime/src/main/cpp/js/primordials.js +++ b/test-app/runtime/src/main/cpp/js/primordials.js @@ -2,7 +2,7 @@ // Snapshot of the intrinsics the other builtins depend on, taken before any // user code can reach the globals. Runs first and is handed to every other -// builtin as the fourth fixed parameter. +// builtin as the fifth fixed parameter. // // Instance methods are exposed "uncurried" (Node's idiom): the receiver // becomes the first argument, so `ArrayPrototypeSlice(list, 0)` reads the From 95c8baeab83b6097d06fae5470fe68ca9e94a7c1 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 16:44:46 -0300 Subject: [PATCH 32/36] docs: sync ns-builtin-modules with the iOS module-reference rewrite Tracks iOS b4b8d8e4 (the reference restructure) and ee51d741 (the validation contract): the per-module reference tables, import maps and scopes with the full validation error table, registry canonicalization, reconfiguration and workers, the require() specifier and require(esm) sections, pumping requires, the module response contract, and app entries and bootstraps - with the Android platform notes (logcat trace tags, the boot backstop's fatal strings, the settle-gated worker queue) replacing the iOS ones, and no releasedObjectPolicy key. --- docs/ns-builtin-modules.md | 755 +++++++++++++++++++++++++++++-------- 1 file changed, 600 insertions(+), 155 deletions(-) diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index 7a91e16ac..a7e4e9a30 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -5,6 +5,12 @@ code. This document is the specification both the iOS and Android runtimes implement; a capability must behave identically on both platforms before it ships in a stable release. +Everything under [The scheme](#the-scheme), [Module reference](#module-reference), +[Loading ES modules](#loading-es-modules), [The internal require](#the-internal-require) +and [Adding a builtin module](#adding-a-builtin-module) is normative. Platform +specifics that a portable app must not depend on are called out as platform +notes, and the closing section collects the Android ones. + ## The scheme Builtin modules live under the URL-style `ns:` scheme, mirroring Node's @@ -13,20 +19,27 @@ Builtin modules live under the URL-style `ns:` scheme, mirroring Node's ```js // CommonJS const util = require("ns:util"); +``` -// ES modules +```js +// ES modules — the exports object is also the default export. import util, { inspect } from "ns:util"; -const util2 = await import("ns:util"); + +const same = await import("ns:util"); +console.log(same.default === util, same.inspect === inspect); // true true ``` Rules: -- `ns:` specifiers are resolved by the runtime **before any filesystem or - npm resolution**. They can never be shadowed by a file, a path mapping, or - a package — and conversely, a file named `ns:util` is not reachable. -- Resolution of an unknown builtin fails synchronously with an `Error` whose - message is exactly `No such built-in module: ns:` (matching Node's - wording for familiarity). +- `ns:` and `node:` specifiers are resolved by the runtime **before any + filesystem or npm resolution**. They can never be shadowed by a file, a path + mapping, or a package — and conversely, a file named `ns:util` is not + reachable. +- Resolution of an unregistered builtin fails with an `Error` whose message is + exactly `No such built-in module: ` (matching Node's wording for + familiarity) — e.g. `No such built-in module: node:fs`. The failure is + identical through `require()`, a static `import`, and a dynamic `import()`; + the first two throw, the third rejects. - A builtin module is a **singleton per JS realm** (main context and each worker get their own instance). `require("ns:util")` twice returns the same object; the CJS exports object and the ESM namespace expose the same @@ -38,21 +51,41 @@ Rules: - Builtin exports are frozen. Apps patch behavior by wrapping, not by mutating the runtime's module. -## Modules +## Module reference -### `ns:util` (v1) +### `ns:util` | export | description | |---|---| | `inspect(value[, options])` | Formats any value for human consumption: depth-limited, output-capped, cycle-safe, never invokes getters (except a guarded `error.stack` read and custom `toString` overrides, which are honored). `options.depth` (number) overrides the default depth of 2. Other option keys are reserved. | | `format(fmt, ...args)` | Node-style printf formatting: `%s`, `%d`, `%i`, `%f`, `%j`, `%o`, `%O`, `%%`. Extra arguments are appended space-separated, objects rendered via `inspect`. When `fmt` is not a string or contains no substitutions, all arguments are formatted and joined with spaces. `console.*` routes its arguments through this, so `console.log("%d apples", 3)` works. | +```js +const { inspect, format } = require("ns:util"); + +// Depth-limited by default; pass `depth` to see further down. +const tree = { a: { b: { c: { d: 1 } } } }; +inspect(tree); // "{ a: { b: { c: [Object] } } }" +inspect(tree, { depth: 4 }); // "{ a: { b: { c: { d: 1 } } } }" + +// Cycles are rendered, not thrown on. +const cyclic = { name: "root" }; +cyclic.self = cyclic; +inspect(cyclic); // '{ name: "root", self: [Circular] }' + +format("%s took %dms", "boot", 12.5); // "boot took 12.5ms" +format("%j", { ok: true }); // '{"ok":true}' +format("100% sure", "extra"); // "100% sure extra" (no placeholder consumed) +``` + **Stability caveat (verbatim from Node's contract):** the output of `inspect` (and therefore `format`'s object rendering) may change between runtime versions for readability; it is intended for humans and must not be parsed -programmatically. +programmatically. String quoting is one such detail: Android renders strings +through `JSON.stringify`, so they come back double-quoted where iOS uses +Node's single-quoted style. -### `ns:runtime` (v1) +### `ns:runtime` Runtime-level configuration. Keys, value domains, and scope are defined and validated natively; the module surface is a thin frozen wrapper. @@ -68,16 +101,46 @@ Config keys: |---|---|---|---| | `debug` | comma-separated category list, e.g. `"esm,fetch"` | process-wide (main-isolate writes only; read live by every isolate) | the `NS_DEBUG` environment variable, or `""` | +iOS additionally registers `releasedObjectPolicy`, which governs access to a +wrapper whose native counterpart has already been released. Android does not +register it: it has no released-native-counterpart machinery for that key to +govern, so the key is unknown here and both functions reject it like any other +unknown key. + +```js +const { setConfig, getConfig } = require("ns:runtime"); + +// Turn on module-resolution and transport tracing for a diagnostic run. +setConfig("debug", "esm,fetch"); +getConfig("debug"); // "esm,fetch" + +// The list replaces the whole set, so turning tracing off needs no knowledge +// of what was already on. +setConfig("debug", ""); +``` + +The `TypeError` messages are part of the contract: + +| condition | message | +|---|---| +| unknown key (either function) | `Unknown runtime config key: ''` | +| bad `setConfig` arity or non-string key | `setConfig expects (key: string, value)` | +| bad `getConfig` arity or non-string key | `getConfig expects (key: string)` | +| process-wide key written from a worker | `'' is process-wide and can only be set from the main isolate` | +| non-string `debug` value | `'debug' must be a comma-separated category string (), or '' to disable tracing` | + +`` is the runtime's own list of valid category names, which on +Android expands to `esm,fetch,registry`. + Remote-module security (`security.allowRemoteModules`, `security.remoteModuleAllowlist`) is **not** part of this surface. Those values are read once from nativescript.config / package.json the first time the HTTP loader gates a fetch, and they cannot be inspected or changed through `getConfig` / `setConfig`. -iOS additionally registers `releasedObjectPolicy`; Android does not, because it -has no released-native-counterpart machinery for that key to govern. +#### `debug` -`debug` turns on the runtime's category-scoped trace logs. Categories: +Turns on the runtime's category-scoped trace logs. Categories: | category | covers | |---|---| @@ -93,46 +156,124 @@ are ignored, with one warning line naming the valid ones. The same list can be given before boot as the `NS_DEBUG` environment variable (`NS_DEBUG=esm,fetch`), which is the only way to trace boot itself. Traces are compiled into release builds as well: a release build that cannot be traced is -a release build that cannot be diagnosed. Each category writes under its own -logcat tag — `TNS.esm`, `TNS.fetch`, `TNS.registry` — so `adb logcat -s TNS.esm` -can filter them without matching message text. +a release build that cannot be diagnosed. + +*Platform note (Android):* each category writes under its own logcat tag — +`TNS.esm`, `TNS.fetch`, `TNS.registry` — so `adb logcat -s TNS.esm` can filter +them without matching message text. -### `ns:module` (v1) +### `ns:module` -The module-loader control surface consumed by development tooling -(`@nativescript/vite`). Mechanism only: every policy concern (boot -orchestration, `import.meta.hot`, full reload, CSS apply, worker teardown, -WebSocket protocol) lives in the tooling. See `HMR_RUNTIME_BOUNDARY.md` for -the full contract rationale. +The module-loader control surface: import-map vocabulary, registry +invalidation, and the `createRequire` family. It is pure mechanism — every +policy concern (boot orchestration, hot-update protocols, full reload, CSS +apply, worker teardown) belongs to whatever tooling drives it. | export | description | |---|---| -| `configureLoader(config)` | Install loader policy before the session imports anything: `importMap` (`imports` + `scopes`, consulted inside the synchronous resolver — see below), `volatilePatterns` (URL substrings always re-fetched), `canonicalization` (`stripParams`/`forPathPrefixes`/`preserveQueryFor` vocabulary for registry keying). Each present section replaces its state wholesale. An invalid `importMap` throws a `TypeError` and leaves the previously installed map untouched. **Configures the calling isolate.** A worker inherits a copy of its parent's vocabulary taken at spawn, so a worker started after `configureLoader` resolves through it; a worker already running does **not** see a later reconfiguration — the dev client restarts workers when the vocabulary changes. | -| `invalidateModules(urls)` | Evict the given URLs (canonicalized) from the module registry and mark them bust-next-fetch, so the next network fetch bypasses every HTTP cache layer. | -| `getLoadedModuleUrls()` | URL-like keys currently in the module registry (used to compute full-reload eviction sets). | -| `createRequire(filenameOrURL)` | A `require` resolving against `filenameOrURL`'s directory (a trailing slash names the directory itself). Accepts an absolute path string, a `file:` URL string, or a URL object; anything else throws a `TypeError`, and an `http(s)` base is refused outright because `require()` of a dev-served module is not supported — import those. ES module graphs load under Node's `require(esm)` rule: a graph containing top-level await is refused before it evaluates. | -| `createPumpingRequire(filenameOrURL, options?)` | Same argument contract and same resolution, but an ES module graph with top-level await is evaluated by driving V8's nestable tasks and microtasks until it settles, instead of being refused. **Callable only from a task context** — see below. `options` (validated at mint time; unknown keys throw `TypeError`): `deadlineSeconds` (positive finite, default 60), `onTimeout` (`"throw"` default, or `"return-pending"`), `pumpRunLoop` (default `false`). They govern the evaluation-settle phase only — the graph walk's fetch deadline is separate. Passing `options` to `createRequire` throws. | - -`createPumpingRequire` pumps the loop, and the loop cannot be pumped -re-entrantly: V8 ignores a microtask checkpoint while the isolate is already -draining the microtask queue. A top-level await resumes through a promise -reaction — a microtask — so such a graph can never settle from inside a -microtask turn. Requiring one from after an `await` or inside a `.then` -callback therefore throws immediately, before evaluation, leaving the graph -instantiated so `import()` can still load it. Call it from a task context -instead — a native boundary, an event handler, a timer callback, or module -evaluation itself. A **synchronous** graph needs no pumping and stays legal -from anywhere, microtask turns included. - -### Import maps and scopes +| `configureLoader(config)` | Installs loader policy for the calling isolate. Sections: `importMap` (`imports` + `scopes`), `volatilePatterns` (URL substrings always re-fetched), `canonicalization` (registry-keying vocabulary). Each **present** section replaces its state wholesale, an empty array included. Throws `TypeError` on any malformed input, having validated the whole config first, so a rejected call installs nothing. | +| `invalidateModules(urls)` | Evicts the given URLs (canonicalized) from the module registry and marks them bust-next-fetch, so the next network fetch bypasses every HTTP cache layer. Takes an array of strings; throws `TypeError` otherwise. | +| `getLoadedModuleUrls()` | The URL-like keys currently in the module registry, as an array of strings (used to compute full-reload eviction sets). | +| `createRequire(filenameOrURL)` | A `require` resolving against `filenameOrURL`'s directory (a trailing slash names the directory itself). Accepts an absolute path string, a `file:` URL string, or a URL object; anything else throws `TypeError`, and an `http(s)` base is refused outright because `require()` of a dev-served module is not supported — import those. ES module graphs load under Node's `require(esm)` rule: a graph containing top-level await is refused before it evaluates. | +| `createPumpingRequire(filenameOrURL, options?)` | Same argument contract and same resolution, but an ES module graph with top-level await is evaluated by driving the loop until it settles, instead of being refused. **Callable only from a task context.** See [Pumping requires](#pumping-requires). | + +`ns:module` (loader policy — structured, installed ahead of traffic) is +deliberately separate from `ns:runtime` (live key-value runtime flags via +`setConfig`/`getConfig`). + +Both functions validate their arguments and throw `TypeError` on anything +malformed — the behavior WebIDL gives a web API and `ERR_INVALID_ARG_TYPE` +gives a Node one. Nothing is silently skipped or filtered: a mistyped section +or a typo'd key is a caller bug, and reporting it is what keeps it from +becoming a config that quietly does nothing. + +| condition | message | +|---|---| +| missing or non-object config | `configureLoader expects a config object` | +| a key other than the three sections | `configureLoader: unknown option ''` | +| `volatilePatterns` not an array | `configureLoader: volatilePatterns must be an array of strings` | +| a non-string in `volatilePatterns` | `configureLoader: volatilePatterns[] must be a string` | +| `canonicalization` not an object | `configureLoader: canonicalization must be an object` | +| a `canonicalization` sub-key not an array | `configureLoader: canonicalization. must be an array of strings` | +| a non-string in a `canonicalization` sub-key | `configureLoader: canonicalization.[] must be a string` | +| `invalidateModules` argument not an array | `invalidateModules expects an array of URL strings` | +| a non-string in that array | `invalidateModules: urls[] must be a string` | + +`configureLoader` validates the **entire** config — every section plus the key +names — before installing any of it. A call that throws therefore leaves all +three sections exactly as they were: the atomicity the import map alone used to +have now covers the whole call, so a config that is half-right cannot land +half-applied. + +"Replaces its state wholesale" is keyed on a section being **present**, not on +its contents: `volatilePatterns: []` clears the list, and an absent section is +left alone. `undefined` counts as absent, so spreading an optional section is +safe. + +```js +const { configureLoader, getLoadedModuleUrls, invalidateModules } = + require("ns:module"); + +configureLoader({ + importMap: { + imports: { + "lodash": "http://localhost:8080/vendor/lodash.mjs", + "@scope/pkg/": "http://localhost:8080/pkg/", + }, + }, + volatilePatterns: ["/@ns/"], +}); + +// Later: drop everything the server says changed, so the next import refetches. +const stale = getLoadedModuleUrls().filter((url) => url.includes("/src/")); +invalidateModules(stale); +``` + +`getLoadedModuleUrls()` reports the **URL-like** keys only: registry entries +that are `blob:`-prefixed or contain `://`. A module keyed by a plain +filesystem path is not in the result, so the eviction set a dev client computes +from it covers served modules rather than the app's own bundled files. + +`createRequire` gives a module-relative `require` from anywhere, including an +ES module that has no `__filename`: + +```js +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const config = require("./config.json"); +const helper = require("./helpers/format.js"); +``` + +Neither require implements `require.resolve`, `require.cache`, or +`require.main`. They are **absent** rather than throwing, so a feature check +works; adding them is a change to this specification first. + +Debug builds additionally carry `canonicalizeHttpUrlKey(url)`, a pure test +diagnostic that takes a string and throws +`canonicalizeHttpUrlKey expects a URL string` otherwise; release builds omit +it. Missing members are simply absent — never +present-but-throwing — so feature checks work. The module is registered in +every build: the security boundary for remote module loading sits at the +network layer (`security.allowRemoteModules` in nativescript.config, enforced +by the HTTP loader), not at the module registry and not at `ns:runtime`. + +#### Import maps and scopes `importMap` takes the WHATWG shape: ```js +const { configureLoader } = require("ns:module"); + configureLoader({ importMap: { - imports: { "lodash": "http://host/vendor/lodash.mjs", "@scope/pkg/": "http://host/pkg/" }, - scopes: { "http://host/legacy/": { "lodash": "http://host/vendor/lodash-3.mjs" } }, + imports: { + "lodash": "http://host/vendor/lodash.mjs", + "@scope/pkg/": "http://host/pkg/", + }, + scopes: { + "http://host/legacy/": { "lodash": "http://host/vendor/lodash-3.mjs" }, + }, }, }); ``` @@ -146,81 +287,85 @@ registry key** — an absolute `http(s)` URL for a served module, or a canonical absolute path for a file on disk. That key is this runtime's analogue of the web's resolved referrer URL, which is what scope prefixes match in a browser. End a scope key with `/` to keep it on a directory boundary. Resolution -consults the most specific matching scope first, then progressively less -specific ones, then `imports` — so a scope can override a global mapping for -one subtree and fall through to it everywhere else. The resolver, the graph -walk, and `import()` all resolve through the same cascade. - -The vocabulary is per-isolate: `configureLoader` writes the isolate that -calls it, and nothing is shared between isolates, so no lock guards it. A -worker receives a copy captured on the parent's thread while it spawns and -installed before the worker loads its first module. That copy is a snapshot — -reconfiguring the parent afterwards leaves running workers on the vocabulary -they started with, which is why the dev client restarts workers on an update. - -The whole map is parsed and validated before any of it is installed. A -malformed map, a non-string target, an unknown top-level section, or a -trailing-slash key with a non-trailing-slash target throws a `TypeError` -naming the offending key or section, and the previously installed map keeps -resolving — a typo in an update cannot empty a live session's vocabulary. - -#### Booting an ESM entry from a CJS bootstrap - -The supported way to give an ESM app its loader vocabulary before any ESM -traffic — which closes the pre-configure window described in the -canonicalization notes — is a small CommonJS bootstrap as the app entry: +consults the most specific matching scope first (longest prefix wins), then +progressively less specific ones, then `imports` — so a scope can override a +global mapping for one subtree and fall through to it everywhere else. The +synchronous resolver, the graph walk, and `import()` all resolve through the +same cascade. + +The whole map is parsed and validated before any of it is installed: a +rejected map throws a `TypeError` out of `configureLoader` and the previously +installed map keeps resolving, so a typo in an update cannot empty a live +session's vocabulary. Every message is prefixed `configureLoader: `. + +| condition | message (after the `configureLoader: ` prefix) | +|---|---| +| `importMap` is neither an object nor a non-empty string | `importMap must be an object or a JSON string` | +| empty JSON | `an import map must be a non-empty JSON object` | +| unparseable JSON | `an import map must be valid JSON: ` | +| JSON that is not an object | `an import map must be a JSON object` | +| any import-map section other than `imports`/`scopes` | `unsupported import-map section ''; only "imports" and "scopes" are supported` | +| `imports` is not an object | `the "imports" section must be an object` | +| `scopes` is not an object | `the "scopes" section must be an object` | +| non-string scope key | `scopes: every scope key must be a string` | +| empty scope key | `scopes: a scope key must not be empty` | +| a scope's value is not an object | `scopes: the map for scope '' must be an object` | + +Inside either section — labelled `imports` or `scope ''`: + +| condition | message | +|---|---| +| non-string specifier key | `
: every key must be a string` | +| empty specifier key | `
: a specifier key must not be empty` | +| non-string target | `
: the target for '' must be a string` | +| empty target | `
: the target for '' must not be empty` | +| trailing-slash key, non-trailing-slash target | `
: the target for '' must end with '/' because the specifier key does` | -```js -const { configureLoader, createPumpingRequire } = require("ns:module"); +#### Registry canonicalization -configureLoader({ importMap: { imports: { /* … */ } } }); +The registry keys modules by a canonical URL. The mechanism — fragment strip, +cache-buster param drop, param sort — is the runtime's; the *vocabulary* is +server policy, supplied here: -createPumpingRequire(__filename, { - pumpRunLoop: true, - onTimeout: "return-pending", - deadlineSeconds: 1, -})("./entry.mjs"); +| key | meaning | +|---|---| +| `stripParams` | query param names that are pure cache busters and are dropped for dev endpoints (e.g. `t`, `v`, `import`) | +| `forPathPrefixes` | path prefixes (starts-with) identifying the dev endpoints whose query may be normalized (e.g. `/ns/`, `/@id/`) | +| `preserveQueryFor` | path substrings whose query **is** the module identity and must be preserved verbatim (e.g. `/@ng/component`) | + +```js +const { configureLoader } = require("ns:module"); + +configureLoader({ + canonicalization: { + stripParams: ["t", "v", "import"], + forPathPrefixes: ["/ns/", "/@id/"], + preserveQueryFor: ["/@ng/component"], + }, +}); ``` -Two warnings, both load-bearing: +Presence of the `canonicalization` object marks the vocabulary as configured +and replaces the built-in fallback entirely; empty arrays are honored as +explicit policy. `preserveQueryFor` is checked before the dev-endpoint prefix +test, so a path that matches both keeps its query. -- `pumpRunLoop: true` is sane **only while boot owns the looper**. After boot - the looper belongs to the app, and slicing it from inside a require - re-enters arbitrary looper sources — including UI callbacks — underneath JS - frames. -- With `onTimeout: "return-pending"` the returned namespace may still be - evaluating. A bootstrap must **discard it** and never read a binding off it; - reading one is a TDZ error at best. +#### Reconfiguration and workers -The bootstrap is not the only option: **an ES module main entry is supported -directly**, top-level await included. When the app's `main` resolves to a -`.mjs`, the entry is evaluated as a module rather than `require()`d — so -`import`/`export` are legal there — under the boot evaluation options: a one -second in-place yield that never throws, after which the boot backstop holds -the process while the entry's evaluation promise is still pending, bounded at -twice the module deadline. The trade-off is that the entry's own static imports -resolve *before* its body runs, so anything the entry needs `configureLoader` -to have configured must be reached through a dynamic `import()` after that -call. The CommonJS bootstrap above avoids that constraint by being synchronous; -pick whichever fits the app. - -Not implemented on either require: `require.resolve`, `require.cache`, and -`require.main`. They are absent rather than throwing, so a feature check -works; adding them is a spec change here first. +The loader vocabulary is **per-isolate**: `configureLoader` writes the isolate +that calls it and nothing is shared between isolates, so no lock guards it. -Debug builds additionally carry `canonicalizeHttpUrlKey(url)`, a pure test -diagnostic; release builds omit it. Missing members are simply absent — -never present-but-throwing — so feature checks work. The module is -registered in every build; the security boundary for remote module loading -sits at the network layer (`security.allowRemoteModules` in -nativescript.config, enforced inside `HttpLoader`), not the module -registry and not `ns:runtime` getConfig/setConfig. +A worker receives a **copy** of its parent's vocabulary, captured on the +parent's thread while the worker spawns and installed on the worker's isolate +before it loads its first module. That copy is a snapshot: reconfiguring the +parent afterwards leaves running workers on the vocabulary they started with. -Note: `ns:module` (loader policy, structured, boot-time) is deliberately -separate from `ns:runtime` (live key-value runtime flags, `setConfig`/ -`getConfig`). +Normatively: **tooling that reconfigures loader vocabulary must restart +workers for the change to reach them.** A worker started after the +`configureLoader` call resolves through the new vocabulary; a live worker +never observes a later reconfiguration. -## `node:` compatibility shims +### `node:` compatibility shims The same registry serves the `node:` scheme with **compatibility shims** so npm packages that require Node builtins by their prefixed names can run @@ -246,40 +391,307 @@ unmodified where a shim exists: break. Bundler-level aliases (webpack/rollup) continue to work and take precedence at build time. - A shim is always a **distinct module object** from any `ns:` module, even - when every member is re-exported unchanged. `ns:` modules may grow runtime-specific - members freely; a `node:` shim only ever gains members that track Node's - actual API. This mirrors how Bun (`bun:*`), Deno (`Deno.*`/JSR) and - Cloudflare (`cloudflare:*`) all keep their own surface strictly apart from - their `node:` compat layer. + when every member is re-exported unchanged. `ns:` modules may grow + runtime-specific members freely; a `node:` shim only ever gains members that + track Node's actual API. This mirrors how Bun (`bun:*`), Deno (`Deno.*`/JSR) + and Cloudflare (`cloudflare:*`) all keep their own surface strictly apart + from their `node:` compat layer. - Shims ship on both runtimes under the same parity rule as `ns:` modules. -### v1 shims - | module | exports | notes | |---|---|---| | `node:util` | `inspect`, `format` | Re-exports `ns:util`'s members unchanged (`nodeUtil.inspect === nsUtil.inspect`) from a **distinct, separately frozen module object**. Documented as partial. | -| `node:url` | `fileURLToPath`, `pathToFileURL` | Node-strict converters between `file:` URLs and paths. Parsing goes through the URL intrinsic, so `file://localhost/x` is accepted (the URL spec folds a `localhost` authority to none) while any other host throws, and the query and fragment are not part of the path. `fileURLToPath` rejects a non-`file:` scheme and rejects `%2F` in the path rather than decoding a separator into it. `pathToFileURL` returns a real `URL` and requires an **absolute** path: Node resolves a relative one against the process working directory, and there is no such thing here. Documented as partial — no `URL`/`URLSearchParams` re-exports (both are globals), no legacy `url.parse`/`format`/`resolve`. | +| `node:url` | `fileURLToPath`, `pathToFileURL` | Node-strict converters between `file:` URLs and paths. Documented as partial — no `URL`/`URLSearchParams` re-exports (both are globals), no legacy `url.parse`/`format`/`resolve`. | | `node:module` | `createRequire` | Re-exports `ns:module`'s `createRequire` unchanged from a **distinct, separately frozen module object**. `createPumpingRequire` is deliberately absent: it has no Node counterpart, so code written against this shim keeps running on Node. `require.resolve`/`.cache`/`.main` are not implemented, and neither is any other `node:module` member (`Module`, `builtinModules`, `isBuiltin`, `register`, `syncBuiltinESMExports`). Documented as partial. | -Candidates for future shims, in rough order of ecosystem demand: -`node:events` (EventEmitter), `node:path` (pure JS), `node:buffer`, -`node:process` (subset). Each requires a spec update here first. +`node:url`'s parsing goes through the URL intrinsic, so `file://localhost/x` is +accepted (the URL spec folds a `localhost` authority to none) while any other +host throws, and the query and fragment are never part of the path. +`fileURLToPath` rejects a non-`file:` scheme and rejects `%2F` in the path +rather than decoding a separator into it. `pathToFileURL` returns a real `URL` +and requires an **absolute** path: Node resolves a relative one against the +process working directory, and there is no such thing here. + +```js +const { fileURLToPath, pathToFileURL } = require("node:url"); + +fileURLToPath("file:///app/src/main.js"); // "/app/src/main.js" +fileURLToPath("file://localhost/app/a.js"); // "/app/a.js" +fileURLToPath("file:///app/a.js?v=2#frag"); // "/app/a.js" + +pathToFileURL("/app/my file.js").href; // "file:///app/my%20file.js" +``` + +Its `TypeError` messages are Node's: + +| condition | message | +|---|---| +| argument is neither a string nor a URL-like object, or is unparseable | `The "path" argument must be of type string or an instance of URL.` | +| non-`file:` scheme | `The URL must be of scheme file` | +| a host other than `localhost` or empty | `File URL host must be "localhost" or empty` | +| `%2F` in the path | `File URL path must not include encoded / characters` | +| `pathToFileURL` given a non-string | `The "path" argument must be of type string.` | +| `pathToFileURL` given a relative path | `The "path" argument must be an absolute path.` | + +## Loading ES modules + +### The `require()` specifier + +Every `require` — the global one and any minted by `createRequire` / +`createPumpingRequire` — takes a **string** specifier. Anything else throws a +`TypeError` with Node's `ERR_INVALID_ARG_TYPE` wording, before any builtin, +`http(s)` or filesystem handling runs: + +``` +The "id" argument must be of type string. Received +``` + +`` follows Node's `determineSpecificType`: `undefined`, `null`, +`type number (42)`, `an instance of Object`, `function foo`, and so on. + +### `require()` of an ES module + +`require()` of an ES module works, under Node's `require(esm)` rule: the graph +is loaded and evaluated synchronously **unless it contains top-level await**, +in which case it is refused *before evaluation* with an `Error` reading + +``` +require() cannot load ES module '': the module graph contains top-level await. Use import() or createPumpingRequire from ns:module instead. +``` + +The refusal never evicts the module: the graph stays instantiated, so the very +same module still loads through `import()`. Match the text with +`toContain`-style substring checks rather than full-string equality — how much +surrounding context a platform adds to a `require()` failure is not part of +this contract. + +### What `require()` of an ES module returns + +A namespace object is not a CommonJS exports object, so the runtime applies +Node's `populateCJSExportsFromESM` cascade, in this order: + +1. **An own export literally named `module.exports` wins outright** — its value + is what `require()` returns. This is the escape hatch for a module that + wants full control of its CJS shape. +2. **Otherwise the namespace is returned unchanged** when it has **no own + `default` export**, *or* when it **already declares its own `__esModule`**. + Declaring `__esModule` yourself is therefore an explicit opt-out of step 3. +3. **Otherwise** (an own `default`, no own `__esModule`) `require()` returns a + **live-binding facade**: a synthetic module re-exporting everything from the + target plus `__esModule = true`. Transpiled consumers reading + `_mod.__esModule ? _mod.default : _mod` find the default, and because the + facade re-exports rather than copies, bindings stay live. + +```js +// a.mjs — no default export: the namespace passes through. +export const x = 1; +// require("./a.mjs") → { x: 1 } + +// b.mjs — a default and no __esModule: the facade is built. +export default function boot() {} +export const version = "1.0"; +// require("./b.mjs") → { default: boot, version: "1.0", __esModule: true } + +// c.mjs — takes over the CJS shape completely. +const handler = () => {}; +export { handler as "module.exports" }; +// require("./c.mjs") → handler +``` + +### Pumping requires + +`createPumpingRequire` lifts the top-level-await refusal by driving the loop — +running nestable tasks and draining microtasks — until the graph settles. + +Options are validated once, **when the require is minted**; a `require()` call +itself does no option work. Unknown keys throw rather than being silently +ignored. + +| option | values | default | meaning | +|---|---|---|---| +| `deadlineSeconds` | positive finite number | `60` | how long the graph gets to settle in-pump. Governs the **evaluation-settle phase only** — the graph walk's fetch deadline is separate and unaffected. | +| `onTimeout` | `"throw"` \| `"return-pending"` | `"throw"` | what an expired deadline means. `"return-pending"` hands back a namespace whose evaluation is still in flight. | +| `pumpRunLoop` | boolean | `false` | also give the platform's own loop a slice per pump iteration, for graphs whose progress depends on native transports rather than engine tasks. | + +Validation errors, all `TypeError`: + +| condition | message | +|---|---| +| `options` present but not an object | `createPumpingRequire: options must be an object` | +| unrecognized key | `createPumpingRequire: unknown option ''` | +| bad `deadlineSeconds` (non-number, non-finite, `<= 0`) | `createPumpingRequire: 'deadlineSeconds' must be a positive finite number` | +| bad `onTimeout` | `createPumpingRequire: 'onTimeout' must be 'throw' or 'return-pending'` | +| bad `pumpRunLoop` | `createPumpingRequire: 'pumpRunLoop' must be a boolean` | +| `options` passed to `createRequire` | `options are not supported on createRequire` | + +Both requires share the base-argument contract, and both reject an `http(s)` +base: + +| condition | message | +|---|---| +| not an absolute path, `file:` URL string, or URL object | `The argument 'filename' must be a file URL object, file URL string, or absolute path string.` | +| an `http(s)` base | `createRequire() cannot take an http(s) URL (): require() of a dev-served module is not supported. Pass an app-root file path and use import() for remote modules.` | + +**The microtask-reentrancy refusal.** The loop cannot be pumped re-entrantly: +the engine ignores a microtask checkpoint while the isolate is already draining +the microtask queue. A top-level await resumes through a promise reaction — a +microtask — so such a graph can never settle from inside a microtask turn. +Requiring one from after an `await` or inside a `.then` callback therefore +throws immediately, before evaluation, leaving the graph instantiated so +`import()` can still load it: + +``` +createPumpingRequire cannot settle module graph '' from inside a microtask (after an await or inside a promise callback): the event loop cannot be pumped re-entrantly. Call it from a task context, or use import(). +``` + +Call it from a task context instead — a native boundary, an event handler, a +timer callback, or module evaluation itself. A **synchronous** graph needs no +pumping and stays legal from anywhere, microtask turns included. + +### What an HTTP module response must be + +A module fetched over `http(s)` is classified by status and MIME type before it +ever reaches the compiler, so a dev server that answers with an error page +produces a clear diagnostic instead of a syntax error. Both the synchronous +fallback and the async graph walk use the same classifier, so they cannot +disagree about what a response means. Every failure below surfaces as a plain +`Error` whose `message` is exactly the quoted text — as a throw during module +instantiation, or as the rejection of a dynamic `import()`. + +The MIME **essence** is the Content-Type with everything from the first `;` +discarded, then trimmed of spaces and tabs and lowercased — so +`Content-Type: TEXT/JavaScript; charset=utf-8` has essence `text/javascript`. + +**Loads as JavaScript** — the HTML spec's JavaScript MIME type essence list, +matched exactly: + +`application/ecmascript`, `application/javascript`, `application/x-ecmascript`, +`application/x-javascript`, `text/ecmascript`, `text/javascript`, +`text/javascript1.0`, `text/javascript1.1`, `text/javascript1.2`, +`text/javascript1.3`, `text/javascript1.4`, `text/javascript1.5`, +`text/jscript`, `text/livescript`, `text/x-ecmascript`, `text/x-javascript`. + +**Loads as a JSON module**: essence `application/json`, `text/json`, or any +essence ending in `+json` (e.g. `application/vnd.api+json`). + +**An empty 2xx body with a JavaScript MIME is a valid empty module.** Type-only +TypeScript modules transform to zero runtime code and dev servers serve them as +empty 200s; the runtime substitutes a canonical empty module rather than +failing the whole graph. An empty **JSON** body is a failure — there is no +canonical empty JSON module. + +Failures, in the order they are checked: + +| condition | message | +|---|---| +| no response at all | `HTTP import failed: (network error)` | +| status 204 or 205 | `HTTP import failed: (status=, no content)` | +| any other non-2xx status | `HTTP import failed: (status=)` | +| missing or empty Content-Type | `Expected a JavaScript module but '' responded with no MIME type` | +| JSON MIME, empty body | `Expected a JSON module but '' responded with an empty body` | +| any other MIME (e.g. `text/html`) | `Expected a JavaScript module but '' responded with MIME type ''` | + +Ahead of all of these sits the security gate: when remote module loading is not +permitted, no request is made at all and the failure is +`HTTP import blocked: remote module loading is not allowed for `. + +204 and 205 are checked before the MIME type, so a "no content" response fails +as such even when it carries a JavaScript Content-Type — the web likewise +treats it as a network error for a module script rather than as an empty +module. The `` in the foreign-MIME message is the normalized essence, +not the raw header. + +### App entries and bootstraps + +An app's entry can be either CommonJS or an ES module, and the choice decides +when loader vocabulary can be installed. + +**The ordering rule is normative: `configureLoader` must run before any ES +module traffic it is meant to govern.** The import map is consulted inside the +engine's *synchronous* resolver, so it cannot be produced on demand — it has to +be installed ahead of the imports that need it. + +**An ES module main entry is supported directly**, top-level await included. +When the app's `main` resolves to an ES module, the entry is evaluated as a +module rather than `require()`d, so `import`/`export` are legal there. A local +entry is given a **short, non-throwing yield**: one brief in-place window in +which the graph may settle, after which evaluation simply continues on the real +event loop. Only nestable tasks can run while the entry's frames are on the +stack, so a top-level await parked on anything else could never settle in +place; returning instead of throwing is the Node shape. Should the entry's +evaluation promise still be pending when the yield ends, a **boot backstop** +holds the process until it settles, **bounded at twice the module deadline**. + +The trade-off: an ES module entry's own **static** imports resolve *before* its +body runs, so anything that needs `configureLoader` to have run must be reached +through a dynamic `import()` after that call. Keep the entry's static imports +to builtins only. + +```js +// main.mjs — static imports are builtins only, so nothing races the config. +import { configureLoader } from "ns:module"; + +configureLoader({ + importMap: { imports: { "lodash": "http://localhost:8080/vendor/lodash.mjs" } }, +}); + +// Everything that resolves through the map is reached dynamically, after. +const { start } = await import("./app.mjs"); +start(); +``` + +**A CommonJS bootstrap avoids that constraint by being synchronous**: it +configures the loader and then pulls in the ES module entry, with no static +imports to resolve early. + +```js +// main.js — a CommonJS bootstrap for an ESM app. +const { configureLoader, createPumpingRequire } = require("ns:module"); + +configureLoader({ + importMap: { imports: { "lodash": "http://localhost:8080/vendor/lodash.mjs" } }, +}); + +createPumpingRequire(__filename, { + pumpRunLoop: true, + onTimeout: "return-pending", + deadlineSeconds: 1, +})("./entry.mjs"); +``` + +Two warnings on that bootstrap, both load-bearing: + +- `pumpRunLoop: true` is sane **only while boot owns the looper**. After boot + the looper belongs to the app, and slicing it from inside a require re-enters + arbitrary looper sources — including UI callbacks — underneath JS frames. +- With `onTimeout: "return-pending"` the returned namespace may still be + evaluating. A bootstrap must **discard it** and never read a binding off it; + reading one is a TDZ error at best. + +Pick whichever fits the app: the ESM entry is simpler and needs no bootstrap +file, the CommonJS bootstrap buys unconstrained ordering. + +*Platform note (Android):* there is no never-returning entry point here — the +entry is evaluated from `Runtime::RunModule`, which returns to Java when boot +finishes — so the boot backstop is on the path of every app, not just the ones +that park. Its two failures are fatal and reported in every build: +`Fatal: the main entry module's evaluation rejected during boot: ` and +`Fatal: the main entry module '' never settled within 120s`. ## The internal require Builtin modules reach each other — and only each other — through an internal -`require` the runtime provides to every builtin source: +`require` the runtime provides to every builtin source. This is the mechanism +shims are built on, so it is normative: both runtimes provide it. - It resolves **builtin specifiers only**. A path, a package name or any other specifier is not reachable from a builtin; an unregistered builtin name throws the same `No such built-in module: ` an app sees. - It materializes the target module on first use and returns the realm's singleton afterwards, which is what makes shims lazy. -- Requiring a module that is still being built throws rather than recursing, - so a dependency cycle between builtins is a loud error and not a hang. - -This is the mechanism shims are built on, so it is normative: both runtimes -provide it. +- Requiring a module that is still being built throws rather than recursing, so + a dependency cycle between builtins is a loud error and not a hang. The + message is exactly `Circular require of built-in module: `. ## Adding a builtin module @@ -289,9 +701,23 @@ provide it. an implementation on both runtimes before a stable release; a module may ship on one platform behind a documented "experimental, iOS-only" (or Android-only) note in between. -- Internal runtime machinery must never be reachable through the scheme: - the registry distinguishes public modules from internal builtins, and only - public ones resolve (Node's `canBeRequiredByUsers` split). +- Internal runtime machinery must never be reachable through the scheme. + +That last rule holds because public modules and internal builtins are **two +separate loading paths**, not one registry with a per-entry flag: + +- The **public registry** is a table mapping specifier → builtin, and it is the + only thing the `ns:`/`node:` resolver consults. A specifier absent from it + does not resolve, full stop. Today it holds six entries: `ns:module`, + `ns:runtime`, `ns:util`, `node:module`, `node:url`, `node:util`. +- **Internal builtins** (the intrinsics snapshot, the require factory, the + console formatter, and so on) are invoked directly from their own native call + sites. They are never named in the public registry, so there is no specifier + that could reach them and nothing to mark private. + +Adding an internal builtin therefore cannot accidentally expose it; exposing +one is an explicit registry entry, which is also the change this document has +to describe. ## Source-text modules: deliberately not supported @@ -305,25 +731,35 @@ current or planned builtin has. Revisit here before building either. ## Android implementation notes (non-normative) Builtin modules are function-body builtins -(`test-app/runtime/src/main/cpp/js/`, see the README there), compiled through -`BuiltinLoader`: the first compile in the process runs eagerly and produces a -code cache, and every later realm — including every worker — consumes that -process-wide bytecode cache instead of recompiling. The registry lives in -`NsBuiltinModules.{h,cpp}` and intercepts specifiers in the CommonJS require -path (`ModuleInternal::RequireCallbackImpl`) and in the ES module resolve and -dynamic-import callbacks (`ModuleInternalCallbacks.cpp`); ESM consumption is -served by a synthetic module whose exports are populated from the same -per-realm exports object. The internal require is a fixed parameter of the -builtin function wrapper (`exports`, `require`, `module`, `binding`, -`primordials`). +(`test-app/runtime/src/main/cpp/js/`, see the README there). A CMake custom +command runs `tools/js2c.mjs` to embed them into `generated/RuntimeBuiltins.cpp`, +and `BuiltinLoader::RunBuiltin` compiles them with an `internal/.js` +script origin and a process-wide bytecode cache: the first compile in the +process runs eagerly and produces a code cache that every later realm — +including every worker — consumes instead of recompiling. The public registry +lives in `NsBuiltinModules.{h,cpp}` and intercepts specifiers in the CommonJS +require path (`ModuleInternal::RequireCallbackImpl`) and in the ES module +resolve and dynamic-import callbacks (`ModuleInternalCallbacks.cpp`); all three +paths share one `NsBuiltinModules::NotFoundMessage`, which is why the failure +text is identical across them. ESM consumption is served by a synthetic module +whose exports are populated from the same per-realm exports object. The +internal require is a fixed parameter of the builtin function wrapper +(`exports`, `require`, `module`, `binding`, `primordials`). + +The files under `js/` that are *not* in the public registry — `primordials.js`, +`require-factory.js`, `inspect.js`, `json-helper.js`, `events.js`, +`error-events.js`, `structured-clone.js`, `blob-url.js`, `performance.js`, +`weak-ref.js` — are the internal builtins: each is run from its own native call +site and none is named in the registry table. Per-realm builtin state — the exports objects, the synthetic modules, the -in-progress set, the cached `format`, the builtin `require` — and the loader's -`ModuleLoaderState` (module registry, loader vocabulary, in-flight graph loads) -live in `RuntimeState` slots rather than in isolate-keyed shared maps. A slot -is reached with an isolate data-slot read and a vector index, needs no lock, -and is destroyed with its isolate, so a worker gets its own instances as the -spec requires and teardown cannot leave a stale entry behind. +in-progress set that produces the circular-require error, the cached `format`, +the builtin `require` — and the loader's `ModuleLoaderState` (module registry, +loader vocabulary, in-flight graph loads) live in `RuntimeState` slots rather +than in isolate-keyed shared maps. A slot is reached with an isolate data-slot +read and a vector index, needs no lock, and is destroyed with its isolate, so a +worker gets its own instances as the spec requires and teardown cannot leave a +stale entry behind. The ES module pipeline is a three-phase module map: a graph walk starting from the entry discovers the transitive closure and compiles + registers every @@ -336,15 +772,20 @@ concurrently off-thread and their completions hop back to the isolate's home thread as **nestable** V8 foreground tasks on that isolate's event loop, so `RunNestableV8Tasks` can drain them with JS frames already on the stack. -The boot backstop lives inside `Runtime::RunModule` (`HoldBootBackstop` in -`Runtime.cpp`). It holds the launching thread while either the entry's own -evaluation promise is still pending or async module-graph work is in flight, -pumping nestable V8 tasks, microtask checkpoints and `ALooper_pollOnce` until -both settle, bounded at twice `kModuleEvaluateDeadlineSeconds` (120s). A -settled entry simply exits the loop; only two outcomes are fatal, and both are -reported in every build: -`Fatal: the main entry module's evaluation rejected during boot: ` and -`Fatal: the main entry module '' never settled within 120s`. +A local entry counts as an ES module when its path ends in `.mjs`. The module +deadline is a single constant, `kModuleEvaluateDeadlineSeconds` = 60 seconds +(`ModuleInternal.h`), shared by the HTTP entry's settle window, the pumped +graph walk, and — doubled, at 120 seconds — the boot backstop, so the waits +stay ordered: transport timeouts < module deadline < boot backstop. The local +entry's short yield is deliberately *not* derived from that constant: it is an +independent one-second literal in `BootEntryEvaluationOptions`, with +`return-pending` behavior and no looper slicing. An HTTP entry instead gets the +full deadline, throws on expiry, and does slice the looper, because the tooling +driving it needs the rejection reason synchronously. The backstop itself is +`HoldBootBackstop` in +`Runtime.cpp`, called from both `Runtime::RunModule` overloads; it pumps +nestable V8 tasks, microtask checkpoints and `ALooper_pollOnce` until the entry +and all async graph work settle. Workers copy the loader vocabulary from the parent at spawn (`CaptureLoaderVocabulary` on the parent's thread, `InstallLoaderVocabulary` @@ -352,3 +793,7 @@ before the worker's first module load) and, for WHATWG parity, keep the implicit port's message queue disabled until the worker entry finishes evaluating — including after a pending top-level await settles. Messages sent before that stay buffered. + +Unlike iOS, Android ships no `.d.ts` declarations for the `ns:` modules; the +`.d.ts` files in this repo describe the Android platform classes, not this +surface. From da6cf9977cd0f60328681b4485eab0d47ead8d22 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 18:54:39 -0300 Subject: [PATCH 33/36] fix(runtime): never poll the looper from inside one of its fd callbacks Android's Looper::pollInner holds a Response& into its response vector across each fd-callback dispatch. The module pumps (evaluation, graph-load, boot backstop, fetch yield) call ALooper_pollOnce on the calling thread, and since fetch completions, platform tasks and worker messages run JS from inside the EventLoop's eventfd/timerfd callbacks, a pump reached from there re-entered pollInner, which clears and reallocates the vector - the outer poll then resumes over freed memory. On slower emulators the freed block was reliably reused (the tombstone shows a module-path string overwriting the response entry, the fault address four UTF-16 characters of "testapplication"), killing the process in Looper::pollOnce during unrelated suites. CFRunLoopRunInMode is re-entrancy-safe, so iOS never had the hazard. EventLoop now tracks a thread-local dispatch depth around both fd callbacks, and every pump consults EventLoop::IsInLooperCallback(): inside a dispatch it drains the nestable-task queue and microtasks directly and yields, instead of polling. Reproduced on run 1 of an API-33 emulator loop before the guard; four consecutive full-suite runs pass after it. --- test-app/runtime/src/main/cpp/EventLoop.cpp | 15 +++++++++++++++ test-app/runtime/src/main/cpp/EventLoop.h | 11 +++++++++++ test-app/runtime/src/main/cpp/HttpLoader.cpp | 8 +++++++- test-app/runtime/src/main/cpp/ModuleInternal.cpp | 9 ++++++++- .../src/main/cpp/ModuleInternalCallbacks.cpp | 10 +++++++++- test-app/runtime/src/main/cpp/Runtime.cpp | 12 +++++++++++- 6 files changed, 61 insertions(+), 4 deletions(-) diff --git a/test-app/runtime/src/main/cpp/EventLoop.cpp b/test-app/runtime/src/main/cpp/EventLoop.cpp index 13d1df306..644e7d924 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.cpp +++ b/test-app/runtime/src/main/cpp/EventLoop.cpp @@ -572,7 +572,21 @@ void EventLoop::RunOrderedTask() { } } +namespace { +// Depth, not a flag: an fd callback can dispatch JS that lands back in +// another callback through a nested drain. +thread_local int t_looperCallbackDepth = 0; + +struct LooperCallbackScope { + LooperCallbackScope() { ++t_looperCallbackDepth; } + ~LooperCallbackScope() { --t_looperCallbackDepth; } +}; +} // namespace + +bool EventLoop::IsInLooperCallback() { return t_looperCallbackDepth > 0; } + int EventLoop::EventFdCallback(int fd, int events, void* data) { + LooperCallbackScope callbackScope; uint64_t value; // EFD_SEMAPHORE: consumes exactly one unit; while more remain the fd stays // readable and ALooper calls back next poll, interleaving with Java @@ -586,6 +600,7 @@ int EventLoop::EventFdCallback(int fd, int events, void* data) { } int EventLoop::TimerFdCallback(int fd, int events, void* data) { + LooperCallbackScope callbackScope; uint64_t expirations; if (read(fd, &expirations, sizeof(expirations)) != sizeof(expirations)) { return 1; diff --git a/test-app/runtime/src/main/cpp/EventLoop.h b/test-app/runtime/src/main/cpp/EventLoop.h index 0fa1e823f..1940ffad9 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.h +++ b/test-app/runtime/src/main/cpp/EventLoop.h @@ -183,6 +183,17 @@ class EventLoop { */ void RunNestableV8Tasks(); + /** + * True while the calling thread is inside one of this process's ALooper + * fd callbacks. Android's Looper::pollInner holds a Response& into its + * response vector across each callback; a nested ALooper_pollOnce on the + * same looper clears and reallocates that vector, so the outer poll + * resumes over freed memory. Any code that pumps the looper (module + * evaluation, the boot backstop, the fetch yield) must consult this and + * drain queues directly instead of polling when it is set. + */ + static bool IsInLooperCallback(); + /** * Runs at most one due ordered-lane entry, then performs a microtask * checkpoint. Invoked by Java EventLoopHandler.handleMessage once per diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp index fc71759f7..b41acd0cb 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.cpp +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -16,6 +16,7 @@ #include #include "ArgConverter.h" +#include "EventLoop.h" #include "JEnv.h" #include "ModuleInternal.h" #include "ModuleInternalCallbacks.h" @@ -950,7 +951,12 @@ static void MaybePumpJSThreadDuringBoot() { if (isolate->GetData((uint32_t)Runtime::IsolateData::RUNTIME) == nullptr) return; isolate->PerformMicrotaskCheckpoint(); - ALooper_pollOnce(0, nullptr, nullptr, nullptr); + // See EventLoop::IsInLooperCallback: a nested poll corrupts the outer + // poll's response state. A fetch issued from inside a dispatch skips the + // looper slice; the microtask checkpoints still run. + if (!EventLoop::IsInLooperCallback()) { + ALooper_pollOnce(0, nullptr, nullptr, nullptr); + } isolate->PerformMicrotaskCheckpoint(); } diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 0d7c47079..9558f6c6a 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -1302,7 +1302,14 @@ MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local co } isolate->PerformMicrotaskCheckpoint(); if (options.pumpRunLoop) { - ALooper_pollOnce(10 /* ms */, nullptr, nullptr, nullptr); + // Nested ALooper_pollOnce inside an fd callback dangles the outer + // poll's Response& (see EventLoop::IsInLooperCallback); the direct + // drains above keep the graph moving, so only yield the CPU here. + if (EventLoop::IsInLooperCallback()) { + usleep(1000); + } else { + ALooper_pollOnce(10 /* ms */, nullptr, nullptr, nullptr); + } isolate->PerformMicrotaskCheckpoint(); } }; diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 8c5926010..5ee25ccc2 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -2,6 +2,7 @@ #include "ModuleInternalCallbacks.h" #include +#include #include #include @@ -1767,7 +1768,14 @@ bool RunModuleGraphLoadPumped(v8::Isolate* isolate, eventLoop->RunNestableV8Tasks(); } if (*done) break; - ALooper_pollOnce(10 /* ms */, nullptr, nullptr, nullptr); + // Polling the looper from inside one of its fd callbacks dangles the + // outer poll's Response& (see EventLoop::IsInLooperCallback); the direct + // drain above still delivers fetch completions, so just yield instead. + if (EventLoop::IsInLooperCallback()) { + usleep(1000); + } else { + ALooper_pollOnce(10 /* ms */, nullptr, nullptr, nullptr); + } } if (!*done) { TNS_DEBUG( diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index c861822c2..56f1028af 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -2,6 +2,8 @@ #include +#include + #include #include #include @@ -15,6 +17,7 @@ #include "Constants.h" #include "CrashBreadcrumbs.h" #include "ErrorEvents.h" +#include "EventLoop.h" #include "Events.h" #include "File.h" #include "FrameCallbacks.h" @@ -398,7 +401,14 @@ static void HoldBootBackstop(v8::Isolate* isolate, const std::string& entryPath) eventLoop->RunNestableV8Tasks(); } isolate->PerformMicrotaskCheckpoint(); - ALooper_pollOnce(10, nullptr, nullptr, nullptr); + // See EventLoop::IsInLooperCallback: a nested poll corrupts the outer + // poll's response state. Boot normally reaches this outside any dispatch, + // but an HTTP entry re-run from a dev-session task must not poll. + if (EventLoop::IsInLooperCallback()) { + usleep(1000); + } else { + ALooper_pollOnce(10, nullptr, nullptr, nullptr); + } isolate->PerformMicrotaskCheckpoint(); if (entryPending) { From c167db4029284fcce6f09e2988dca885a87246dd Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 19:13:15 -0300 Subject: [PATCH 34/36] perf(runtime): wait on the event loop's fds instead of sleeping in pumps The looper-callback guard substituted a fixed 1ms usleep for the skipped ALooper_pollOnce, paying the full millisecond per iteration even when a fetch completion or platform task had already landed. WaitForInternalWork polls the loop's own eventfd and timerfd - the same wakeups the looper would have delivered, without entering it - so nested pumps wake the moment internal-lane work arrives and idle at the same 10ms cap the un-nested path uses. The non-pumping TLA wait, which carried the same blind 1ms spin, gets the same treatment. --- test-app/runtime/src/main/cpp/EventLoop.cpp | 16 ++++++++++++++++ test-app/runtime/src/main/cpp/EventLoop.h | 9 +++++++++ .../runtime/src/main/cpp/ModuleInternal.cpp | 18 ++++++++++++++---- .../src/main/cpp/ModuleInternalCallbacks.cpp | 10 +++++++--- test-app/runtime/src/main/cpp/Runtime.cpp | 9 +++++++-- 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/test-app/runtime/src/main/cpp/EventLoop.cpp b/test-app/runtime/src/main/cpp/EventLoop.cpp index 644e7d924..1f5d96682 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.cpp +++ b/test-app/runtime/src/main/cpp/EventLoop.cpp @@ -1,6 +1,7 @@ #include "EventLoop.h" #include +#include #include #include #include @@ -585,6 +586,21 @@ struct LooperCallbackScope { bool EventLoop::IsInLooperCallback() { return t_looperCallbackDepth > 0; } +void EventLoop::WaitForInternalWork(int timeoutMs) { + struct pollfd fds[2]; + nfds_t count = 0; + { + std::lock_guard lock(mutex_); + if (eventFd_ != -1) fds[count++] = {eventFd_, POLLIN, 0}; + if (timerFd_ != -1) fds[count++] = {timerFd_, POLLIN, 0}; + } + if (count == 0) { + usleep(static_cast(timeoutMs) * 1000); + return; + } + poll(fds, count, timeoutMs); +} + int EventLoop::EventFdCallback(int fd, int events, void* data) { LooperCallbackScope callbackScope; uint64_t value; diff --git a/test-app/runtime/src/main/cpp/EventLoop.h b/test-app/runtime/src/main/cpp/EventLoop.h index 1940ffad9..ef65e6a78 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.h +++ b/test-app/runtime/src/main/cpp/EventLoop.h @@ -194,6 +194,15 @@ class EventLoop { */ static bool IsInLooperCallback(); + /** + * Blocks the calling thread until this loop's internal lane has work (the + * eventfd or timerfd is readable) or `timeoutMs` elapses, whichever comes + * first, without consuming either fd and without entering the looper - so + * it is safe where IsInLooperCallback forbids polling. Pumps pair it with + * RunNestableV8Tasks, which drains the queue directly. + */ + void WaitForInternalWork(int timeoutMs); + /** * Runs at most one due ordered-lane entry, then performs a microtask * checkpoint. Invoked by Java EventLoopHandler.handleMessage once per diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index 9558f6c6a..c2b589670 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -1303,10 +1303,14 @@ MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local co isolate->PerformMicrotaskCheckpoint(); if (options.pumpRunLoop) { // Nested ALooper_pollOnce inside an fd callback dangles the outer - // poll's Response& (see EventLoop::IsInLooperCallback); the direct - // drains above keep the graph moving, so only yield the CPU here. + // poll's Response& (see EventLoop::IsInLooperCallback); wait on + // the loop's own fds instead - same wakeups, no looper re-entry. if (EventLoop::IsInLooperCallback()) { - usleep(1000); + if (eventLoop != nullptr) { + eventLoop->WaitForInternalWork(10); + } else { + usleep(1000); + } } else { ALooper_pollOnce(10 /* ms */, nullptr, nullptr, nullptr); } @@ -1339,7 +1343,13 @@ MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local co pumpAsyncProgress(); if (!options.pumpRunLoop) { - usleep(1000); // 1ms delay for non-HTTP top-level await polling + // Wakes on the next internal-lane task (fetch completion, TLA + // continuation) instead of a fixed spin interval. + if (eventLoop != nullptr) { + eventLoop->WaitForInternalWork(10); + } else { + usleep(1000); + } } } diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 5ee25ccc2..71c56c110 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -1769,10 +1769,14 @@ bool RunModuleGraphLoadPumped(v8::Isolate* isolate, } if (*done) break; // Polling the looper from inside one of its fd callbacks dangles the - // outer poll's Response& (see EventLoop::IsInLooperCallback); the direct - // drain above still delivers fetch completions, so just yield instead. + // outer poll's Response& (see EventLoop::IsInLooperCallback); wait on the + // loop's own fds instead - same wakeups, no looper re-entry. if (EventLoop::IsInLooperCallback()) { - usleep(1000); + if (eventLoop != nullptr) { + eventLoop->WaitForInternalWork(10); + } else { + usleep(1000); + } } else { ALooper_pollOnce(10 /* ms */, nullptr, nullptr, nullptr); } diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 56f1028af..b60a8d016 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -403,9 +403,14 @@ static void HoldBootBackstop(v8::Isolate* isolate, const std::string& entryPath) isolate->PerformMicrotaskCheckpoint(); // See EventLoop::IsInLooperCallback: a nested poll corrupts the outer // poll's response state. Boot normally reaches this outside any dispatch, - // but an HTTP entry re-run from a dev-session task must not poll. + // but an HTTP entry re-run from a dev-session task must not poll - it + // waits on the loop's own fds instead: same wakeups, no looper re-entry. if (EventLoop::IsInLooperCallback()) { - usleep(1000); + if (eventLoop != nullptr) { + eventLoop->WaitForInternalWork(10); + } else { + usleep(1000); + } } else { ALooper_pollOnce(10, nullptr, nullptr, nullptr); } From 47333f4cd95c0a9f961231c683aa90f6e634ae58 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 22:09:44 -0300 Subject: [PATCH 35/36] feat(runtime): one pump primitive that drains due JS timers EventLoop::PumpUntil replaces the three hand-rolled module pump loops: settled-check first (a disk graph pays nothing), execution-termination and shutdown exits, deadline, nestable-task drain, microtask checkpoint, then a bounded drain of DUE ordered-lane entries - JS timers included - before idling on the loop's own fds. A pump can finally settle an entry that awaits setTimeout: the ordered lane's work lives in native bookkeeping and its Java Handler messages are only wakeups, so the drain runs due items through the same earliest-due-across-domain selection the token path uses, and the orphaned tokens retire against the claim gate exactly as a cancelled timer's do. An 8ms slice bound keeps a setTimeout(0) chain from pinning the pump past its deadline checks. WaitForInternalWork blocks properly instead of spinning: each posted entry records whether it issued an eventfd unit, direct drains count the units they orphan, and the wait swallows exactly those before polling. Its due-work early-return applies the same nestable/v8 filter the pump's drain does - a due entry the drain cannot take (a non-nestable task or plain post) must not turn the wait into a spin, and when such an entry pins the fds readable the wait falls back to a plain sleep until the looper can service it. A drained delayed entry rearms the timerfd. Timers::RunIfEarliest saves and restores the ambient nesting level instead of resetting it: nested dispatch under a pump would have handed the outer callback's remaining setTimeout calls a nesting level of zero. --- docs/ns-builtin-modules.md | 23 ++- test-app/runtime/src/main/cpp/EventLoop.cpp | 184 +++++++++++++++++--- test-app/runtime/src/main/cpp/EventLoop.h | 49 +++++- test-app/runtime/src/main/cpp/Timers.cpp | 6 +- 4 files changed, 226 insertions(+), 36 deletions(-) diff --git a/docs/ns-builtin-modules.md b/docs/ns-builtin-modules.md index a7e4e9a30..aea37ee52 100644 --- a/docs/ns-builtin-modules.md +++ b/docs/ns-builtin-modules.md @@ -779,13 +779,22 @@ graph walk, and — doubled, at 120 seconds — the boot backstop, so the waits stay ordered: transport timeouts < module deadline < boot backstop. The local entry's short yield is deliberately *not* derived from that constant: it is an independent one-second literal in `BootEntryEvaluationOptions`, with -`return-pending` behavior and no looper slicing. An HTTP entry instead gets the -full deadline, throws on expiry, and does slice the looper, because the tooling -driving it needs the rejection reason synchronously. The backstop itself is -`HoldBootBackstop` in -`Runtime.cpp`, called from both `Runtime::RunModule` overloads; it pumps -nestable V8 tasks, microtask checkpoints and `ALooper_pollOnce` until the entry -and all async graph work settle. +`return-pending` behavior. An HTTP entry instead gets the full deadline and +throws on expiry, because the tooling driving it needs the rejection reason +synchronously. The backstop itself is `HoldBootBackstop` in `Runtime.cpp`, +called from both `Runtime::RunModule` overloads; it pumps the isolate's event +loop in place (`EventLoop::PumpUntil`) until the entry and all async graph +work settle. + +Every pump on Android — a pumping require, the graph walk, the boot backstop — +runs the same `EventLoop::PumpUntil` slice: nestable V8 tasks, a microtask +checkpoint, and the loop's own **ordered lane drained directly** (JS timers +ride Java `Handler` messages, which cannot dispatch while the pump's JS frames +hold the thread — the drain is what lets an entry or a pumped graph parked on +`setTimeout` settle in-pump). The pump never re-enters the platform looper, so +on Android `pumpRunLoop` is validated and carried but adds nothing beyond that +baseline; the option's cross-platform meaning and its warnings above are +unchanged. Workers copy the loader vocabulary from the parent at spawn (`CaptureLoaderVocabulary` on the parent's thread, `InstallLoaderVocabulary` diff --git a/test-app/runtime/src/main/cpp/EventLoop.cpp b/test-app/runtime/src/main/cpp/EventLoop.cpp index 1f5d96682..31d73aca1 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.cpp +++ b/test-app/runtime/src/main/cpp/EventLoop.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -133,9 +134,10 @@ void EventLoop::BindToCurrentThread() { // flush work buffered before the home thread was known auto now = now_ms(); - for (size_t i = 0; i < internal_.immediate.size(); i++) { + for (auto& entry : internal_.immediate) { uint64_t value = 1; write(eventFd_, &value, sizeof(value)); + entry.unitIssued = true; } ArmTimerLocked(now); for (auto& entry : ordered_.immediate) { @@ -204,6 +206,7 @@ void EventLoop::PostInternalLocked(Entry entry, double delayMs) { auto now = now_ms(); if (delayMs <= 0) { entry.time = now; + entry.unitIssued = eventFd_ != -1; internal_.immediate.push_back(std::move(entry)); if (eventFd_ != -1) { uint64_t value = 1; @@ -451,6 +454,28 @@ double EventLoop::PeekDueLocked(Lane& lane, double now) { return due; } +double EventLoop::PeekDueFilteredLocked(Lane& lane, bool nestableOnly, bool v8Only, double now) { + auto matches = [&](const Entry& e) { + return (!nestableOnly || e.nestable) && (!v8Only || e.task != nullptr); + }; + double due = -1; + for (const auto& e : lane.immediate) { + if (matches(e)) { + due = e.time; + break; + } + } + for (const auto& pair : lane.delayed) { + if (pair.first > now) { + break; + } + if (matches(pair.second) && (due < 0 || pair.first < due)) { + due = pair.first; + } + } + return due; +} + void EventLoop::ArmTimerLocked(double now) { if (timerFd_ == -1) { return; @@ -503,11 +528,15 @@ void EventLoop::RunOneInternal() { return; } entry = TakeDueLocked(internal_, false, false, true, now_ms()); - } - if (entry == nullptr) { - // leftover unit: the work it represented ran early from a nested loop - // drain - return; + if (entry == nullptr) { + // leftover unit: the work it represented ran early from a direct + // drain - this dispatch just consumed it, so it is no longer + // WaitForInternalWork's to swallow + if (leftoverUnits_ > 0) { + leftoverUnits_--; + } + return; + } } RunEntry(*entry); } @@ -527,10 +556,20 @@ void EventLoop::RunNestableV8Tasks() { if (stopped_) { return; } + const size_t delayedBefore = internal_.delayed.size(); entry = TakeDueLocked(internal_, true, true, false, now_ms()); - } - if (entry == nullptr) { - return; + if (entry == nullptr) { + return; + } + if (entry->unitIssued) { + leftoverUnits_++; + } + if (internal_.delayed.size() != delayedBefore) { + // a drained delayed entry may leave the timerfd armed (or + // expired unread) for it; rearming to the queue's new + // earliest also discards the stale expiration + ArmTimerLocked(now_ms()); + } } // the pause loops call this from inside v8 inspector frames - a C++ // exception must not unwind through them @@ -538,38 +577,100 @@ void EventLoop::RunNestableV8Tasks() { } } -void EventLoop::RunOrderedTask() { - // one anonymous token = one due slot across the whole ordered domain: - // pick the earliest due item among the ordered entries and the timer - // source, whichever it is. Timers and entries only ever run on this - // thread, so the peeked winner can't be taken by anyone else before we - // re-lock (a concurrent post can only add later work). +bool EventLoop::RunOneOrderedDue() { + // one due slot across the whole ordered domain: pick the earliest due + // item among the ordered entries and the timer source, whichever it is. + // Timers and entries only ever run on this thread, so the peeked winner + // can't be taken by anyone else before we re-lock (a concurrent post can + // only add later work). auto now = now_ms(); double entryDue; { std::lock_guard lock(mutex_); if (stopped_) { - return; + return false; } entryDue = PeekDueLocked(ordered_, now); } if (timerSource_ != nullptr && timerSource_->RunIfEarliest(now, entryDue)) { - return; + return true; } if (entryDue < 0) { - // leftover token: nothing in the domain is due yet - return; + // leftover token, or an idle drain: nothing in the domain is due yet + return false; } std::unique_ptr entry; { std::lock_guard lock(mutex_); if (stopped_) { - return; + return false; } entry = TakeDueLocked(ordered_, false, false, false, now_ms()); } - if (entry != nullptr) { - RunEntry(*entry); + if (entry == nullptr) { + return false; + } + RunEntry(*entry); + return true; +} + +void EventLoop::RunOrderedTask() { + // one anonymous token = one due slot; a token whose item a pump drained + // early finds nothing due and dies here + RunOneOrderedDue(); +} + +int EventLoop::RunDueOrderedEntries() { + // Bounded slice: a callback that keeps minting due-now work (a + // setTimeout(0) chain) must not pin the calling pump past its own + // deadline checks, so the drain yields after a few milliseconds and the + // pump comes back for the rest on its next iteration. + constexpr double kSliceMs = 8.0; + const double start = now_ms(); + int ran = 0; + while (RunOneOrderedDue()) { + ran++; + if (now_ms() - start >= kSliceMs) { + break; + } + } + return ran; +} + +EventLoop::PumpResult EventLoop::PumpUntil(double deadlineSeconds, + const std::function& settled) { + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::duration(deadlineSeconds); + for (;;) { + if (settled()) { + return PumpResult::kSettled; + } + if (isolate_ != nullptr && isolate_->IsExecutionTerminating()) { + return PumpResult::kTerminated; + } + if (IsStopped()) { + // a stopped loop drops every post, so nothing can settle anymore + return PumpResult::kTerminated; + } + if (std::chrono::steady_clock::now() >= deadline) { + return PumpResult::kDeadline; + } + RunNestableV8Tasks(); + { + // work may enqueue microtasks without entering JS; scopes are + // re-entrant, so callers already holding them pay nothing + v8::Locker locker(isolate_); + v8::Isolate::Scope isolateScope(isolate_); + v8::HandleScope handleScope(isolate_); + isolate_->PerformMicrotaskCheckpoint(); + } + const int ranOrdered = RunDueOrderedEntries(); + if (settled()) { + return PumpResult::kSettled; + } + if (ranOrdered == 0) { + WaitForInternalWork(10); + } } } @@ -589,12 +690,44 @@ bool EventLoop::IsInLooperCallback() { return t_looperCallbackDepth > 0; } void EventLoop::WaitForInternalWork(int timeoutMs) { struct pollfd fds[2]; nfds_t count = 0; + bool sleepOnly = false; { std::lock_guard lock(mutex_); - if (eventFd_ != -1) fds[count++] = {eventFd_, POLLIN, 0}; - if (timerFd_ != -1) fds[count++] = {timerFd_, POLLIN, 0}; + if (stopped_) { + sleepOnly = true; + } else { + const double now = now_ms(); + // Drainable work already due: the caller's drain runs it, waiting + // would only add latency. The filter must match RunNestableV8Tasks + // (nestable v8 tasks only) — a due entry the pump cannot take must + // not turn the wait into a no-op. + if (PeekDueFilteredLocked(internal_, /*nestableOnly=*/true, /*v8Only=*/true, now) >= + 0) { + return; + } + // units whose entries a direct drain already consumed keep the + // eventfd readable; swallow them or the poll below returns + // immediately on every call + while (leftoverUnits_ > 0 && eventFd_ != -1) { + uint64_t value; + if (read(eventFd_, &value, sizeof(value)) != sizeof(value)) { + break; + } + leftoverUnits_--; + } + // A due entry the drain cannot take (non-nestable task, plain fn + // post) pins its unread unit in the eventfd, so the fds cannot go + // quiet — polling them would spin. Plain sleep is the only honest + // wait until the looper resumes and runs it. + if (PeekDueLocked(internal_, now) >= 0) { + sleepOnly = true; + } else { + if (eventFd_ != -1) fds[count++] = {eventFd_, POLLIN, 0}; + if (timerFd_ != -1) fds[count++] = {timerFd_, POLLIN, 0}; + } + } } - if (count == 0) { + if (sleepOnly || count == 0) { usleep(static_cast(timeoutMs) * 1000); return; } @@ -635,6 +768,7 @@ int EventLoop::TimerFdCallback(int fd, int events, void* data) { } if (!pair.second.signaled) { pair.second.signaled = true; + pair.second.unitIssued = true; due++; } } diff --git a/test-app/runtime/src/main/cpp/EventLoop.h b/test-app/runtime/src/main/cpp/EventLoop.h index ef65e6a78..5b793605a 100644 --- a/test-app/runtime/src/main/cpp/EventLoop.h +++ b/test-app/runtime/src/main/cpp/EventLoop.h @@ -197,12 +197,41 @@ class EventLoop { /** * Blocks the calling thread until this loop's internal lane has work (the * eventfd or timerfd is readable) or `timeoutMs` elapses, whichever comes - * first, without consuming either fd and without entering the looper - so - * it is safe where IsInLooperCallback forbids polling. Pumps pair it with - * RunNestableV8Tasks, which drains the queue directly. + * first, without entering the looper - so it is safe where + * IsInLooperCallback forbids polling. Returns immediately when internal + * work is already due. Eventfd units left over from entries a direct + * drain (RunNestableV8Tasks) consumed are swallowed first, so the wait + * only wakes for new work instead of spinning on stale readability. */ void WaitForInternalWork(int timeoutMs); + /** + * Runs every ordered-lane item that is due NOW - Java-token entries and + * timer-source items alike, in due order - directly from the calling + * (home) thread, without waiting for their Handler messages. The messages + * still arrive later and die as leftover tokens: a token whose item was + * drained early finds nothing due and no-ops, and its claim cell is + * retired by the dispatch gate as usual. Bounded to a short slice so a + * callback minting due-now work (a setTimeout(0) chain) cannot pin the + * caller past its own deadline checks. Returns the number of items run. + */ + int RunDueOrderedEntries(); + + enum class PumpResult { kSettled, kDeadline, kTerminated }; + + /** + * Drives this loop in place on the home thread until `settled()` returns + * true or `deadlineSeconds` elapses: nestable v8 tasks, a microtask + * checkpoint, and due ordered-lane work per iteration, idling in + * WaitForInternalWork between slices. The one pump primitive behind + * module evaluation, the graph walk, and the boot backstop - JS frames + * are on the stack throughout, so non-nestable v8 tasks and plain + * internal posts stay queued, exactly as in the inspector pause loops. + * Returns kTerminated when the isolate is terminating or the loop has + * been shut down; `settled` may throw and the exception propagates. + */ + PumpResult PumpUntil(double deadlineSeconds, const std::function& settled); + /** * Runs at most one due ordered-lane entry, then performs a microtask * checkpoint. Invoked by Java EventLoopHandler.handleMessage once per @@ -228,6 +257,10 @@ class EventLoop { // this entry (written when its timerfd deadline fired), so a later // timer fire must not issue a second one bool signaled = false; + // internal entries only: an eventfd unit backs this entry, so a + // direct (unit-free) drain that consumes it must count the unit as + // leftover for WaitForInternalWork to swallow + bool unitIssued = false; }; struct Lane { std::deque immediate; @@ -248,9 +281,15 @@ class EventLoop { bool requireSignaledDelayed, double now); // earliest due entry time in the lane, or a negative value if none is due static double PeekDueLocked(Lane& lane, double now); + // same, but only over entries matching TakeDueLocked's nestable/v8 filter + static double PeekDueFilteredLocked(Lane& lane, bool nestableOnly, bool v8Only, double now); void ArmTimerLocked(double now); void RunEntry(Entry& entry); void RunOneInternal(); + // one due slot across the ordered domain (entries + timer source); true + // when a slot was consumed. The body behind both RunOrderedTask (one call + // per Java token) and RunDueOrderedEntries (looped by the pumps). + bool RunOneOrderedDue(); static int EventFdCallback(int fd, int events, void* data); static int TimerFdCallback(int fd, int events, void* data); @@ -307,6 +346,10 @@ class EventLoop { int eventFd_ = -1; int timerFd_ = -1; bool stopped_ = false; + // units written to eventFd_ whose entries a direct drain already ran; + // consumed by WaitForInternalWork (or by an EventFdCallback that finds + // nothing due). Guarded by mutex_. + uint64_t leftoverUnits_ = 0; // process-wide JNI cache, written once under the first bind's lock (the // main runtime binds before any worker thread exists) diff --git a/test-app/runtime/src/main/cpp/Timers.cpp b/test-app/runtime/src/main/cpp/Timers.cpp index 232db81c0..996f67f05 100644 --- a/test-app/runtime/src/main/cpp/Timers.cpp +++ b/test-app/runtime/src/main/cpp/Timers.cpp @@ -331,6 +331,10 @@ bool Timers::RunIfEarliest(double now, double otherDue) { // task is no longer in queue to be executed task->queued_ = false; #ifdef NS_TIMERS_NESTING_CLAMP + // save/restore, not reset: the event-loop pump dispatches timers + // nested inside an outer timer's callback, and the outer callback's + // remaining setTimeout calls must keep the outer nesting level + const int enclosingNesting = nesting; nesting = task->nestingLevel_; #endif if (task->repeats_) { @@ -363,7 +367,7 @@ bool Timers::RunIfEarliest(double now, double otherDue) { } #ifdef NS_TIMERS_NESTING_CLAMP - nesting = 0; + nesting = enclosingNesting; #endif if (tc.HasCaught() && From d4e98c688f5223ae0d8394954687d8e71aff9b7b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Wed, 19 Aug 2026 22:09:44 -0300 Subject: [PATCH 36/36] fix(runtime): route pumps through PumpUntil and close the evaluation findings All three pumps (module evaluation, graph load, boot backstop) run on the shared primitive: no ALooper_pollOnce remains anywhere - polling the looper from module code is gone along with the unconditional slice that ran non-nestable tasks under app JS frames. A pumping require or boot entry awaiting a timer settles; execution termination ends a pump instead of masquerading as a TLA timeout; pumpRunLoop is validated and carried but adds nothing on Android (documented). Runtime lookups at V8 callback and task boundaries use TryGetRuntime - GetRuntime throws, so every ported null-guard was dead and two sites could unwind a C++ exception through V8 frames. A failed CommonJS main is now fatal through the JNI boundary instead of leaving its exception pending under the backstop's pump. Promise settles use FromMaybe or reject with the thrown reason instead of Check-aborting on a throwing user 'then'. The dirname strcpy into a fixed buffer is gone. IsHttpModulePath classifies the normalized URL. The boot fetch-yield complex is deleted end to end (yield registration, boot-evaluation depth, the mid-fetch looper pump): nothing registered a yield, the default fired after the fetch it claimed to overlap, and Android boot has no window to repaint. The sync-fetch anomaly guard itself stays. HttpURLConnection fetches no longer disable process-wide keep-alive; the StrictMode policy is restored on exit instead of being permanently replaced; the sync fetch's JNI locals live in a pushed frame. configureLoader's non-serializable importMap guard actually fires. MarkKeysForCacheBust takes canonical keys verbatim. Dead diagnostics and unused primordials are gone. The pump specs pin the runtime's own ordered-lane timers via __ns__setTimeout: the test app's global setTimeout is a Java-Handler polyfill that no pump can dispatch by construction. --- .../app/esm/createrequire/timer-tla.mjs | 9 + .../assets/app/tests/esmEntryTimerWorker.mjs | 14 + .../assets/app/tests/testCreateRequire.js | 17 + .../assets/app/tests/testEsmHttpLoader.js | 28 + .../assets/app/tests/testWorkerEsmEntry.js | 11 + test-app/runtime/src/main/cpp/HttpLoader.cpp | 645 +++++++++--------- test-app/runtime/src/main/cpp/HttpLoader.h | 49 +- .../runtime/src/main/cpp/ModuleInternal.cpp | 172 +++-- .../runtime/src/main/cpp/ModuleInternal.h | 11 +- .../src/main/cpp/ModuleInternalCallbacks.cpp | 95 ++- .../src/main/cpp/ModuleInternalCallbacks.h | 9 +- test-app/runtime/src/main/cpp/Runtime.cpp | 52 +- .../runtime/src/main/cpp/js/primordials.js | 5 - 13 files changed, 571 insertions(+), 546 deletions(-) create mode 100644 test-app/app/src/main/assets/app/esm/createrequire/timer-tla.mjs create mode 100644 test-app/app/src/main/assets/app/tests/esmEntryTimerWorker.mjs diff --git a/test-app/app/src/main/assets/app/esm/createrequire/timer-tla.mjs b/test-app/app/src/main/assets/app/esm/createrequire/timer-tla.mjs new file mode 100644 index 000000000..7b29f1596 --- /dev/null +++ b/test-app/app/src/main/assets/app/esm/createrequire/timer-tla.mjs @@ -0,0 +1,9 @@ +// Settles only if due JS timers run while the module evaluation promise is +// pending: the resolution rides the ordered lane (a Java Handler token), which +// no Handler dispatch can deliver while the pump's JS frames hold the thread. +// The runtime primitive is used directly: the test app's global setTimeout is +// a Java-Handler polyfill the pump cannot dispatch, and this fixture pins the +// runtime's own ordered-lane timers. +export const value = await new Promise(function (resolve) { + __ns__setTimeout(function () { resolve("timer-ok"); }, 10); +}); diff --git a/test-app/app/src/main/assets/app/tests/esmEntryTimerWorker.mjs b/test-app/app/src/main/assets/app/tests/esmEntryTimerWorker.mjs new file mode 100644 index 000000000..3a220c043 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/esmEntryTimerWorker.mjs @@ -0,0 +1,14 @@ +// An ES module worker entry whose top-level await parks on a JS timer: the +// resolution rides the worker looper's ordered lane, which the entry pump +// drains directly, so the entry settles inside the yield window. The runtime +// primitive is used directly: the test app wires global setTimeout on the +// main thread only, and as a Java-Handler polyfill the pump could not +// dispatch it anyway — this fixture pins the runtime's own ordered-lane +// timers. +const settled = await new Promise(function (resolve) { + __ns__setTimeout(function () { resolve("ok"); }, 10); +}); + +globalThis.onmessage = function (msg) { + postMessage("timer-entry:" + settled + ":" + msg.data); +}; diff --git a/test-app/app/src/main/assets/app/tests/testCreateRequire.js b/test-app/app/src/main/assets/app/tests/testCreateRequire.js index 8ddad7881..133f07204 100644 --- a/test-app/app/src/main/assets/app/tests/testCreateRequire.js +++ b/test-app/app/src/main/assets/app/tests/testCreateRequire.js @@ -182,6 +182,23 @@ describe("createRequire", function () { }); }); + // A timer's Handler token cannot dispatch while the pumping require + // holds the thread, so this settles only through the pump's direct + // ordered-lane drain. + it("settles a top-level await parked on a JS timer while pumping", function (done) { + onFreshTask(function () { + var pumpingRequire = nsModule.createPumpingRequire(fixtureDir + "/anything.js"); + var result = ""; + try { + result = String(pumpingRequire("./timer-tla.mjs").value); + } catch (e) { + result = "threw: " + ((e && e.message) || e); + } + expect(result).toBe("timer-ok"); + done(); + }); + }); + it("refuses to pump a top-level-await graph from inside a microtask", function (done) { var pumpingRequire = nsModule.createPumpingRequire(fixtureDir + "/anything.js"); Promise.resolve().then(function () { diff --git a/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js b/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js index c21dbf9d5..c020089ae 100644 --- a/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js +++ b/test-app/app/src/main/assets/app/tests/testEsmHttpLoader.js @@ -413,6 +413,34 @@ describe("HTTP ESM Loader", function () { }).toThrowError(TypeError, /canonicalization\.forPathPrefixes\[0\] must be a string/); }); + // A function is an object to the engine, and JSON.stringify answers + // the literal text "undefined" for one rather than failing, so it + // has to be turned away before the map parser ever sees it. + it("rejects a function importMap and leaves the installed map in place", function (done) { + nsModule.configureLoader({ + importMap: { imports: { "ns-fnmap-leaf": "~/esm/vocab/leafA.mjs" } }, + }); + + expect(function () { + nsModule.configureLoader({ importMap: function () {} }); + }).toThrowError(TypeError, /importMap must be an object or a JSON string/); + + function restore() { + nsModule.configureLoader({ importMap: { imports: {} } }); + } + + // A bare specifier resolves only through the map, so it still + // importing proves the rejected call replaced nothing. + import("ns-fnmap-leaf").then(function (mod) { + expect(mod.name).toBe("vocab-a"); + restore(); + done(); + }).catch(function (error) { + restore(); + reportRejection(error, done); + }); + }); + it("rejects a non-array invalidateModules argument", function () { expect(function () { nsModule.invalidateModules("x"); diff --git a/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js b/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js index f055104ee..9c66a4fad 100644 --- a/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js +++ b/test-app/app/src/main/assets/app/tests/testWorkerEsmEntry.js @@ -40,6 +40,17 @@ describe("worker ES module entries", function () { worker.postMessage("ping"); }); + it("runs an ES module worker entry whose top-level await parks on a JS timer", + function (done) { + var worker = new Worker("~/tests/esmEntryTimerWorker.mjs"); + worker.onmessage = function (msg) { + expect(msg.data).toBe("timer-entry:ok:ping"); + worker.terminate(); + done(); + }; + worker.postMessage("ping"); + }); + it("runs an ES module worker entry spawned through a relative path", function (done) { var worker = new Worker("./esmEntryRelativeWorker.mjs"); worker.onmessage = function (msg) { diff --git a/test-app/runtime/src/main/cpp/HttpLoader.cpp b/test-app/runtime/src/main/cpp/HttpLoader.cpp index b41acd0cb..db6850673 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.cpp +++ b/test-app/runtime/src/main/cpp/HttpLoader.cpp @@ -1,6 +1,5 @@ #include "HttpLoader.h" -#include #include #include @@ -16,7 +15,6 @@ #include #include "ArgConverter.h" -#include "EventLoop.h" #include "JEnv.h" #include "ModuleInternal.h" #include "ModuleInternalCallbacks.h" @@ -150,22 +148,6 @@ bool IsRemoteUrlAllowed(const std::string& url) { return false; } -// ───────────────────────────────────────────────────────────── -// Boot-evaluation flag - -// Nonzero while this thread is evaluating an entry module (main or worker) — -// the only window in which the fetch yield may pump the looper: during entry -// evaluation nothing else owns it, while pumping mid-app would re-enter -// arbitrary user code under a synchronous fetch. Thread-local because a fetch -// and the entry evaluation that triggered it always share a thread, so a -// worker booting never arms the main thread's pump. The runtime derives this -// itself — there is no client signal to forget. -static thread_local int t_bootEvaluationDepth = 0; - -void SetBootEvaluationActive(bool active) { t_bootEvaluationDepth += active ? 1 : -1; } - -static inline bool IsBootEvaluationActive() { return t_bootEvaluationDepth > 0; } - // ───────────────────────────────────────────────────────────── // Canonical module keys @@ -271,13 +253,13 @@ std::string CanonicalizeHttpUrlKey(const std::string& url) { static std::mutex g_bustNextFetchMutex; static robin_hood::unordered_set g_bustNextFetchKeys; -void MarkUrlsForCacheBust(const std::vector& urls) { - if (urls.empty()) return; +void MarkKeysForCacheBust(const std::vector& canonicalKeys) { + if (canonicalKeys.empty()) return; std::lock_guard lock(g_bustNextFetchMutex); - for (const auto& url : urls) { - if (url.empty()) continue; - if (!(StartsWith(url, "http://") || StartsWith(url, "https://"))) continue; - g_bustNextFetchKeys.insert(CanonicalizeHttpUrlKey(url)); + for (const auto& key : canonicalKeys) { + if (key.empty()) continue; + if (!(StartsWith(key, "http://") || StartsWith(key, "https://"))) continue; + g_bustNextFetchKeys.insert(key); } } @@ -301,28 +283,6 @@ static void ClearAllCacheBustMarks() { // ───────────────────────────────────────────────────────────── // JNI fetch diagnostics + request builder -static thread_local std::string t_lastHttpFetchErrorReason; - -static void RecordLastHttpFetchError(const char* stage, const std::string& excClass, - const std::string& excMsg) { - t_lastHttpFetchErrorReason.assign("stage="); - t_lastHttpFetchErrorReason.append(stage ? stage : "?"); - t_lastHttpFetchErrorReason.append(" class="); - t_lastHttpFetchErrorReason.append(excClass); - t_lastHttpFetchErrorReason.append(" msg="); - t_lastHttpFetchErrorReason.append(excMsg); -} - -static void ClearLastHttpFetchErrorReason() { - t_lastHttpFetchErrorReason.clear(); -} - -std::string TakeLastHttpFetchErrorReason() { - std::string out = std::move(t_lastHttpFetchErrorReason); - t_lastHttpFetchErrorReason.clear(); - return out; -} - // Describes and clears a pending Java exception. The introspection calls go // through the raw JNIEnv: JEnv's wrappers turn a pending Java exception into a // thrown NativeScriptException, which here would replace the exception being @@ -364,8 +324,6 @@ static bool DrainPendingJniException(JEnv& env, std::string& outClassName, std:: static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& canonicalKey, std::string& out, std::string& contentType, int& status, bool& bustApplied); -static void MaybePumpJSThreadDuringBoot(); -static inline void InvokeHttpFetchYield(); static std::string ApplyCacheBustNonce(const std::string& url, const std::string& canonicalKey, bool* outBustRequested) { @@ -388,50 +346,69 @@ static std::string ApplyCacheBustNonce(const std::string& url, const std::string return fetchUrl; } -static void DisableHttpKeepAliveOnce(JEnv& env) { - static std::atomic sKeepAliveDisabled{false}; - if (sKeepAliveDisabled.exchange(true)) { - return; - } - jclass clsSystem = env.FindClass("java/lang/System"); - if (clsSystem) { - jmethodID setProperty = env.GetStaticMethodID( - clsSystem, "setProperty", - "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"); - if (setProperty) { - jstring jKey = env.NewStringUTF("http.keepAlive"); - jstring jVal = env.NewStringUTF("false"); - env.CallStaticObjectMethod(clsSystem, setProperty, jKey, jVal); - env.ExceptionClear(); +// A blocking network call on the JS thread is exactly what StrictMode is meant +// to flag, so the fetch relaxes the thread policy — but the policy belongs to +// the app, not to this request: whatever runs next on this thread must get its +// own policy back, including when the fetch leaves through an exception. +// Restoring uses the raw JNIEnv (JEnv's wrappers throw, and a destructor may +// run mid-unwind). +struct StrictModeScope { + JNIEnv* jni = nullptr; + jclass clsStrict = nullptr; + jmethodID setThreadPolicy = nullptr; + jobject savedPolicy = nullptr; + + explicit StrictModeScope(JEnv& env) { + clsStrict = env.FindClass("android/os/StrictMode"); + jclass clsPolicyBuilder = env.FindClass("android/os/StrictMode$ThreadPolicy$Builder"); + if (!clsStrict || !clsPolicyBuilder) { + return; } - } -} - -static void PermitAllStrictMode(JEnv& env) { - jclass clsStrict = env.FindClass("android/os/StrictMode"); - jclass clsPolicyBuilder = env.FindClass("android/os/StrictMode$ThreadPolicy$Builder"); - if (!clsStrict || !clsPolicyBuilder) { - return; - } - jmethodID builderCtor = env.GetMethodID(clsPolicyBuilder, "", "()V"); - jobject builder = env.NewObject(clsPolicyBuilder, builderCtor); - if (!builder) { - return; - } - jmethodID permitAll = env.GetMethodID(clsPolicyBuilder, "permitAll", - "()Landroid/os/StrictMode$ThreadPolicy$Builder;"); - jobject builder2 = permitAll ? env.CallObjectMethod(builder, permitAll) : builder; - jmethodID build = env.GetMethodID(clsPolicyBuilder, "build", - "()Landroid/os/StrictMode$ThreadPolicy;"); - jobject policy = build ? env.CallObjectMethod(builder2 ? builder2 : builder, build) : nullptr; - if (policy) { - jmethodID setThreadPolicy = env.GetStaticMethodID( + jmethodID getThreadPolicy = env.GetStaticMethodID( + clsStrict, "getThreadPolicy", "()Landroid/os/StrictMode$ThreadPolicy;"); + jmethodID setter = env.GetStaticMethodID( clsStrict, "setThreadPolicy", "(Landroid/os/StrictMode$ThreadPolicy;)V"); - if (setThreadPolicy) { - env.CallStaticVoidMethod(clsStrict, setThreadPolicy, policy); + if (!getThreadPolicy || !setter) { + return; + } + // No captured policy means no way back, so leave the thread alone + // rather than relaxing it permanently. + jobject captured = env.CallStaticObjectMethod(clsStrict, getThreadPolicy); + if (!captured) { + return; } + + jmethodID builderCtor = env.GetMethodID(clsPolicyBuilder, "", "()V"); + jobject builder = env.NewObject(clsPolicyBuilder, builderCtor); + if (!builder) { + return; + } + jmethodID permitAll = env.GetMethodID(clsPolicyBuilder, "permitAll", + "()Landroid/os/StrictMode$ThreadPolicy$Builder;"); + jobject builder2 = permitAll ? env.CallObjectMethod(builder, permitAll) : builder; + jmethodID build = env.GetMethodID(clsPolicyBuilder, "build", + "()Landroid/os/StrictMode$ThreadPolicy;"); + jobject policy = build ? env.CallObjectMethod(builder2 ? builder2 : builder, build) + : nullptr; + if (!policy) { + return; + } + env.CallStaticVoidMethod(clsStrict, setter, policy); + // Armed only once the permissive policy is actually in force. + setThreadPolicy = setter; + savedPolicy = captured; + jni = env; } -} + + StrictModeScope(const StrictModeScope&) = delete; + StrictModeScope& operator=(const StrictModeScope&) = delete; + + ~StrictModeScope() { + if (jni == nullptr) return; + jni->CallStaticVoidMethod(clsStrict, setThreadPolicy, savedPolicy); + jni->ExceptionClear(); + } +}; // ── The module response policy ─────────────────────────────── // @@ -563,7 +540,6 @@ static void ClassifyModuleResponse(const std::string& url, bool transportOk, int bool HttpFetchModule(const std::string& url, ModuleFetchResult& result) { result = ModuleFetchResult(); - ClearLastHttpFetchErrorReason(); // Security gate: the single point of enforcement for all HTTP module // loading, checked before any network turn. @@ -625,7 +601,6 @@ bool HttpFetchModule(const std::string& url, ModuleFetchResult& result) { (unsigned long)result.body.size(), (long long)netMs); } - InvokeHttpFetchYield(); return true; } @@ -651,7 +626,6 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& auto recordStageFailure = [&url](const char* stage, const std::string& excClass, const std::string& excMsg) { - RecordLastHttpFetchError(stage, excClass, excMsg); TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=%s url=%s class=%s msg=%s", stage, url.c_str(), excClass.c_str(), excMsg.c_str()); }; @@ -659,8 +633,25 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& try { JEnv env; JNIEnv* raw = env; - DisableHttpKeepAliveOnce(env); - PermitAllStrictMode(env); + + // Request setup alone burns a couple of dozen local refs (a jstring + // per header, one per drained exception), and the sync path runs + // inside a caller's frame — V8's resolve walk — that must not be left + // holding them. Pushed first, so everything scoped inside it, + // StrictModeScope included, is torn down while its refs are still live. + const bool framePushed = raw->PushLocalFrame(64) == JNI_OK; + if (!framePushed) { + raw->ExceptionClear(); + } + struct LocalFrame { + JNIEnv* jni; + bool pushed; + ~LocalFrame() { + if (pushed) jni->PopLocalFrame(nullptr); + } + } localFrame{raw, framePushed}; + + StrictModeScope strictMode(env); jclass clsURL = env.FindClass("java/net/URL"); if (!clsURL) return false; @@ -838,9 +829,6 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& } if (status == 0) status = 200; - // Keeps TakeLastHttpFetchErrorReason's contract across a recovered - // retry: a reason belongs to the attempt that failed, not to the fetch. - ClearLastHttpFetchErrorReason(); // Pure transport: true means a response arrived. Whether that response // is a usable module — status, MIME, emptiness — is // ClassifyModuleResponse's call, so both fetch paths answer it the @@ -851,18 +839,15 @@ static bool PerformHttpFetchOnceSync(const std::string& url, const std::string& if (what.empty()) { what = nse.GetErrorMessage(); } - RecordLastHttpFetchError("native-script-exception", "tns::NativeScriptException", what); TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=native-script-exception url=%s msg=%s", url.c_str(), what.c_str()); return false; } catch (std::exception& ex) { std::string what = ex.what() ? ex.what() : ""; - RecordLastHttpFetchError("std-exception", "std::exception", what); TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=std-exception url=%s msg=%s", url.c_str(), what.c_str()); return false; } catch (...) { - RecordLastHttpFetchError("unknown-cpp-exception", "", ""); TNS_DEBUG(Esm, "[http-esm][fetch][exception] stage=unknown-cpp-exception url=%s", url.c_str()); return false; @@ -944,36 +929,7 @@ void FetchModuleBodyAsync(const std::string& url, }).detach(); } -static void MaybePumpJSThreadDuringBoot() { - v8::Isolate* isolate = v8::Isolate::TryGetCurrent(); - if (isolate == nullptr) return; - if (!IsBootEvaluationActive()) return; - if (isolate->GetData((uint32_t)Runtime::IsolateData::RUNTIME) == nullptr) return; - - isolate->PerformMicrotaskCheckpoint(); - // See EventLoop::IsInLooperCallback: a nested poll corrupts the outer - // poll's response state. A fetch issued from inside a dispatch skips the - // looper slice; the microtask checkpoints still run. - if (!EventLoop::IsInLooperCallback()) { - ALooper_pollOnce(0, nullptr, nullptr, nullptr); - } - isolate->PerformMicrotaskCheckpoint(); -} - -static std::atomic g_httpFetchYield{&MaybePumpJSThreadDuringBoot}; - -void RegisterHttpFetchYield(void (*callback)()) { - g_httpFetchYield.store(callback, std::memory_order_release); -} - -static inline void InvokeHttpFetchYield() { - auto cb = g_httpFetchYield.load(std::memory_order_acquire); - if (cb != nullptr) cb(); -} - void CleanupHttpLoaderGlobals() { - // The boot-evaluation flag is thread-local and RAII-balanced by - // ModuleInternal::Load, so it needs no reset here. ClearAllCacheBustMarks(); } @@ -1000,182 +956,199 @@ void ConfigureLoaderCallback(const v8::FunctionCallbackInfo& info) { v8::HandleScope scope(isolate); v8::Local ctx = isolate->GetCurrentContext(); - auto throwTypeError = [&](const std::string& message) { - isolate->ThrowException(v8::Exception::TypeError(ToV8String(isolate, message))); - }; + try { + auto throwTypeError = [&](const std::string& message) { + isolate->ThrowException(v8::Exception::TypeError(ToV8String(isolate, message))); + }; - if (info.Length() < 1 || !info[0]->IsObject()) { - throwTypeError("configureLoader expects a config object"); - return; - } + if (info.Length() < 1 || !info[0]->IsObject()) { + throwTypeError("configureLoader expects a config object"); + return; + } - v8::Local config = info[0].As(); + v8::Local config = info[0].As(); - // ── Validation phase ───────────────────────────────────────────────── - // Nothing below mutates the vocabulary. The whole config is checked first - // so a rejected call leaves every section exactly as it was — the - // atomicity the import map alone used to have, now covering the entire - // call. + // ── Validation phase ───────────────────────────────────────────── + // Nothing below mutates the vocabulary. The whole config is checked first + // so a rejected call leaves every section exactly as it was — the + // atomicity the import map alone used to have, now covering the entire + // call. - // Unknown top-level keys. - v8::Local configKeys; - if (!config->GetOwnPropertyNames(ctx, v8::PropertyFilter::ONLY_ENUMERABLE, - v8::KeyConversionMode::kConvertToString) - .ToLocal(&configKeys)) { - return; // pending exception - } - for (uint32_t i = 0; i < configKeys->Length(); i++) { - v8::Local keyVal; - if (!configKeys->Get(ctx, i).ToLocal(&keyVal)) { - return; + // Unknown top-level keys. + v8::Local configKeys; + if (!config->GetOwnPropertyNames(ctx, v8::PropertyFilter::ONLY_ENUMERABLE, + v8::KeyConversionMode::kConvertToString) + .ToLocal(&configKeys)) { + return; // pending exception } - std::string key = ArgConverter::ToString(isolate, keyVal); - bool known = false; - for (const char* candidate : kLoaderConfigKeys) { - if (key == candidate) { - known = true; - break; + for (uint32_t i = 0; i < configKeys->Length(); i++) { + v8::Local keyVal; + if (!configKeys->Get(ctx, i).ToLocal(&keyVal)) { + return; + } + std::string key = ArgConverter::ToString(isolate, keyVal); + bool known = false; + for (const char* candidate : kLoaderConfigKeys) { + if (key == candidate) { + known = true; + break; + } + } + if (!known) { + throwTypeError("configureLoader: unknown option '" + key + "'"); + return; } } - if (!known) { - throwTypeError("configureLoader: unknown option '" + key + "'"); - return; - } - } - // Reads `obj[key]` as an array of strings into `out`. `label` names the - // section in any error. Returns false with an exception pending on a type - // failure; `present` distinguishes "absent" from "present and valid". - auto readStringArray = [&](v8::Local obj, const char* key, - const std::string& label, std::vector& out, - bool* present) -> bool { - *present = false; - v8::Local val; - if (!obj->Get(ctx, ToV8String(isolate, key)).ToLocal(&val)) { - return false; - } - if (val->IsUndefined()) { - return true; - } - if (!val->IsArray()) { - throwTypeError("configureLoader: " + label + " must be an array of strings"); - return false; - } - v8::Local arr = val.As(); - for (uint32_t i = 0; i < arr->Length(); i++) { - v8::Local elem; - if (!arr->Get(ctx, i).ToLocal(&elem)) { + // Reads `obj[key]` as an array of strings into `out`. `label` names the + // section in any error. Returns false with an exception pending on a type + // failure; `present` distinguishes "absent" from "present and valid". + auto readStringArray = [&](v8::Local obj, const char* key, + const std::string& label, std::vector& out, + bool* present) -> bool { + *present = false; + v8::Local val; + if (!obj->Get(ctx, ToV8String(isolate, key)).ToLocal(&val)) { return false; } - if (!elem->IsString()) { - throwTypeError("configureLoader: " + label + "[" + std::to_string(i) + - "] must be a string"); + if (val->IsUndefined()) { + return true; + } + if (!val->IsArray()) { + throwTypeError("configureLoader: " + label + " must be an array of strings"); return false; } - out.push_back(ArgConverter::ToString(isolate, elem)); - } - *present = true; - return true; - }; + v8::Local arr = val.As(); + for (uint32_t i = 0; i < arr->Length(); i++) { + v8::Local elem; + if (!arr->Get(ctx, i).ToLocal(&elem)) { + return false; + } + if (!elem->IsString()) { + throwTypeError("configureLoader: " + label + "[" + std::to_string(i) + + "] must be a string"); + return false; + } + out.push_back(ArgConverter::ToString(isolate, elem)); + } + *present = true; + return true; + }; - // importMap: an object or a JSON string. Validated here, installed below. - std::string importMapJson; - bool haveImportMap = false; - v8::Local importMapVal; - if (!config->Get(ctx, ToV8String(isolate, "importMap")).ToLocal(&importMapVal)) { - return; - } - if (!importMapVal->IsUndefined()) { - std::string jsonStr; - if (importMapVal->IsString()) { - jsonStr = ArgConverter::ToString(isolate, importMapVal); - } else if (importMapVal->IsObject()) { - v8::Local stringified; - if (!v8::JSON::Stringify(ctx, importMapVal).ToLocal(&stringified)) { - return; // a throwing toJSON / getter propagates unchanged + // importMap: an object or a JSON string. Validated here, installed below. + std::string importMapJson; + bool haveImportMap = false; + v8::Local importMapVal; + if (!config->Get(ctx, ToV8String(isolate, "importMap")).ToLocal(&importMapVal)) { + return; + } + if (!importMapVal->IsUndefined()) { + std::string jsonStr; + if (importMapVal->IsString()) { + jsonStr = ArgConverter::ToString(isolate, importMapVal); + } else if (importMapVal->IsObject() && !importMapVal->IsFunction()) { + // A function is an object to V8, and JSON::Stringify hands one back + // as the literal text "undefined" rather than failing — so it is + // excluded here and falls through to the TypeError below. + v8::Local stringified; + if (!v8::JSON::Stringify(ctx, importMapVal).ToLocal(&stringified)) { + return; // a throwing toJSON / getter propagates unchanged + } + std::string text = ArgConverter::ToString(isolate, stringified); + // The same "undefined" answer reaches a plain object whose toJSON + // returns undefined; leaving jsonStr empty routes it to the same + // TypeError. + if (text != "undefined") { + jsonStr = std::move(text); + } } - // JSON.stringify answers `undefined` for a function or a - // symbol-valued object, which is not a JSON string. - if (stringified->IsString()) { - jsonStr = ArgConverter::ToString(isolate, stringified); + if (jsonStr.empty()) { + throwTypeError("configureLoader: importMap must be an object or a JSON string"); + return; } + std::string importMapError; + if (!ValidateImportMapJson(jsonStr, &importMapError)) { + // The previous map is still installed: a rejected update changes + // nothing, so a typo cannot empty a live session's vocabulary. + throwTypeError("configureLoader: " + importMapError); + return; + } + importMapJson = std::move(jsonStr); + haveImportMap = true; } - if (jsonStr.empty()) { - throwTypeError("configureLoader: importMap must be an object or a JSON string"); - return; - } - std::string importMapError; - if (!ValidateImportMapJson(jsonStr, &importMapError)) { - // The previous map is still installed: a rejected update changes - // nothing, so a typo cannot empty a live session's vocabulary. - throwTypeError("configureLoader: " + importMapError); + + // volatilePatterns: array of strings. Presence of the array decides, not + // its contents — an empty one is explicit policy meaning "nothing is + // volatile any more", the same rule canonicalization follows, and the only + // reading under which a present section replaces its state wholesale. + std::vector patterns; + bool havePatterns = false; + if (!readStringArray(config, "volatilePatterns", "volatilePatterns", patterns, + &havePatterns)) { return; } - importMapJson = std::move(jsonStr); - haveImportMap = true; - } - // volatilePatterns: array of strings. Presence of the array decides, not - // its contents — an empty one is explicit policy meaning "nothing is - // volatile any more", the same rule canonicalization follows, and the only - // reading under which a present section replaces its state wholesale. - std::vector patterns; - bool havePatterns = false; - if (!readStringArray(config, "volatilePatterns", "volatilePatterns", patterns, &havePatterns)) { - return; - } - - // canonicalization: { stripParams, forPathPrefixes, preserveQueryFor } — - // the URL vocabulary CanonicalizeHttpUrlKey applies (see its doc block). - // Presence of the object marks the vocabulary as configured, replacing the - // built-in fallback entirely (empty arrays are honored as explicit policy). - CanonicalizationConfig canon; - bool haveCanon = false; - v8::Local canonVal; - if (!config->Get(ctx, ToV8String(isolate, "canonicalization")).ToLocal(&canonVal)) { - return; - } - if (!canonVal->IsUndefined()) { - if (!canonVal->IsObject()) { - throwTypeError("configureLoader: canonicalization must be an object"); + // canonicalization: { stripParams, forPathPrefixes, preserveQueryFor } — + // the URL vocabulary CanonicalizeHttpUrlKey applies (see its doc block). + // Presence of the object marks the vocabulary as configured, replacing the + // built-in fallback entirely (empty arrays are honored as explicit policy). + CanonicalizationConfig canon; + bool haveCanon = false; + v8::Local canonVal; + if (!config->Get(ctx, ToV8String(isolate, "canonicalization")).ToLocal(&canonVal)) { return; } - v8::Local canonObj = canonVal.As(); - bool ignored = false; - if (!readStringArray(canonObj, "stripParams", "canonicalization.stripParams", - canon.stripParams, &ignored) || - !readStringArray(canonObj, "forPathPrefixes", "canonicalization.forPathPrefixes", - canon.devPathPrefixes, &ignored) || - !readStringArray(canonObj, "preserveQueryFor", "canonicalization.preserveQueryFor", - canon.preserveQueryPrefixes, &ignored)) { - return; + if (!canonVal->IsUndefined()) { + if (!canonVal->IsObject()) { + throwTypeError("configureLoader: canonicalization must be an object"); + return; + } + v8::Local canonObj = canonVal.As(); + bool ignored = false; + if (!readStringArray(canonObj, "stripParams", "canonicalization.stripParams", + canon.stripParams, &ignored) || + !readStringArray(canonObj, "forPathPrefixes", "canonicalization.forPathPrefixes", + canon.devPathPrefixes, &ignored) || + !readStringArray(canonObj, "preserveQueryFor", "canonicalization.preserveQueryFor", + canon.preserveQueryPrefixes, &ignored)) { + return; + } + haveCanon = true; } - haveCanon = true; - } - // ── Apply phase ────────────────────────────────────────────────────── - // Everything validated; from here nothing can fail on the caller's input. + // ── Apply phase ────────────────────────────────────────────────── + // Everything validated; from here nothing can fail on the caller's input. - if (haveImportMap) { - // The re-parse inside SetImportMap is deterministic and already - // succeeded above, so the only failure left is the isolate shutting - // down. - std::string installError; - if (!SetImportMap(importMapJson, &installError)) { - throwTypeError("configureLoader: " + installError); - return; + if (haveImportMap) { + // The re-parse inside SetImportMap is deterministic and already + // succeeded above, so the only failure left is the isolate shutting + // down. + std::string installError; + if (!SetImportMap(importMapJson, &installError)) { + throwTypeError("configureLoader: " + installError); + return; + } + TNS_DEBUG(Esm, "[ns:module configureLoader] import map set (%zu bytes)", + importMapJson.size()); } - TNS_DEBUG(Esm, "[ns:module configureLoader] import map set (%zu bytes)", - importMapJson.size()); - } - if (havePatterns) { - SetVolatilePatterns(patterns); - TNS_DEBUG(Esm, "[ns:module configureLoader] %zu volatile patterns set", patterns.size()); - } + if (havePatterns) { + SetVolatilePatterns(patterns); + TNS_DEBUG(Esm, "[ns:module configureLoader] %zu volatile patterns set", + patterns.size()); + } - if (haveCanon) { - SetCanonicalizationConfig(std::move(canon)); + if (haveCanon) { + SetCanonicalizationConfig(std::move(canon)); + } + } catch (NativeScriptException& e) { + e.ReThrowToV8(); + } catch (std::exception& e) { + NativeScriptException nsEx(std::string("Error: c++ exception: ") + e.what() + "\n"); + nsEx.ReThrowToV8(); + } catch (...) { + NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToV8(); } } @@ -1184,43 +1157,54 @@ void InvalidateModulesCallback(const v8::FunctionCallbackInfo& info) v8::HandleScope scope(isolate); v8::Local ctx = isolate->GetCurrentContext(); - if (info.Length() < 1 || !info[0]->IsArray()) { - isolate->ThrowException(v8::Exception::TypeError( - ToV8String(isolate, "invalidateModules expects an array of URL strings"))); - return; - } - - v8::Local urlsArray = info[0].As(); - std::vector urls; - urls.reserve(urlsArray->Length()); - for (uint32_t index = 0; index < urlsArray->Length(); index++) { - v8::Local value; - if (!urlsArray->Get(ctx, index).ToLocal(&value)) { - return; - } - if (!value->IsString()) { - isolate->ThrowException(v8::Exception::TypeError(ToV8String( - isolate, - "invalidateModules: urls[" + std::to_string(index) + "] must be a string"))); + try { + if (info.Length() < 1 || !info[0]->IsArray()) { + isolate->ThrowException(v8::Exception::TypeError( + ToV8String(isolate, "invalidateModules expects an array of URL strings"))); return; } - urls.push_back(ArgConverter::ToString(isolate, value)); - } - if (tns::LogCategoryEnabled(tns::LogCategory::Registry)) { - TNS_DEBUG(Registry, "invalidate called urls.count=%zu", urls.size()); - size_t shown = 0; - for (const auto& u : urls) { - if (shown >= 32) break; - TNS_DEBUG(Registry, "invalidate url[%zu]=%s", shown, u.c_str()); - shown++; + v8::Local urlsArray = info[0].As(); + std::vector urls; + urls.reserve(urlsArray->Length()); + for (uint32_t index = 0; index < urlsArray->Length(); index++) { + v8::Local value; + if (!urlsArray->Get(ctx, index).ToLocal(&value)) { + return; + } + if (!value->IsString()) { + isolate->ThrowException(v8::Exception::TypeError(ToV8String( + isolate, + "invalidateModules: urls[" + std::to_string(index) + + "] must be a string"))); + return; + } + urls.push_back(ArgConverter::ToString(isolate, value)); } - if (urls.size() > shown) { - TNS_DEBUG(Registry, "invalidate (hidden %zu more URL(s))", urls.size() - shown); + + if (tns::LogCategoryEnabled(tns::LogCategory::Registry)) { + TNS_DEBUG(Registry, "invalidate called urls.count=%zu", urls.size()); + size_t shown = 0; + for (const auto& u : urls) { + if (shown >= 32) break; + TNS_DEBUG(Registry, "invalidate url[%zu]=%s", shown, u.c_str()); + shown++; + } + if (urls.size() > shown) { + TNS_DEBUG(Registry, "invalidate (hidden %zu more URL(s))", urls.size() - shown); + } } - } - tns::InvalidateModules(isolate, ctx, urls); + tns::InvalidateModules(isolate, ctx, urls); + } catch (NativeScriptException& e) { + e.ReThrowToV8(); + } catch (std::exception& e) { + NativeScriptException nsEx(std::string("Error: c++ exception: ") + e.what() + "\n"); + nsEx.ReThrowToV8(); + } catch (...) { + NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToV8(); + } } void GetLoadedModuleUrlsCallback(const v8::FunctionCallbackInfo& info) { @@ -1228,14 +1212,24 @@ void GetLoadedModuleUrlsCallback(const v8::FunctionCallbackInfo& info v8::HandleScope scope(isolate); v8::Local ctx = isolate->GetCurrentContext(); - std::vector urls = tns::GetLoadedModuleUrls(); - v8::Local result = v8::Array::New(isolate, static_cast(urls.size())); + try { + std::vector urls = tns::GetLoadedModuleUrls(); + v8::Local result = v8::Array::New(isolate, static_cast(urls.size())); - for (uint32_t index = 0; index < urls.size(); index++) { - result->Set(ctx, index, ToV8String(isolate, urls[index])).FromMaybe(false); - } + for (uint32_t index = 0; index < urls.size(); index++) { + result->Set(ctx, index, ToV8String(isolate, urls[index])).FromMaybe(false); + } - info.GetReturnValue().Set(result); + info.GetReturnValue().Set(result); + } catch (NativeScriptException& e) { + e.ReThrowToV8(); + } catch (std::exception& e) { + NativeScriptException nsEx(std::string("Error: c++ exception: ") + e.what() + "\n"); + nsEx.ReThrowToV8(); + } catch (...) { + NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToV8(); + } } } // namespace @@ -1255,13 +1249,24 @@ bool BuildNsModuleBinding(v8::Local context, v8::Local if (IsDebuggable()) { auto canonicalizeCb = [](const v8::FunctionCallbackInfo& info) { v8::Isolate* iso = info.GetIsolate(); - if (info.Length() < 1 || !info[0]->IsString()) { - iso->ThrowException(v8::Exception::TypeError( - ToV8String(iso, "canonicalizeHttpUrlKey expects a URL string"))); - return; + try { + if (info.Length() < 1 || !info[0]->IsString()) { + iso->ThrowException(v8::Exception::TypeError( + ToV8String(iso, "canonicalizeHttpUrlKey expects a URL string"))); + return; + } + std::string key = CanonicalizeHttpUrlKey(ArgConverter::ToString(iso, info[0])); + info.GetReturnValue().Set(ToV8String(iso, key)); + } catch (NativeScriptException& e) { + e.ReThrowToV8(); + } catch (std::exception& e) { + NativeScriptException nsEx(std::string("Error: c++ exception: ") + e.what() + + "\n"); + nsEx.ReThrowToV8(); + } catch (...) { + NativeScriptException nsEx(std::string("Error: c++ exception!")); + nsEx.ReThrowToV8(); } - std::string key = CanonicalizeHttpUrlKey(ArgConverter::ToString(iso, info[0])); - info.GetReturnValue().Set(ToV8String(iso, key)); }; v8::Local fn; if (v8::Function::New(context, canonicalizeCb).ToLocal(&fn)) { diff --git a/test-app/runtime/src/main/cpp/HttpLoader.h b/test-app/runtime/src/main/cpp/HttpLoader.h index 939e6dcc1..e3cffe336 100644 --- a/test-app/runtime/src/main/cpp/HttpLoader.h +++ b/test-app/runtime/src/main/cpp/HttpLoader.h @@ -30,8 +30,6 @@ namespace tns { // normally arrive, // - eviction plumbing (an eviction-driven fetch nonce that defeats // any HTTP cache layer between the runtime and the origin), -// - the boot-evaluation flag that arms the cold-boot looper pump only -// while an entry module is evaluating (derived by the runtime itself), // - the remote-module security gate, seeded once from nativescript.config // at boot and never exposed on ns:runtime getConfig/setConfig. @@ -48,9 +46,8 @@ namespace tns { // per-isolate loader vocabulary — installed through SetCanonicalizationConfig // in ModuleInternalCallbacks.h — so CanonicalizeHttpUrlKey runs on the // isolate's own thread only. The transport canonicalizes at its JS-thread -// entry points (HttpFetchModule, FetchModuleBodyAsync, MarkUrlsForCacheBust) -// and nowhere else; background fetch threads only ever carry keys computed -// for them. +// entry points (HttpFetchModule, FetchModuleBodyAsync) and nowhere else; +// background fetch threads only ever carry keys computed for them. // // When unconfigured, canonicalization is purely mechanical (fragment strip). struct CanonicalizationConfig { @@ -112,40 +109,16 @@ void FetchModuleBodyAsync( const std::string& url, std::function completion); -// Return the most recent low-level fetch error reason for the calling -// thread, or an empty string if the last fetch succeeded (or no fetch -// has run on this thread yet). Take semantics — the slot is cleared on -// read. Android-only diagnostic for splicing JNI exceptions into JS -// errors when the transport never reached an HTTP status. -std::string TakeLastHttpFetchErrorReason(); - -// Register a "yield" callback that `HttpFetchModule` invokes once, after a -// successful fetch, so the caller can pump its own runloop (e.g. the JS-thread -// looper so a placeholder UI can repaint during cold-boot). -// -// Default: a built-in pump that no-ops unless the calling thread has an -// isolate and is inside an entry-module evaluation window opened by -// SetBootEvaluationActive (see `MaybePumpJSThreadDuringBoot` in -// HttpLoader.cpp). -// -// Pass `nullptr` to disable any yielding (used by hosts that drive their own -// run loop or by tests that want bit-for-bit deterministic fetch timing). -// Safe to call from any thread; reads use acquire/release ordering. -void RegisterHttpFetchYield(void (*callback)()); - -// Mark a URL set (canonicalized internally) so that the NEXT network -// fetch of each URL carries a unique `__ns_dev_nonce` query parameter, -// guaranteeing no HTTP cache layer between the runtime and the origin -// can satisfy the request. Called by `InvalidateModules` for the -// eviction set; marks are consumed when a fresh body arrives. +// Mark a set of canonical registry keys so that the NEXT network fetch of +// each carries a unique `__ns_dev_nonce` query parameter, guaranteeing no +// HTTP cache layer between the runtime and the origin can satisfy the +// request. Called by `InvalidateModules` for the eviction set; marks are +// consumed when a fresh body arrives. +// The keys are inserted verbatim: canonicalization belongs to the caller's +// isolate thread (see CanonicalizeHttpUrlKey), and the transport's own +// background threads have no isolate to read the vocabulary from. // The nonce is transport-only and never affects module identity. -void MarkUrlsForCacheBust(const std::vector& urls); - -// Arm/disarm this thread's boot-evaluation window: while nonzero, the yield -// inside synchronous HTTP fetches may pump the JS thread's looper (safe only -// while the entry module is evaluating — nothing else owns the looper yet). -// Balanced RAII-style by ModuleInternal::Load. -void SetBootEvaluationActive(bool active); +void MarkKeysForCacheBust(const std::vector& canonicalKeys); // Clear the transport's process-wide state (cache-bust marks). MUST be // called inside Runtime::DestroyRuntime() before isolate diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.cpp b/test-app/runtime/src/main/cpp/ModuleInternal.cpp index c2b589670..5af726851 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternal.cpp @@ -40,11 +40,6 @@ using namespace v8; using namespace std; using namespace tns; -static bool IsHttpModulePath(const std::string& path) { - return path.rfind("http://", 0) == 0 || path.rfind("https://", 0) == 0 || - path.rfind("file://http://", 0) == 0 || path.rfind("file://https://", 0) == 0; -} - static std::string NormalizeHttpModuleUrl(const std::string& path) { if (path.empty()) { return path; @@ -66,6 +61,15 @@ static std::string NormalizeHttpModuleUrl(const std::string& path) { return normalized; } +// Classifies the NORMALIZED form: a scheme separator collapsed by a path +// normalizer (`http:/host/...`) must still route to the HTTP loader, or the +// same string classifies as a filesystem path and repairs itself only after +// taking the wrong branch. +static bool IsHttpModulePath(const std::string& path) { + const std::string normalized = NormalizeHttpModuleUrl(path); + return normalized.rfind("http://", 0) == 0 || normalized.rfind("https://", 0) == 0; +} + // What a rejected evaluation promise says about itself. struct RejectionDetail { // The reason's own text: an Error's `message`, or the reason stringified. @@ -239,12 +243,12 @@ void ModuleInternal::Init(Isolate* isolate, const string& baseDir) { } // How an entry module's graph settles. For local modules the bound is a yield, -// not a timeout: only nestable V8 tasks can run while these JS frames are on -// the stack, so a TLA parked on a non-nestable foreground task can never settle -// in-pump — give it one short window, then return and let the real event loop -// finish it after the turn. HTTP entries must settle in-pump — the dev client -// needs the rejection reason synchronously — so they get the full deadline and -// the looper slices their transport needs. +// not a timeout: only nestable V8 tasks and due ordered-lane work can run +// while these JS frames are on the stack, so a TLA parked on a non-nestable +// foreground task can never settle in-pump — give it one short window, then +// return and let the real event loop finish it after the turn. HTTP entries +// must settle in-pump — the dev client needs the rejection reason +// synchronously — so they get the full deadline. static ModuleEvaluationOptions BootEntryEvaluationOptions(bool isHttpModule) { ModuleEvaluationOptions options; options.policy = ModuleEvaluationPolicy::kSyncPumping; @@ -258,10 +262,10 @@ static ModuleEvaluationOptions BootEntryEvaluationOptions(bool isHttpModule) { // How a graph reached through require() settles. A pumping require must settle // or throw — handing back a half-initialized namespace is what the strict -// policy exists to prevent — so it gets the full deadline. It never slices the -// looper by default: outside boot the loop belongs to the app, and re-entering -// arbitrary looper sources from the middle of a require would run UI callbacks -// underneath JS frames. +// policy exists to prevent — so it gets the full deadline. The pump drains +// this loop's own lanes only (nestable v8 tasks, due JS timers), never the +// platform looper: re-entering arbitrary looper sources from the middle of a +// require would run UI callbacks underneath JS frames. static ModuleEvaluationOptions RequireEvaluationOptions(ModuleEvaluationPolicy policy) { ModuleEvaluationOptions options; options.policy = policy; @@ -339,7 +343,7 @@ void ModuleInternal::CreateRequireCallback(const v8::FunctionCallbackInfoGetModuleInternal() : nullptr; if (moduleInternal == nullptr) { isolate->ThrowException(Exception::Error(ArgConverter::ConvertToV8String( @@ -596,14 +600,6 @@ void ModuleInternal::RequireNativeCallback(const v8::FunctionCallbackInfo context, const string& path) { TNSPERF(); auto isolate = m_isolate; - // Entry evaluation is this thread's boot window: while it is active, the - // yield inside synchronous HTTP fetches may pump the looper (nothing else - // owns it yet). Balanced on every exit path, throws included. - struct BootEvalScope { - BootEvalScope() { SetBootEvaluationActive(true); } - ~BootEvalScope() { SetBootEvaluationActive(false); } - } bootEvalScope; - // The ES module branch compiles and links against // isolate->GetCurrentContext(); a caller that enters the isolate through a // fresh Isolate::Scope has no current context, and CompileModule would @@ -626,7 +622,15 @@ void ModuleInternal::Load(Local context, const string& path) { auto globalObject = context->Global(); auto require = globalObject->Get(context, ArgConverter::ConvertToV8String(isolate, "require")).ToLocalChecked().As(); Local args[] = { ArgConverter::ConvertToV8String(isolate, path) }; - require->Call(context, globalObject, 1, args); + // A failed entry must throw through this boundary in every build — the + // caller (boot, or a worker's onerror routing) owns the report, and the + // boot backstop must never pump with an exception pending on the isolate. + TryCatch tc(isolate); + Local result; + const bool ok = require->Call(context, globalObject, 1, args).ToLocal(&result); + if (!ok || tc.HasCaught()) { + throw NativeScriptException(tc, "require() failed for module " + path); + } } void ModuleInternal::LoadWorker(Local context, const string& path) { @@ -942,9 +946,12 @@ Local ModuleInternal::LoadModule(Isolate* isolate, const string& moduleP SET_PROFILER_FRAME(); auto fileName = ArgConverter::ConvertToV8String(isolate, modulePath); - char pathcopy[1024]; - strcpy(pathcopy, modulePath.c_str()); - string strDirName(dirname(pathcopy)); + // dirname() semantics without its fixed-size copy: module paths can + // exceed any stack buffer (PATH_MAX is 4096 and node_modules nests). + const size_t lastSlash = modulePath.find_last_of('/'); + string strDirName = lastSlash == string::npos ? "." + : lastSlash == 0 ? "/" + : modulePath.substr(0, lastSlash); auto dirName = ArgConverter::ConvertToV8String(isolate, strDirName); // A module's own require inherits the options it was loaded under, so a // pumping require's whole dependency tree keeps pumping. @@ -1027,7 +1034,12 @@ Local ModuleInternal::LoadData(Isolate* isolate, const string& path) { tns::instrumentation::Frame frame(frameName); Local json; - auto jsonData = Runtime::GetRuntime(m_isolate)->ReadFileText(path); + Runtime* runtime = Runtime::TryGetRuntime(m_isolate); + if (runtime == nullptr) { + throw NativeScriptException("Cannot read JSON module " + path + + ": the isolate has no runtime"); + } + auto jsonData = runtime->ReadFileText(path); TryCatch tc(isolate); @@ -1072,7 +1084,12 @@ MaybeLocal ModuleInternal::CompileFileEsModule(Isolate* isolate, const s // the open, reads as "" — which compiles into a perfectly valid empty // module unless the failure is told apart from an empty file here. bool readOk = false; - string content = Runtime::GetRuntime(isolate)->ReadFileText(path, readOk); + Runtime* runtime = Runtime::TryGetRuntime(isolate); + if (runtime == nullptr) { + throw NativeScriptException("Cannot read module " + path + + ": the isolate has no runtime"); + } + string content = runtime->ReadFileText(path, readOk); if (!readOk) { throw NativeScriptException("Cannot read module " + path); } @@ -1288,68 +1305,41 @@ MaybeLocal tns::EvaluateModuleGraph(Isolate* isolate, Local co return MaybeLocal(); } - // Top-level await can depend on native async work such as fetch(), which - // needs both V8 microtasks and the looper to advance. An await whose - // resolution arrives as a v8 foreground task never settles from checkpoints - // alone; JS frames are on the stack, so like the inspector pause loops only - // nestable tasks may run here. - Runtime* runtime = Runtime::GetRuntime(isolate); + // Top-level await can depend on native async work (fetch completions and + // TLA continuations arrive as nestable v8 tasks) and on JS timers, which + // live in the ordered lane — Java Handler messages cannot dispatch while + // these JS frames hold the thread, so the pump drains due ordered work + // directly. Like the inspector pause loops, non-nestable v8 tasks stay + // queued. + Runtime* runtime = Runtime::TryGetRuntime(isolate); std::shared_ptr eventLoop = runtime != nullptr ? runtime->GetEventLoop() : nullptr; - auto pumpAsyncProgress = [&]() { - if (eventLoop != nullptr) { - eventLoop->RunNestableV8Tasks(); - } - isolate->PerformMicrotaskCheckpoint(); - if (options.pumpRunLoop) { - // Nested ALooper_pollOnce inside an fd callback dangles the outer - // poll's Response& (see EventLoop::IsInLooperCallback); wait on - // the loop's own fds instead - same wakeups, no looper re-entry. - if (EventLoop::IsInLooperCallback()) { - if (eventLoop != nullptr) { - eventLoop->WaitForInternalWork(10); - } else { - usleep(1000); - } - } else { - ALooper_pollOnce(10 /* ms */, nullptr, nullptr, nullptr); - } - isolate->PerformMicrotaskCheckpoint(); - } - }; - - const auto deadline = - std::chrono::steady_clock::now() + - std::chrono::milliseconds(static_cast(options.deadlineSeconds * 1000.0)); bool settled = false; - - // State is checked before the first pump: a synchronous graph's evaluation - // promise is already settled when Evaluate() returns, so it exits here - // without paying for a looper slice. - while (!promiseTc.HasCaught()) { + // Probed before the first pump iteration: a synchronous graph's evaluation + // promise is already settled when Evaluate() returns, so it never pays for + // a pump slice. + const auto probe = [&]() { + if (promiseTc.HasCaught()) { + return true; + } Promise::PromiseState state = promise->State(); - if (state != Promise::kPending) { - settled = true; - if (state == Promise::kRejected) { - ThrowModuleEvaluationRejection(isolate, promise, promiseTc, canonicalPath); - } - LogEsmPhase(canonicalPath, "evaluate", "promise-resolved"); - break; + if (state == Promise::kPending) { + return false; } - - if (std::chrono::steady_clock::now() >= deadline) { - break; + settled = true; + if (state == Promise::kRejected) { + ThrowModuleEvaluationRejection(isolate, promise, promiseTc, canonicalPath); } + LogEsmPhase(canonicalPath, "evaluate", "promise-resolved"); + return true; + }; - pumpAsyncProgress(); - if (!options.pumpRunLoop) { - // Wakes on the next internal-lane task (fetch completion, TLA - // continuation) instead of a fixed spin interval. - if (eventLoop != nullptr) { - eventLoop->WaitForInternalWork(10); - } else { - usleep(1000); - } + if (!probe() && eventLoop != nullptr) { + if (eventLoop->PumpUntil(options.deadlineSeconds, probe) == + EventLoop::PumpResult::kTerminated) { + // terminating isolate (worker.terminate) or a stopped loop: no + // outcome to report, and no timeout to mislabel it with + return MaybeLocal(); } } @@ -1475,11 +1465,6 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p if (tcLoad.HasCaught()) { throw NativeScriptException(tcLoad, message); } - std::string reason = TakeLastHttpFetchErrorReason(); - if (!reason.empty()) { - message.append(" — "); - message.append(reason); - } throw NativeScriptException(message); } logPhase("compile", "ok", "http-loader"); @@ -1615,7 +1600,12 @@ Local ModuleInternal::LoadESModule(Isolate* isolate, const std::string& p Local ModuleInternal::WrapModuleContent(const string& path) { TNSPERF(); - string content = Runtime::GetRuntime(m_isolate)->ReadFileText(path); + Runtime* runtime = Runtime::TryGetRuntime(m_isolate); + if (runtime == nullptr) { + throw NativeScriptException("Cannot read module " + path + + ": the isolate has no runtime"); + } + string content = runtime->ReadFileText(path); // TODO: Use statically allocated buffer for better performance string result(MODULE_PROLOGUE); diff --git a/test-app/runtime/src/main/cpp/ModuleInternal.h b/test-app/runtime/src/main/cpp/ModuleInternal.h index 9f96d9a4c..5916d76b7 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternal.h +++ b/test-app/runtime/src/main/cpp/ModuleInternal.h @@ -28,8 +28,9 @@ inline constexpr double kModuleEvaluateDeadlineSeconds = 60.0; // ever evaluates, and the capability promise must already be // settled when Evaluate() returns. // kSyncPumping - drive this thread in place until the promise settles or the -// window closes. Only legal while nothing else owns the loop -// (entry evaluation), and only nestable V8 tasks can run. +// window closes: nestable V8 tasks, microtask checkpoints, +// and due ordered-lane work (JS timers). Non-nestable tasks +// stay queued, as in the inspector pause loops. // kAsync - evaluate and hand the caller the capability promise. enum class ModuleEvaluationPolicy { kSyncStrict, kSyncPumping, kAsync }; @@ -48,8 +49,10 @@ struct ModuleEvaluationOptions { double deadlineSeconds = 0.0; // kSyncPumping only: what an expired window means. TimeoutBehavior timeoutBehavior = TimeoutBehavior::kReturnPending; - // kSyncPumping only: also give the Android looper a slice per iteration, for - // graphs whose progress depends on native transports rather than V8 tasks. + // kSyncPumping only. Contract surface (createPumpingRequire validates and + // carries it); on Android the pump always drains this loop's own lanes — + // internal v8 tasks and due JS timers — and never re-enters the platform + // looper, so the option adds nothing here. bool pumpRunLoop = false; }; diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp index 71c56c110..d80dbbe0b 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp @@ -195,7 +195,6 @@ static std::string ResolveHttpRelative(const std::string& referrerUrl, // Forward declarations for helpers referenced before their definitions. static const char* ModuleStatusToString(v8::Module::Status status); static void KillAsyncGraphLoadsForIsolate(v8::Isolate* isolate); -static bool IsCurrentIsolateWorker(v8::Isolate* isolate); static v8::MaybeLocal CompileJsonTextAsEsModule( v8::Isolate* isolate, v8::Local context, const std::string& jsonText, const std::string& registryAbsPath, @@ -1120,6 +1119,13 @@ static ModuleResolution ResolveSpecifierToPath(const std::string& rawSpec, return result; } + // blob: names a registry key, never a filesystem path — the callers' blob + // branches own it, so it must not burn stat() probes under the app root. + if (StartsWith(rawSpec, "blob:")) { + result.specifier = rawSpec; + return result; + } + std::string spec = rawSpec; // Repair 'http:/host' (single slash) left by upstream path joins, so the URL // takes the HTTP path instead of becoming '/app/http:/host'. @@ -1319,16 +1325,6 @@ static ModuleResolution ResolveSpecifierToPath(const std::string& rawSpec, return result; } -// ───────────────────────────────────────────────────────────── -// Worker isolate detection: iOS keys off Caches::Get(isolate)->isWorker. -// Android encodes the same signal by installing a WORKER_WRAPPER pointer in -// the isolate's data slot on worker isolates only (see Runtime.h). -static bool IsCurrentIsolateWorker(v8::Isolate* isolate) { - if (isolate == nullptr) return false; - return isolate->GetData((uint32_t)Runtime::IsolateData::WORKER_WRAPPER) != - nullptr; -} - // Monotonic microseconds since some fixed epoch — matches iOS's // CFAbsoluteTimeGetCurrent() semantic (used for internal timing only, never // exposed to JS). @@ -1506,7 +1502,7 @@ static void AsyncGraphOnFetchCompleted( const std::shared_ptr& fetched) { if (load->dead.load(std::memory_order_acquire)) return; v8::Isolate* isolate = load->isolate; - if (Runtime::GetRuntime(isolate) == nullptr) return; + if (Runtime::TryGetRuntime(isolate) == nullptr) return; v8::Locker locker(isolate); v8::Isolate::Scope isolate_scope(isolate); @@ -1752,34 +1748,14 @@ bool RunModuleGraphLoadPumped(v8::Isolate* isolate, [done](bool /*ok*/, const std::string& /*errorMessage*/, v8::Local) { *done = true; }); - // Manual pump ("until either all is settled or the app takes over"). Fetch - // completions are nestable v8 foreground tasks on the isolate's event loop, - // drained directly; the short ALooper slice stays as the idle-wait and still - // services the other looper-delivered work the walk indirectly depends on. A - // graph with no HTTP edges is already done here, so the loop body never runs. - Runtime* runtime = Runtime::GetRuntime(isolate); + // Fetch completions are nestable v8 foreground tasks on the isolate's event + // loop, which the pump drains directly. A graph with no HTTP edges is + // already done here, so the pump never runs. + Runtime* runtime = Runtime::TryGetRuntime(isolate); std::shared_ptr eventLoop = runtime != nullptr ? runtime->GetEventLoop() : nullptr; - const auto deadline = - std::chrono::steady_clock::now() + - std::chrono::milliseconds(static_cast(timeoutSeconds * 1000.0)); - while (!*done && std::chrono::steady_clock::now() < deadline) { - if (eventLoop != nullptr) { - eventLoop->RunNestableV8Tasks(); - } - if (*done) break; - // Polling the looper from inside one of its fd callbacks dangles the - // outer poll's Response& (see EventLoop::IsInLooperCallback); wait on the - // loop's own fds instead - same wakeups, no looper re-entry. - if (EventLoop::IsInLooperCallback()) { - if (eventLoop != nullptr) { - eventLoop->WaitForInternalWork(10); - } else { - usleep(1000); - } - } else { - ALooper_pollOnce(10 /* ms */, nullptr, nullptr, nullptr); - } + if (!*done && eventLoop != nullptr) { + eventLoop->PumpUntil(timeoutSeconds, [&]() { return *done; }); } if (!*done) { TNS_DEBUG( @@ -1918,7 +1894,7 @@ void InvalidateModules(v8::Isolate* isolate, v8::Local context, // `__ns_dev_nonce` query param — the network sees a URL it has never // cached and must go to origin. The nonce is transport-only; module // identity stays the canonical URL. - MarkUrlsForCacheBust(uniqueUrls); + MarkKeysForCacheBust(uniqueUrls); TNS_DEBUG(Registry, "invalidate summary unique=%lu hits=%lu misses=%lu " "(registry now=%lu)", @@ -2192,7 +2168,15 @@ static v8::MaybeLocal CompileJsonTextAsEsModule( static v8::MaybeLocal CompileJsonAsEsModule( v8::Isolate* isolate, v8::Local context, const std::string& absPath, const std::string& registryAbsPath) { - const std::string jsonText = Runtime::GetRuntime(isolate)->ReadFileText(absPath); + Runtime* runtime = Runtime::TryGetRuntime(isolate); + if (runtime == nullptr) { + // Resolve-callback contract: an empty return needs an exception scheduled, + // and a C++ throw here would unwind through InstantiateModule. + isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, "Cannot read JSON module " + absPath + ": the isolate has no runtime"))); + return v8::MaybeLocal(); + } + const std::string jsonText = runtime->ReadFileText(absPath); return CompileJsonTextAsEsModule(isolate, context, jsonText, registryAbsPath, "file://" + absPath); } @@ -2241,7 +2225,6 @@ static v8::MaybeLocal LoadResolvedModule( return v8::MaybeLocal(); } auto& registry = moduleState->registry; - const bool isWorker = IsCurrentIsolateWorker(isolate); switch (resolution.kind) { case ModuleResolution::Kind::kBuiltin: { @@ -2316,10 +2299,8 @@ static v8::MaybeLocal LoadResolvedModule( IndexRegisteredModule(*moduleState, registryAbsPath, mod); return v8::MaybeLocal(mod); } catch (NativeScriptException& ex) { - if (isWorker) { - DEBUG_WRITE("[resolver] Worker failed to compile '%s' -> '%s'", - resolution.specifier.c_str(), absPath.c_str()); - } + TNS_DEBUG(Esm, "[resolver] failed to compile '%s' -> '%s'", + resolution.specifier.c_str(), absPath.c_str()); ex.ReThrowToV8(); return v8::MaybeLocal(); } @@ -3200,7 +3181,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( resolver ->Reject(context, v8::Exception::Error( ArgConverter::ConvertToV8String(isolate, msg))) - .Check(); + .FromMaybe(false); return scope.Escape(resolver->GetPromise()); } } @@ -3217,7 +3198,7 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( v8::Local ex = BuildModuleFailureReason( isolate, resolveTc, "Evaluation failed for module", normalizedSpec); resolveTc.Reset(); - resolver->Reject(context, ex).Check(); + resolver->Reject(context, ex).FromMaybe(false); return scope.Escape(resolver->GetPromise()); } if (!evalResult.IsEmpty() && evalResult->IsPromise()) { @@ -3301,11 +3282,27 @@ v8::MaybeLocal ImportModuleDynamicallyCallback( : v8::Exception::Error(ArgConverter::ConvertToV8String( isolate, "TDZ on default after eval (generic)")); tc3.Reset(); - resolver->Reject(context, tdzError).Check(); + resolver->Reject(context, tdzError).FromMaybe(false); + return scope.Escape(resolver->GetPromise()); + } + } + { + // Resolving reads `then` off the namespace; a module exporting `then` + // can make that read throw (TDZ in a cycle) — that is the importer's + // rejection, never a CHECK. + v8::TryCatch tcResolve(isolate); + if (resolver->Resolve(context, module->GetModuleNamespace()).IsNothing()) { + v8::Local reason = + tcResolve.HasCaught() + ? tcResolve.Exception() + : v8::Exception::Error(ArgConverter::ConvertToV8String( + isolate, + "Cannot resolve the namespace of " + normalizedSpec)); + tcResolve.Reset(); + resolver->Reject(context, reason).FromMaybe(false); return scope.Escape(resolver->GetPromise()); } } - resolver->Resolve(context, module->GetModuleNamespace()).Check(); TNS_DEBUG(Esm, "[dyn-import] resolved %s", normalizedSpec.c_str()); } catch (NativeScriptException& ex) { TNS_DEBUG(Esm, "[dyn-import] native failed %s", normalizedSpec.c_str()); diff --git a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h index 2d17ed516..0e06eff37 100644 --- a/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h +++ b/test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h @@ -177,10 +177,11 @@ void StartModuleGraphLoad( onComplete); // Synchronous wrapper for callers that need the graph ready before -// continuing: starts the walk, then pumps the current thread's Android Looper -// until it settles or `timeoutSeconds` elapses. A graph with no http(s) edges -// completes entirely inside StartModuleGraphLoad, so this returns without -// entering the wait loop at all — a disk-only load pays no looper slice. +// continuing: starts the walk, then pumps the isolate's event loop in place +// (EventLoop::PumpUntil) until it settles or `timeoutSeconds` elapses. A +// graph with no http(s) edges completes entirely inside StartModuleGraphLoad, +// so this returns without entering the pump at all — a disk-only load pays +// no pump slice. // Returns true when the walk completed (regardless of root success — the // caller's own load path reports root failures). bool RunModuleGraphLoadPumped(v8::Isolate* isolate, diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index b60a8d016..72da703ee 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -387,42 +387,24 @@ static void HoldBootBackstop(v8::Isolate* isolate, const std::string& entryPath) } const double deadlineSeconds = 2 * kModuleEvaluateDeadlineSeconds; - const auto start = std::chrono::steady_clock::now(); - std::shared_ptr eventLoop = Runtime::GetRuntime(isolate) != nullptr - ? Runtime::GetRuntime(isolate)->GetEventLoop() - : nullptr; - - while (!entryRejected && (entryPending || tns::HasPendingAsyncModuleGraphWork())) { - if (std::chrono::duration(std::chrono::steady_clock::now() - start).count() > - deadlineSeconds) { - break; - } - if (eventLoop != nullptr) { - eventLoop->RunNestableV8Tasks(); - } - isolate->PerformMicrotaskCheckpoint(); - // See EventLoop::IsInLooperCallback: a nested poll corrupts the outer - // poll's response state. Boot normally reaches this outside any dispatch, - // but an HTTP entry re-run from a dev-session task must not poll - it - // waits on the loop's own fds instead: same wakeups, no looper re-entry. - if (EventLoop::IsInLooperCallback()) { - if (eventLoop != nullptr) { - eventLoop->WaitForInternalWork(10); - } else { - usleep(1000); + Runtime* runtime = Runtime::TryGetRuntime(isolate); + std::shared_ptr eventLoop = + runtime != nullptr ? runtime->GetEventLoop() : nullptr; + + if (!entryRejected && eventLoop != nullptr) { + // The pump drains due ordered-lane work too: an entry parked on a JS + // timer settles here — Java Handler messages cannot dispatch while this + // frame holds the launching thread. + eventLoop->PumpUntil(deadlineSeconds, [&]() { + if (entryPending) { + EntryEvaluationState state = + ModuleInternal::PollEntryEvaluation(isolate, entryPath, &entryRejectionReason); + // Once it settles, stop probing for good. + entryPending = state == EntryEvaluationState::kPending; + entryRejected = state == EntryEvaluationState::kRejected; } - } else { - ALooper_pollOnce(10, nullptr, nullptr, nullptr); - } - isolate->PerformMicrotaskCheckpoint(); - - if (entryPending) { - EntryEvaluationState state = - ModuleInternal::PollEntryEvaluation(isolate, entryPath, &entryRejectionReason); - // Once it settles, stop probing for good. - entryPending = state == EntryEvaluationState::kPending; - entryRejected = state == EntryEvaluationState::kRejected; - } + return entryRejected || (!entryPending && !tns::HasPendingAsyncModuleGraphWork()); + }); } // Evict before throwing: the entry would otherwise stay registered at diff --git a/test-app/runtime/src/main/cpp/js/primordials.js b/test-app/runtime/src/main/cpp/js/primordials.js index 912d00200..559c2e37c 100644 --- a/test-app/runtime/src/main/cpp/js/primordials.js +++ b/test-app/runtime/src/main/cpp/js/primordials.js @@ -11,7 +11,6 @@ const FunctionPrototypeCall = Function.prototype.call; const FunctionPrototypeBind = Function.prototype.bind; -const FunctionPrototypeApply = Function.prototype.apply; // bind() with `this` pinned to call(): uncurryThis(fn) === fn.call.bind(fn), // but without reading `fn.call`. @@ -25,7 +24,6 @@ const intrinsics = { Error, Map, Number, - Proxy, Set, String, TypeError, @@ -66,7 +64,6 @@ const intrinsics = { DatePrototypeGetTime: uncurryThis(Date.prototype.getTime), DatePrototypeToISOString: uncurryThis(Date.prototype.toISOString), DatePrototypeToJSON: uncurryThis(Date.prototype.toJSON), - FunctionPrototypeApply: uncurryThis(FunctionPrototypeApply), FunctionPrototypeCall: uncurryThis(FunctionPrototypeCall), FunctionPrototypeToString: uncurryThis(Function.prototype.toString), MapPrototypeDelete: uncurryThis(Map.prototype.delete), @@ -75,8 +72,6 @@ const intrinsics = { MapPrototypeSet: uncurryThis(Map.prototype.set), ObjectPrototypePropertyIsEnumerable: uncurryThis(Object.prototype.propertyIsEnumerable), ObjectPrototypeToString: uncurryThis(Object.prototype.toString), - PromisePrototypeCatch: uncurryThis(Promise.prototype.catch), - PromisePrototypeThen: uncurryThis(Promise.prototype.then), RegExpPrototypeTest: uncurryThis(RegExp.prototype.test), RegExpPrototypeToString: uncurryThis(RegExp.prototype.toString), SetPrototypeAdd: uncurryThis(Set.prototype.add),