feat: ESM resolver hardening, http loader, dev-mode globals#1965
feat: ESM resolver hardening, http loader, dev-mode globals#1965NathanWalker wants to merge 5 commits into
Conversation
📝 WalkthroughWalkthroughThis PR adds a native ChangesDev-loader, ESM resolver, and runtime dex fallback
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant JS as __NS_DEV__ client
participant HMRSupport
participant DevFlags
participant JavaHttp as HttpURLConnection
JS->>HMRSupport: configureRuntime / kickstartPrefetch
HMRSupport->>DevFlags: IsRemoteUrlAllowed(url)
DevFlags-->>HMRSupport: allowed or denied
HMRSupport->>JavaHttp: fetch URL
JavaHttp-->>HMRSupport: response or diagnostic
HMRSupport-->>JS: cached body or error
sequenceDiagram
participant ClassResolver
participant DexFactory
participant ClassLoader
ClassResolver->>ClassResolver: retrieveClass fails
ClassResolver->>DexFactory: resolveClass in debuggable mode
DexFactory->>ClassLoader: load normalized proxy name
ClassLoader-->>DexFactory: class or failure
DexFactory-->>ClassResolver: resolved class or null
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
fb386eb to
708fecd
Compare
08e7b8e to
696d4fc
Compare
There was a problem hiding this comment.
Actionable comments posted: 11
🧹 Nitpick comments (1)
test-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjs (1)
20-21: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueLoose OR assertion weakens the test.
expect(p === "/foo/bar.txt" || p === "foo/bar.txt").toBe(true)accepts two different behaviors, which means a regression that flips the leading-slash handling would go undetected either way. If the exact expected value on Android is known, pin it directly instead of accepting both.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjs` around lines 20 - 21, The assertion in testNodeBuiltinsAndOptionalModules.mjs is too permissive because it accepts both leading-slash and no-leading-slash results from mod.fileURLToPath. Update the test around the fileURLToPath check to assert the exact expected Android value directly, using the same mod.fileURLToPath symbol and expect call, so the test fails if the path handling changes unexpectedly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@README.md`:
- Around line 80-92: The runtime cache path description is incomplete: the dex
filename pattern in the README should match DexFactory.getDexFile. Update the
documentation around ClassResolver and DexFactory to state that the generated
dex is written with the thumb suffix (class name plus dex thumb) rather than
just <name>.dex, so the troubleshooting guidance reflects the actual on-disk
path.
In `@test-app/runtime/src/main/cpp/CallbackHandlers.cpp`:
- Around line 1348-1361: Wrap CallbackHandlers::TerminateAllWorkersCallback in
the same V8 exception handling pattern used by the neighboring worker callbacks
so any exception from WorkerWrapper::TerminateChildren or child->Terminate() is
converted to NativeScriptException instead of escaping across V8. Locate the fix
in TerminateAllWorkersCallback and apply the same try/catch boundary and
rethrow/forwarding behavior already used in the adjacent callback handlers that
call into WorkerWrapper.
In `@test-app/runtime/src/main/cpp/HMRSupport.cpp`:
- Around line 1249-1251: configureRuntime() is leaving stale resolver state
behind because SetImportMapEntries() and SetVolatilePatterns() are only called
when the parsed lists are non-empty. Update the logic in configureRuntime() so
an explicit empty import map or volatile pattern list still invokes the
համապատասխան setter and replaces any previous session values. Keep the existing
parsing helpers like ReadImportMapEntries() and ReadVolatilePatterns(), but
remove the empty-check gate before SetImportMapEntries() and
SetVolatilePatterns() so cleared runtime config truly resets resolver state.
- Around line 1044-1063: The detached prefetch worker in
HMRSupport::KickstartHmrPrefetchUrlsSync can still update g_prefetchCache after
the request has timed out or global HMR state has been cleaned up. Add a
cancellation/liveness check tied to the current prefetch context (for example in
the ctxCopy worker path before writing to the cache) so stale workers exit
without mutating shared state. Apply the same guard to the matching detached
fetch path referenced by the related block, and keep the cache write under
g_prefetchMutex only when the context is still valid.
- Around line 664-668: The per-fetch URL entry trace in HMRSupport’s HTTP-ESM
fetch path is still guarded by the script-loading flag instead of the new
httpFetchUrlLog setting. Update the conditional around the DEBUG_WRITE in the
fetch entry flow to use the httpFetchUrlLog-backed check (for example, the
getter or helper associated with httpFetchUrlLog) so enabling that setting alone
turns on the URL trace. Keep the existing fetch entry logging in the same
location, just swap the gate used by the HTTP fetch diagnostics path.
- Around line 580-586: `g_prefetchCache` is using raw URLs instead of the same
canonical identity used by `MarkUrlsForCacheBust()`, so equivalent URLs can miss
cache hits or leave stale prefetched bodies behind. Update the prefetch cache
read/write/eviction paths in `HMRSupport.cpp` to normalize URLs before using
them as keys, and make the affected prewarm and invalidation flows use the same
canonicalized key consistently. Use the existing `MarkUrlsForCacheBust()` logic
as the reference for canonicalization and apply it wherever `g_prefetchCache` is
accessed.
In `@test-app/runtime/src/main/cpp/MetadataNode.cpp`:
- Around line 1798-1816: The module-name normalization in
MetadataNode::GetModulePath should strip any query string or fragment before
checking for .mjs/.js suffixes, since cache-busted URLs can bypass the current
extension trimming. Update the logic around the normalized/fullPathToFile
handling to remove everything after ? or # first, then keep sanitizing all
non-identifier characters (including ?, =, &, #) before the Util::SplitString
step.
In `@test-app/runtime/src/main/cpp/ModuleInternal.cpp`:
- Around line 292-321: The promise-drain logic in ModuleInternal.cpp currently
exits successfully when evalResult remains kPending after the maxAttempts loop.
Update the HTTP module evaluation path in the promise handling block to detect
the still-pending state after the loop and throw a timeout/pending-evaluation
NativeScriptException instead of falling through. Keep the existing
rejected-path behavior intact and make the new error message clearly identify
the module path and that evaluation never completed.
In `@test-app/runtime/src/main/cpp/Runtime.cpp`:
- Around line 101-113: The fatal signal handler in Runtime.cpp currently
heap-allocates via abi::__cxa_demangle and frees the result, which makes the
crash path depend on the allocator. Update the backtrace formatting logic around
the symbol lookup to avoid any allocation in this handler: keep info.dli_sname
unchanged for logging, remove the demangling/freeing work from this path, and
move demangling to an offline or non-signal-handling context if needed. Use the
existing backtrace loop and __android_log_print call site as the place to
preserve safe, allocator-free logging.
In `@test-app/runtime/src/main/cpp/URLImpl.cpp`:
- Around line 55-86: The URL.searchParams getter caches a URLSearchParams
instance, but the SetSearch path does not refresh that cached object when
url.search is reassigned, so it can become stale. Update the URLImpl URL/search
handling so the existing _searchParams object is synchronized with the new
search string in SetSearch instead of replacing or leaving it unchanged, and
keep the URLSearchParams methods on the cached instance consistent with the
updated URL.
In `@test-app/runtime/src/main/cpp/Version.h`:
- Around line 1-2: The checked-in fallback for the runtime commit SHA in
Version.h is still the placeholder string, so startup logs can show a bogus
value. Update the Version.h literal or make test-app/runtime/build.gradle
replace the exact symbol used by NATIVE_SCRIPT_RUNTIME_COMMIT_SHA so packaged
release builds include the real git SHA instead of the fallback.
---
Nitpick comments:
In
`@test-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjs`:
- Around line 20-21: The assertion in testNodeBuiltinsAndOptionalModules.mjs is
too permissive because it accepts both leading-slash and no-leading-slash
results from mod.fileURLToPath. Update the test around the fileURLToPath check
to assert the exact expected Android value directly, using the same
mod.fileURLToPath symbol and expect call, so the test fails if the path handling
changes unexpectedly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: f6782789-325f-4eaa-9610-9964979b18d6
📒 Files selected for processing (26)
README.mdtest-app/app/src/main/assets/app/mainpage.jstest-app/app/src/main/assets/app/tests/esm/meta-no-hot.mjstest-app/app/src/main/assets/app/tests/testHttpCanonicalKey.mjstest-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjstest-app/app/src/main/assets/app/tests/testNsDevBoundary.mjstest-app/runtime/src/main/cpp/CallbackHandlers.cpptest-app/runtime/src/main/cpp/CallbackHandlers.htest-app/runtime/src/main/cpp/DevFlags.cpptest-app/runtime/src/main/cpp/DevFlags.htest-app/runtime/src/main/cpp/HMRSupport.cpptest-app/runtime/src/main/cpp/HMRSupport.htest-app/runtime/src/main/cpp/MetadataNode.cpptest-app/runtime/src/main/cpp/ModuleInternal.cpptest-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpptest-app/runtime/src/main/cpp/ModuleInternalCallbacks.htest-app/runtime/src/main/cpp/Runtime.cpptest-app/runtime/src/main/cpp/URLImpl.cpptest-app/runtime/src/main/cpp/URLImpl.htest-app/runtime/src/main/cpp/Version.htest-app/runtime/src/main/cpp/WorkerWrapper.cpptest-app/runtime/src/main/cpp/WorkerWrapper.htest-app/runtime/src/main/java/com/tns/AppConfig.javatest-app/runtime/src/main/java/com/tns/ClassResolver.javatest-app/runtime/src/main/java/com/tns/DexFactory.javatest-app/runtime/src/main/java/com/tns/Runtime.java
| The runtime path is wired through | ||
| [`com.tns.ClassResolver`](test-app/runtime/src/main/java/com/tns/ClassResolver.java) | ||
| → [`com.tns.DexFactory`](test-app/runtime/src/main/java/com/tns/DexFactory.java): | ||
|
|
||
| 1. `ClassResolver.resolveClass` first tries `classStorageService.retrieveClass(name)`. | ||
| In production this hits the SBG-generated dex and we're done. | ||
| 2. On `LookedUpClassNotFound` (typical for HMR), if a `baseClassName` is | ||
| present, `ClassResolver` falls back to `DexFactory.resolveClass(...)` | ||
| which runs the same `ProxyGenerator`/`Dump` pipeline SBG uses — only | ||
| it does it at runtime, writes the dex into the app's per-thumb cache | ||
| under `<dexDir>/<name>.dex`, wraps it in a `.jar`, and loads it via | ||
| `DexClassLoader` (or `BaseDexClassLoader` injection when the | ||
| `injectIntoParentClassLoader` flag is on). |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Dex cache file path in doc omits thumb suffix.
Doc says the dex is written to <dexDir>/<name>.dex, but DexFactory.getDexFile actually appends the thumb: dexDir + "/" + classToProxyFile + "-" + dexThumb + ".dex". Since this section doubles as a troubleshooting reference (see "What to look at when this breaks"), the actual on-disk filename pattern is worth stating precisely.
✏️ Suggested wording fix
- which runs the same `ProxyGenerator`/`Dump` pipeline SBG uses — only
- it does it at runtime, writes the dex into the app's per-thumb cache
- under `<dexDir>/<name>.dex`, wraps it in a `.jar`, and loads it via
+ which runs the same `ProxyGenerator`/`Dump` pipeline SBG uses — only
+ it does it at runtime, writes the dex into the app's per-thumb cache
+ under `<dexDir>/<name>-<dexThumb>.dex`, wraps it in a `.jar`, and loads it via
`DexClassLoader` (or `BaseDexClassLoader` injection when the📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| The runtime path is wired through | |
| [`com.tns.ClassResolver`](test-app/runtime/src/main/java/com/tns/ClassResolver.java) | |
| → [`com.tns.DexFactory`](test-app/runtime/src/main/java/com/tns/DexFactory.java): | |
| 1. `ClassResolver.resolveClass` first tries `classStorageService.retrieveClass(name)`. | |
| In production this hits the SBG-generated dex and we're done. | |
| 2. On `LookedUpClassNotFound` (typical for HMR), if a `baseClassName` is | |
| present, `ClassResolver` falls back to `DexFactory.resolveClass(...)` | |
| which runs the same `ProxyGenerator`/`Dump` pipeline SBG uses — only | |
| it does it at runtime, writes the dex into the app's per-thumb cache | |
| under `<dexDir>/<name>.dex`, wraps it in a `.jar`, and loads it via | |
| `DexClassLoader` (or `BaseDexClassLoader` injection when the | |
| `injectIntoParentClassLoader` flag is on). | |
| The runtime path is wired through | |
| [`com.tns.ClassResolver`](test-app/runtime/src/main/java/com/tns/ClassResolver.java) | |
| → [`com.tns.DexFactory`](test-app/runtime/src/main/java/com/tns/DexFactory.java): | |
| 1. `ClassResolver.resolveClass` first tries `classStorageService.retrieveClass(name)`. | |
| In production this hits the SBG-generated dex and we're done. | |
| 2. On `LookedUpClassNotFound` (typical for HMR), if a `baseClassName` is | |
| present, `ClassResolver` falls back to `DexFactory.resolveClass(...)` | |
| which runs the same `ProxyGenerator`/`Dump` pipeline SBG uses — only | |
| it does it at runtime, writes the dex into the app's per-thumb cache | |
| under `<dexDir>/<name>-<dexThumb>.dex`, wraps it in a `.jar`, and loads it via | |
| `DexClassLoader` (or `BaseDexClassLoader` injection when the | |
| `injectIntoParentClassLoader` flag is on). |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@README.md` around lines 80 - 92, The runtime cache path description is
incomplete: the dex filename pattern in the README should match
DexFactory.getDexFile. Update the documentation around ClassResolver and
DexFactory to state that the generated dex is written with the thumb suffix
(class name plus dex thumb) rather than just <name>.dex, so the troubleshooting
guidance reflects the actual on-disk path.
| void | ||
| CallbackHandlers::TerminateAllWorkersCallback(const v8::FunctionCallbackInfo<v8::Value> &args) { | ||
| // `__NS_DEV__.terminateAllWorkers()` — main-isolate-only dev helper. | ||
| // Tears down every worker parented by this isolate through the WorkerWrapper | ||
| // registry. TerminateChildren snapshots the registry under its lock, | ||
| // terminates and clears each worker, and lets each one cascade into its own | ||
| // nested workers, so a worker self-terminating in parallel can't invalidate | ||
| // the walk. Returns the number of direct (top-level) workers torn down so | ||
| // the HMR client can log it. | ||
| auto isolate = args.GetIsolate(); | ||
| HandleScope scope(isolate); | ||
|
|
||
| int terminated = WorkerWrapper::TerminateChildren(isolate); | ||
| args.GetReturnValue().Set(terminated); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Wrap worker termination in the same V8 callback exception boundary.
TerminateChildren() can reach JNI through child->Terminate(). If that throws, this callback currently lets a C++ exception escape across V8; the neighboring worker callbacks convert it back to NativeScriptException.
Proposed fix
void
CallbackHandlers::TerminateAllWorkersCallback(const v8::FunctionCallbackInfo<v8::Value> &args) {
- // `__NS_DEV__.terminateAllWorkers()` — main-isolate-only dev helper.
- // Tears down every worker parented by this isolate through the WorkerWrapper
- // registry. TerminateChildren snapshots the registry under its lock,
- // terminates and clears each worker, and lets each one cascade into its own
- // nested workers, so a worker self-terminating in parallel can't invalidate
- // the walk. Returns the number of direct (top-level) workers torn down so
- // the HMR client can log it.
auto isolate = args.GetIsolate();
- HandleScope scope(isolate);
+ try {
+ HandleScope scope(isolate);
- int terminated = WorkerWrapper::TerminateChildren(isolate);
- args.GetReturnValue().Set(terminated);
+ int terminated = WorkerWrapper::TerminateChildren(isolate);
+ args.GetReturnValue().Set(terminated);
+ } catch (NativeScriptException &ex) {
+ ex.ReThrowToV8();
+ } catch (std::exception &e) {
+ std::stringstream ss;
+ ss << "Error: c++ exception: " << e.what() << std::endl;
+ NativeScriptException nsEx(ss.str());
+ nsEx.ReThrowToV8();
+ } catch (...) {
+ NativeScriptException nsEx(std::string("Error: c++ exception!"));
+ nsEx.ReThrowToV8();
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| void | |
| CallbackHandlers::TerminateAllWorkersCallback(const v8::FunctionCallbackInfo<v8::Value> &args) { | |
| // `__NS_DEV__.terminateAllWorkers()` — main-isolate-only dev helper. | |
| // Tears down every worker parented by this isolate through the WorkerWrapper | |
| // registry. TerminateChildren snapshots the registry under its lock, | |
| // terminates and clears each worker, and lets each one cascade into its own | |
| // nested workers, so a worker self-terminating in parallel can't invalidate | |
| // the walk. Returns the number of direct (top-level) workers torn down so | |
| // the HMR client can log it. | |
| auto isolate = args.GetIsolate(); | |
| HandleScope scope(isolate); | |
| int terminated = WorkerWrapper::TerminateChildren(isolate); | |
| args.GetReturnValue().Set(terminated); | |
| void | |
| CallbackHandlers::TerminateAllWorkersCallback(const v8::FunctionCallbackInfo<v8::Value> &args) { | |
| auto isolate = args.GetIsolate(); | |
| try { | |
| HandleScope scope(isolate); | |
| int terminated = WorkerWrapper::TerminateChildren(isolate); | |
| args.GetReturnValue().Set(terminated); | |
| } catch (NativeScriptException &ex) { | |
| ex.ReThrowToV8(); | |
| } catch (std::exception &e) { | |
| std::stringstream ss; | |
| ss << "Error: c++ exception: " << e.what() << std::endl; | |
| NativeScriptException nsEx(ss.str()); | |
| nsEx.ReThrowToV8(); | |
| } catch (...) { | |
| NativeScriptException nsEx(std::string("Error: c++ exception!")); | |
| nsEx.ReThrowToV8(); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/CallbackHandlers.cpp` around lines 1348 - 1361,
Wrap CallbackHandlers::TerminateAllWorkersCallback in the same V8 exception
handling pattern used by the neighboring worker callbacks so any exception from
WorkerWrapper::TerminateChildren or child->Terminate() is converted to
NativeScriptException instead of escaping across V8. Locate the fix in
TerminateAllWorkersCallback and apply the same try/catch boundary and
rethrow/forwarding behavior already used in the adjacent callback handlers that
call into WorkerWrapper.
| { | ||
| std::string cached; | ||
| bool cacheHit = false; | ||
| { | ||
| std::lock_guard<std::mutex> lock(g_prefetchMutex); | ||
| auto it = g_prefetchCache.find(url); | ||
| if (it != g_prefetchCache.end()) { |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Use canonical keys for the prefetch cache.
MarkUrlsForCacheBust() canonicalizes URL identity, but g_prefetchCache reads, writes, and evicts raw URLs. Canonical-equivalent URLs like reordered dev query params or dropped t/v/import markers can miss the prewarm hit or leave stale prefetched bodies behind after invalidation.
Proposed fix
void EvictHttpModulePrefetchCacheUrls(const std::vector<std::string>& urls) {
if (urls.empty()) return;
std::lock_guard<std::mutex> lock(g_prefetchMutex);
size_t hits = 0;
for (const std::string& u : urls) {
- auto it = g_prefetchCache.find(u);
+ auto it = g_prefetchCache.find(CanonicalizeHttpUrlKey(u));
if (it != g_prefetchCache.end()) { g_prefetchCache.erase(it); ++hits; }
} {
std::lock_guard<std::mutex> lock(g_prefetchMutex);
- auto it = g_prefetchCache.find(url);
+ auto it = g_prefetchCache.find(CanonicalizeHttpUrlKey(url));
if (it != g_prefetchCache.end()) { {
std::lock_guard<std::mutex> lock(g_prefetchMutex);
- g_prefetchCache[urlCopy] = std::move(body);
+ g_prefetchCache[CanonicalizeHttpUrlKey(urlCopy)] = std::move(body);
}Also applies to: 1048-1057, 419-425
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/HMRSupport.cpp` around lines 580 - 586,
`g_prefetchCache` is using raw URLs instead of the same canonical identity used
by `MarkUrlsForCacheBust()`, so equivalent URLs can miss cache hits or leave
stale prefetched bodies behind. Update the prefetch cache read/write/eviction
paths in `HMRSupport.cpp` to normalize URLs before using them as keys, and make
the affected prewarm and invalidation flows use the same canonicalized key
consistently. Use the existing `MarkUrlsForCacheBust()` logic as the reference
for canonicalization and apply it wherever `g_prefetchCache` is accessed.
| // Entry trace gated behind `logScriptLoading`. Cold boot fires this dozens of | ||
| // times per session, so it stays off by default; enable `logScriptLoading` | ||
| // when triaging the HTTP-ESM path. | ||
| if (IsScriptLoadingLogEnabled()) { | ||
| DEBUG_WRITE("[http-esm][fetch][enter] url=%s", url.c_str()); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Gate per-fetch URL traces with httpFetchUrlLog.
The new httpFetchUrlLog setting is parsed and exposed, but the high-volume fetch URL entry trace still checks IsScriptLoadingLogEnabled(). Enabling "httpFetchUrlLog": true alone will not produce the advertised per-fetch diagnostics.
Proposed fix
- if (IsScriptLoadingLogEnabled()) {
+ if (IsHttpFetchUrlLogEnabled()) {
DEBUG_WRITE("[http-esm][fetch][enter] url=%s", url.c_str());
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Entry trace gated behind `logScriptLoading`. Cold boot fires this dozens of | |
| // times per session, so it stays off by default; enable `logScriptLoading` | |
| // when triaging the HTTP-ESM path. | |
| if (IsScriptLoadingLogEnabled()) { | |
| DEBUG_WRITE("[http-esm][fetch][enter] url=%s", url.c_str()); | |
| // Entry trace gated behind `logScriptLoading`. Cold boot fires this dozens of | |
| // times per session, so it stays off by default; enable `logScriptLoading` | |
| // when triaging the HTTP-ESM path. | |
| if (IsHttpFetchUrlLogEnabled()) { | |
| DEBUG_WRITE("[http-esm][fetch][enter] url=%s", url.c_str()); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/HMRSupport.cpp` around lines 664 - 668, The
per-fetch URL entry trace in HMRSupport’s HTTP-ESM fetch path is still guarded
by the script-loading flag instead of the new httpFetchUrlLog setting. Update
the conditional around the DEBUG_WRITE in the fetch entry flow to use the
httpFetchUrlLog-backed check (for example, the getter or helper associated with
httpFetchUrlLog) so enabling that setting alone turns on the URL trace. Keep the
existing fetch entry logging in the same location, just swap the gate used by
the HTTP fetch diagnostics path.
| std::thread([ctxCopy, urlCopy]() { | ||
| ctxCopy->concurrency->Acquire(); | ||
| std::string body, contentType; | ||
| int status = 0; | ||
| bool ok = PerformHttpFetchOnceSync(urlCopy, body, contentType, status); | ||
| if (ok && status >= 200 && status < 300 && !body.empty()) { | ||
| const size_t bodySize = body.size(); | ||
| // Overwrite unconditionally — the fresh body we just fetched is | ||
| // by definition the authoritative copy; any older cache entry is | ||
| // stale by construction (the caller has just told us so). | ||
| { | ||
| std::lock_guard<std::mutex> lock(g_prefetchMutex); | ||
| g_prefetchCache[urlCopy] = std::move(body); | ||
| } | ||
| ctxCopy->fetchedCount.fetch_add(1, std::memory_order_relaxed); | ||
| ctxCopy->bytes.fetch_add(bodySize, std::memory_order_relaxed); | ||
| } | ||
| ctxCopy->concurrency->Release(); | ||
| ctxCopy->LeavePending(); | ||
| }).detach(); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Prevent timed-out prefetch workers from writing stale cache entries.
Detached fetch threads can outlive KickstartHmrPrefetchUrlsSync() after timeout and still write into the process-wide prefetch cache. That can repopulate stale bodies after the caller has fallen back, after invalidation, or even after CleanupHMRGlobals() clears state.
Proposed direction
struct KickstartContext {
std::mutex mutex;
std::unordered_set<std::string> visited;
+ std::atomic_bool acceptingWrites{true};
std::atomic<size_t> fetchedCount{0};- if (ok && status >= 200 && status < 300 && !body.empty()) {
+ if (ok && status >= 200 && status < 300 && !body.empty() &&
+ ctxCopy->acceptingWrites.load(std::memory_order_acquire)) {
const size_t bodySize = body.size();
{
std::lock_guard<std::mutex> lock(g_prefetchMutex);
- g_prefetchCache[urlCopy] = std::move(body);
+ if (ctxCopy->acceptingWrites.load(std::memory_order_relaxed)) {
+ g_prefetchCache[CanonicalizeHttpUrlKey(urlCopy)] = std::move(body);
+ }
} while (true) {
drained = ctx->WaitDrainSlice(sliceMs);
if (drained) break;
if (std::chrono::steady_clock::now() >= deadline) break;
if (!IsDevSessionBootComplete()) {
InvokeHttpFetchYield();
}
}
+ if (!drained) {
+ ctx->acceptingWrites.store(false, std::memory_order_release);
+ }Also applies to: 1111-1140
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/HMRSupport.cpp` around lines 1044 - 1063, The
detached prefetch worker in HMRSupport::KickstartHmrPrefetchUrlsSync can still
update g_prefetchCache after the request has timed out or global HMR state has
been cleaned up. Add a cancellation/liveness check tied to the current prefetch
context (for example in the ctxCopy worker path before writing to the cache) so
stale workers exit without mutating shared state. Apply the same guard to the
matching detached fetch path referenced by the related block, and keep the cache
write under g_prefetchMutex only when the context is still valid.
| 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(), '.', '_'); | ||
| std::replace(fullPathToFile.begin(), fullPathToFile.end(), '-', '_'); | ||
| std::replace(fullPathToFile.begin(), fullPathToFile.end(), ' ', '_'); | ||
|
|
||
| std::vector<std::string> pathParts; | ||
|
|
||
| Util::SplitString(fullPathToFile, "_", pathParts); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Strip query/fragment and sanitize all non-identifier characters.
Cache-busted HTTP module URLs like foo.mjs?t=... won’t match the .mjs suffix check and can leave ?, =, &, or # in the generated token.
Proposed fix
+ size_t queryOrFragment = normalized.find_first_of("?#");
+ if (queryOrFragment != string::npos) {
+ normalized.resize(queryOrFragment);
+ }
+
auto endsWith = [](const string& s, const string& suffix) {
return s.size() >= suffix.size() &&
s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0;
};
@@
- 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<unsigned char>(ch);
+ const bool isIdentifierChar =
+ (c >= 'A' && c <= 'Z') ||
+ (c >= 'a' && c <= 'z') ||
+ (c >= '0' && c <= '9') ||
+ ch == '_';
+ if (!isIdentifierChar) {
+ ch = '_';
+ }
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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(), '.', '_'); | |
| std::replace(fullPathToFile.begin(), fullPathToFile.end(), '-', '_'); | |
| std::replace(fullPathToFile.begin(), fullPathToFile.end(), ' ', '_'); | |
| std::vector<std::string> pathParts; | |
| Util::SplitString(fullPathToFile, "_", pathParts); | |
| size_t queryOrFragment = normalized.find_first_of("?#"); | |
| if (queryOrFragment != string::npos) { | |
| normalized.resize(queryOrFragment); | |
| } | |
| 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; | |
| for (char& ch : fullPathToFile) { | |
| const unsigned char c = static_cast<unsigned char>(ch); | |
| const bool isIdentifierChar = | |
| (c >= 'A' && c <= 'Z') || | |
| (c >= 'a' && c <= 'z') || | |
| (c >= '0' && c <= '9') || | |
| ch == '_'; | |
| if (!isIdentifierChar) { | |
| ch = '_'; | |
| } | |
| } | |
| std::vector<std::string> pathParts; | |
| Util::SplitString(fullPathToFile, "_", pathParts); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/MetadataNode.cpp` around lines 1798 - 1816, The
module-name normalization in MetadataNode::GetModulePath should strip any query
string or fragment before checking for .mjs/.js suffixes, since cache-busted
URLs can bypass the current extension trimming. Update the logic around the
normalized/fullPathToFile handling to remove everything after ? or # first, then
keep sanitizing all non-identifier characters (including ?, =, &, #) before the
Util::SplitString step.
| if (!evalResult.IsEmpty() && evalResult->IsPromise()) { | ||
| Local<Promise> promise = evalResult.As<Promise>(); | ||
| const int maxAttempts = 100; | ||
| int attempts = 0; | ||
| while (attempts < maxAttempts) { | ||
| isolate->PerformMicrotaskCheckpoint(); | ||
| Promise::PromiseState state = promise->State(); | ||
| if (state != Promise::kPending) { | ||
| if (state == Promise::kRejected) { | ||
| Local<Value> reason = promise->Result(); | ||
| std::string reasonStr; | ||
| if (!reason.IsEmpty()) { | ||
| v8::Local<v8::String> reasonV8; | ||
| if (reason->ToString(context).ToLocal(&reasonV8)) { | ||
| reasonStr = ArgConverter::ConvertToString(reasonV8); | ||
| } | ||
| } | ||
| DEBUG_WRITE("[run-module][http-esm][evaluate-rejected] %s reason=%s", | ||
| path.c_str(), | ||
| reasonStr.empty() ? "<unknown>" : reasonStr.c_str()); | ||
| isolate->ThrowException(reason); | ||
| throw NativeScriptException( | ||
| string("HTTP module evaluation promise rejected: ") + path + | ||
| (reasonStr.empty() ? "" : (" — " + reasonStr))); | ||
| } | ||
| break; | ||
| } | ||
| attempts++; | ||
| usleep(100); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the relevant file and surrounding context.
git ls-files 'test-app/runtime/src/main/cpp/ModuleInternal.cpp'
wc -l test-app/runtime/src/main/cpp/ModuleInternal.cpp
sed -n '240,360p' test-app/runtime/src/main/cpp/ModuleInternal.cpp
# Find related HTTP/module evaluation code paths.
rg -n "PerformMicrotaskCheckpoint|Promise::kPending|evaluate-rejected|HTTP module evaluation|Top-level-await|TLA|IsPromise\\(" test-app/runtime/src/main/cpp/ModuleInternal.cpp test-app/runtime/src/main/cpp -A4 -B4Repository: NativeScript/android
Length of output: 25227
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the ES module evaluation path around the analogous promise-drain loop.
sed -n '680,760p' test-app/runtime/src/main/cpp/ModuleInternal.cpp
# Check whether any code path explicitly handles "still pending" after the loop.
rg -n "still pending after microtask drain|Promise::kPending|maxAttempts = 100|PerformMicrotaskCheckpoint\\(\\)" test-app/runtime/src/main/cpp/ModuleInternal.cpp -A3 -B3Repository: NativeScript/android
Length of output: 4752
Fail when HTTP module evaluation is still pending after the drain loop.
If the promise is still kPending after maxAttempts, this returns as success while evaluation is still in flight. Throw a timeout/pending-evaluation error instead.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/ModuleInternal.cpp` around lines 292 - 321, The
promise-drain logic in ModuleInternal.cpp currently exits successfully when
evalResult remains kPending after the maxAttempts loop. Update the HTTP module
evaluation path in the promise handling block to detect the still-pending state
after the loop and throw a timeout/pending-evaluation NativeScriptException
instead of falling through. Keep the existing rejected-path behavior intact and
make the new error message clearly identify the module path and that evaluation
never completed.
| int status = 0; | ||
| demangled = abi::__cxa_demangle(info.dli_sname, nullptr, nullptr, &status); | ||
| if (status == 0 && demangled) { | ||
| symName = demangled; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| __android_log_print(ANDROID_LOG_FATAL, "TNS.Native", | ||
| " #%02d pc 0x%016lx %s (%s+%lu)", idx, | ||
| static_cast<unsigned long>(relPc), libName, symName, | ||
| static_cast<unsigned long>(symOff)); | ||
| if (demangled) free(demangled); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Inspect allocating operations still present in the fatal signal path.
python - <<'PY'
from pathlib import Path
p = Path('test-app/runtime/src/main/cpp/Runtime.cpp')
for i, line in enumerate(p.read_text().splitlines(), 1):
if 85 <= i <= 176 and any(token in line for token in ['__cxa_demangle', 'free(', 'stringstream', 'malloc', 'new ']):
print(f'{i}: {line}')
PYRepository: NativeScript/android
Length of output: 472
🏁 Script executed:
#!/bin/bash
sed -n '90,180p' test-app/runtime/src/main/cpp/Runtime.cpp | cat -nRepository: NativeScript/android
Length of output: 4653
Avoid heap allocation in the fatal signal handler. abi::__cxa_demangle and the matching free() make this path depend on the allocator; if the crash already involves heap corruption, the backtrace can hang or fail before logging completes. Keep info.dli_sname as-is and demangle offline.
🧰 Tools
🪛 Cppcheck (2.21.0)
[error] 108-108: #error Unsupported bitness
(preprocessorErrorDirective)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/Runtime.cpp` around lines 101 - 113, The fatal
signal handler in Runtime.cpp currently heap-allocates via abi::__cxa_demangle
and frees the result, which makes the crash path depend on the allocator. Update
the backtrace formatting logic around the symbol lookup to avoid any allocation
in this handler: keep info.dli_sname unchanged for logging, remove the
demangling/freeing work from this path, and move demangling to an offline or
non-signal-handling context if needed. Use the existing backtrace loop and
__android_log_print call site as the place to preserve safe, allocator-free
logging.
| Object.defineProperty(URL.prototype, 'searchParams', { | ||
| get() { | ||
| if (this._searchParams == null) { | ||
| this._searchParams = new URLSearchParams(this.search); | ||
| Object.defineProperty(this._searchParams, '_url', { | ||
| enumerable: false, | ||
| writable: false, | ||
| value: this, | ||
| }); | ||
| this._searchParams._append = this._searchParams.append; | ||
| this._searchParams.append = function (name, value) { | ||
| this._append(name, value); | ||
| this._url.search = this.toString(); | ||
| }; | ||
| this._searchParams._delete = this._searchParams.delete; | ||
| this._searchParams.delete = function (name) { | ||
| this._delete(name); | ||
| this._url.search = this.toString(); | ||
| }; | ||
| this._searchParams._set = this._searchParams.set; | ||
| this._searchParams.set = function (name, value) { | ||
| this._set(name, value); | ||
| this._url.search = this.toString(); | ||
| }; | ||
| this._searchParams._sort = this._searchParams.sort; | ||
| this._searchParams.sort = function () { | ||
| this._sort(); | ||
| this._url.search = this.toString(); | ||
| }; | ||
| } | ||
| return this._searchParams; | ||
| }, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify coverage for URL.search/searchParams cache synchronization.
rg -n -C3 'searchParams|URLSearchParams|URL\\.createObjectURL' test-app/runtime/src/main/cpp test-app/app/src/main/assets/app/testsRepository: NativeScript/android
Length of output: 50376
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the URL polyfill and any existing cache-invalidation hooks.
sed -n '1,140p' test-app/runtime/src/main/cpp/URLImpl.cpp
printf '\n--- search-related definitions ---\n'
rg -n -C2 'searchParams|\.search\b|_searchParamsSearch|defineProperty\(URL\.prototype' test-app/runtime/src/main/cpp/URLImpl.cpp
printf '\n--- tests covering URL.search / searchParams interactions ---\n'
rg -n -C3 'searchParams|\.search\s*=|URL\(' test-app/app/src/main/assets/app/tests/testURLImpl.js test-app/app/src/main/assets/app/tests/testURLSearchParamsImpl.jsRepository: NativeScript/android
Length of output: 14841
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the URL.search accessor implementation and any cache-reset logic.
rg -n -C4 'SetSearch|GetSearch|search\b|_searchParams|searchParams' test-app/runtime/src/main/cpp/URLImpl.cpp
printf '\n--- accessor block around search ---\n'
sed -n '140,260p' test-app/runtime/src/main/cpp/URLImpl.cppRepository: NativeScript/android
Length of output: 8160
Keep URL.searchParams in sync when search is reassigned. SetSearch updates the backing URL but never resyncs the cached params object, so url.searchParams can expose stale data after url.search = ...; update the existing params object instead of recreating it.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/URLImpl.cpp` around lines 55 - 86, The
URL.searchParams getter caches a URLSearchParams instance, but the SetSearch
path does not refresh that cached object when url.search is reassigned, so it
can become stale. Update the URLImpl URL/search handling so the existing
_searchParams object is synchronized with the new search string in SetSearch
instead of replacing or leaving it unchanged, and keep the URLSearchParams
methods on the cached instance consistent with the updated URL.
| #define NATIVE_SCRIPT_RUNTIME_VERSION "9.1.0-alpha.7" | ||
| #define NATIVE_SCRIPT_RUNTIME_COMMIT_SHA "no commit sha was provided by build.gradle build" No newline at end of file |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify whether build tooling rewrites NATIVE_SCRIPT_RUNTIME_COMMIT_SHA.
rg -n -C3 'NATIVE_SCRIPT_RUNTIME_COMMIT_SHA|Version\\.h|commit sha' .Repository: NativeScript/android
Length of output: 1470
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the runtime build wiring around Version.h and the commit SHA placeholder.
printf '\n== build.gradle relevant lines ==\n'
sed -n '1,220p' build.gradle | nl -ba | sed -n '1,140p'
printf '\n== files that mention Version.h generation ==\n'
rg -n -C 2 'Version\.h|arVersion|pVersion|commit sha was provided by build\.gradle build|native.*version' build.gradle test-app/runtime -g '!**/dist/**'Repository: NativeScript/android
Length of output: 233
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== build.gradle excerpt ==\n'
awk 'NR>=1 && NR<=220 {printf "%4d %s\n", NR, $0}' build.gradle
printf '\n== Version.h generation refs ==\n'
rg -n -C 3 'Version\.h|arVersion|pVersion|commit sha was provided by build\.gradle build|configure.*Version|generate.*Version' build.gradle test-app/runtime -g '!**/dist/**'Repository: NativeScript/android
Length of output: 14555
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== exact runtime build replacements ==\n'
sed -n '260,305p' test-app/runtime/build.gradle | awk '{printf "%4d %s\n", NR+259, $0}'
printf '\n== all placeholder symbols in repo ==\n'
rg -n 'RUNTIME_COMMIT_SHA_PLACEHOLDER|0\.0\.0\.0|no commit sha was provided by build\.gradle build' test-app/runtime build.gradleRepository: NativeScript/android
Length of output: 2400
Replace the commit SHA fallback before packaging
test-app/runtime/build.gradle only rewrites RUNTIME_COMMIT_SHA_PLACEHOLDER, so the checked-in value in test-app/runtime/src/main/cpp/Version.h stays as "no commit sha was provided by build.gradle build" and gets logged at startup. Update the literal or the replacement token so release builds carry the actual git SHA.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/Version.h` around lines 1 - 2, The checked-in
fallback for the runtime commit SHA in Version.h is still the placeholder
string, so startup logs can show a bogus value. Update the Version.h literal or
make test-app/runtime/build.gradle replace the exact symbol used by
NATIVE_SCRIPT_RUNTIME_COMMIT_SHA so packaged release builds include the real git
SHA instead of the fallback.
Adds the Hot Module Replacement runtime layer plus the supporting ESM
resolver hardening and dev-session globals that make hot reload viable on
Android.
* `import.meta.hot`: `data`, `accept`, `dispose`, `prune`, `decline`,
`invalidate`, `on`/`off`/`send` event surface.
* Dev-session globals (`__nsStartDevSession`, `__nsReloadDevApp`,
`__nsInvalidateModules`, `__nsRunHmrDispose`, `__nsRunHmrPrune`,
`__nsHasDeclinedModule`, `__nsKickstartHmrPrefetch`,
`__nsGetLoadedModuleUrls`, `__nsApplyStyleUpdate`,
`__nsConfigureDevRuntime`/`__nsConfigureRuntime`,
`__nsTerminateAllWorkers`).
* Speculative HTTP module prefetch (opt-in) with canonical-key
normalization so `__ns_hmr__/v<N>` and `__ns_boot__/b<N>` tag prefixes
share `hot.data` identity across reload cycles.
* ESM resolver hardening in `ModuleInternalCallbacks.cpp` to:
- Preserve synthetic-namespace identity (`ns-vendor://`, `optional:`,
`node:`, `blob:`) — these are NOT filesystem paths.
- Handle HTTP/HTTPS module URLs end-to-end (resolution, fetch,
canonical-key collapse, dynamic import).
- Compile `.json` imports into synthetic ES modules.
* Android runtime-dex support for `.extend()` classes created during HMR
that the static binding generator can't see at build time: runtime DEX
generation with `$`/`_` inner-class normalization (`DexFactory`),
dev/HMR class-resolution fallback (`ClassResolver`), and dev-flag /
`logScriptLoading` plumbing (`AppConfig`, `DevFlags`).
…ract The runtime explicitly does not implement HMR policy. import.meta.hot, the hot-data/accept/dispose/prune registries, and dev-session state move to the JS HMR clients (eg, @nativescript/vite); native keeps only the sync HTTP module fetch, prewarm cache + list-mode kickstart, eviction plumbing, and the dev-boot-complete signal. Dev helpers are consolidated under __NS_DEV__. Also fixes dynamic import() error propagation: with top-level-await enabled, Module::Evaluate() returns a promise instead of an empty MaybeLocal on throw, so the previous empty-check never fired and import() resolved a half-evaluated namespace, silently swallowing module evaluation errors. The callback now checks Module::GetStatus() for kErrored (HTTP, blob, and generic paths) and rejects with the module's real exception, matching iOS. Removes the httpModulePrefetch app-config flag and speculative prefetching; list-mode kickstart remains.
…from the boot archive
The JS bootstrap downloads the dev server's /__ns_dev__/boot-archive
(NDJSON of {url, body} entries) and seeds them directly into the one-shot
prewarm cache consumed by HttpFetchText during V8's synchronous module
walk — replacing hundreds of serial kickstart fetches with one payload.
Mechanism only: the server computes the closure and bodies; the runtime
stores them behind the same gates as kickstart prefetch. Returns
{ ok, seeded, bytes } so callers can fall back to kickstartPrefetch.
[skip ci]
f821c25 to
7c48d11
Compare
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test-app/runtime/src/main/cpp/HMRSupport.cpp`:
- Around line 697-729: Remove the process-wide keep-alive workaround guarded by
sKeepAliveDisabled, including the System.setProperty("http.keepAlive", "false")
JNI calls. Preserve the existing per-request Connection: close header and retry
path so the workaround remains scoped to loader requests.
- Around line 1015-1044: Update KickstartScheduleUrls so it does not create one
detached thread per URL or call EnterPending before unbounded thread
construction. Build a shared URL queue and start at most maxConcurrent worker
threads that consume it, ensuring thread creation is bounded and construction
failures cannot leave pending state inconsistent.
In `@test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp`:
- Around line 751-789: Update RemoveModuleFromRegistry and InvalidateModules to
also clear the corresponding handle in g_vendorModuleCache whenever the
canonical key is an ns-vendor://<id> entry. Keep registry removal and existing
URL eviction behavior unchanged, and ensure both APIs evict the vendor cache
entry so ResolveFromVendorRegistry cannot return the stale module.
- Around line 580-592: The declaration generation in ResolveFromVendorRegistry
must not use export names that are JavaScript reserved words, even when
IsValidJSIdentifier accepts them. Detect reserved keywords and emit a safe local
alias for the declaration, then re-export that alias under the original name;
retain direct declarations for non-reserved valid identifiers.
- Around line 1728-1741: Update the dynamic-import evaluation flow around
blobMod->Evaluate() to await its returned promise before resolving the module
namespace, propagating rejected evaluation promises to the import resolver.
Apply the same promise chaining and fulfillment-only namespace resolution to the
other dynamic-import branches, while preserving the existing synchronous error
handling.
In `@test-app/runtime/src/main/cpp/Runtime.cpp`:
- Around line 225-240: Update the signal-handler setup around sigaltstack and
the sigaction calls to check each return value and log or otherwise surface
registration failures. Ensure failures for the alternate stack and every signal
in this initialization path are reported, while preserving the existing handler
configuration.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 503fb44d-5be8-4da5-a9c1-bf6a23e9b351
📒 Files selected for processing (26)
README.mdtest-app/app/src/main/assets/app/mainpage.jstest-app/app/src/main/assets/app/tests/esm/meta-no-hot.mjstest-app/app/src/main/assets/app/tests/testHttpCanonicalKey.mjstest-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjstest-app/app/src/main/assets/app/tests/testNsDevBoundary.mjstest-app/runtime/src/main/cpp/CallbackHandlers.cpptest-app/runtime/src/main/cpp/CallbackHandlers.htest-app/runtime/src/main/cpp/DevFlags.cpptest-app/runtime/src/main/cpp/DevFlags.htest-app/runtime/src/main/cpp/HMRSupport.cpptest-app/runtime/src/main/cpp/HMRSupport.htest-app/runtime/src/main/cpp/MetadataNode.cpptest-app/runtime/src/main/cpp/ModuleInternal.cpptest-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpptest-app/runtime/src/main/cpp/ModuleInternalCallbacks.htest-app/runtime/src/main/cpp/Runtime.cpptest-app/runtime/src/main/cpp/URLImpl.cpptest-app/runtime/src/main/cpp/URLImpl.htest-app/runtime/src/main/cpp/Version.htest-app/runtime/src/main/cpp/WorkerWrapper.cpptest-app/runtime/src/main/cpp/WorkerWrapper.htest-app/runtime/src/main/java/com/tns/AppConfig.javatest-app/runtime/src/main/java/com/tns/ClassResolver.javatest-app/runtime/src/main/java/com/tns/DexFactory.javatest-app/runtime/src/main/java/com/tns/Runtime.java
🚧 Files skipped from review as they are similar to previous changes (22)
- test-app/runtime/src/main/cpp/Version.h
- test-app/app/src/main/assets/app/tests/esm/meta-no-hot.mjs
- test-app/runtime/src/main/cpp/DevFlags.h
- test-app/runtime/src/main/cpp/CallbackHandlers.h
- test-app/app/src/main/assets/app/tests/testHttpCanonicalKey.mjs
- test-app/app/src/main/assets/app/tests/testNodeBuiltinsAndOptionalModules.mjs
- test-app/runtime/src/main/java/com/tns/Runtime.java
- test-app/runtime/src/main/cpp/ModuleInternalCallbacks.h
- test-app/runtime/src/main/cpp/WorkerWrapper.cpp
- test-app/app/src/main/assets/app/mainpage.js
- README.md
- test-app/runtime/src/main/cpp/CallbackHandlers.cpp
- test-app/runtime/src/main/cpp/URLImpl.cpp
- test-app/runtime/src/main/java/com/tns/AppConfig.java
- test-app/runtime/src/main/java/com/tns/DexFactory.java
- test-app/runtime/src/main/cpp/WorkerWrapper.h
- test-app/runtime/src/main/cpp/ModuleInternal.cpp
- test-app/runtime/src/main/cpp/HMRSupport.h
- test-app/runtime/src/main/cpp/MetadataNode.cpp
- test-app/runtime/src/main/cpp/DevFlags.cpp
- test-app/app/src/main/assets/app/tests/testNsDevBoundary.mjs
- test-app/runtime/src/main/cpp/URLImpl.h
| // One-time process-wide kill switch for okhttp's connection | ||
| // pool. Android's stock HttpURLConnection (which okhttp backs) | ||
| // pools sockets across requests, and when Vite's keep-alive | ||
| // timeout fires before our next request we end up reusing a | ||
| // dead socket and hitting | ||
| // `IOException: unexpected end of stream`. Setting | ||
| // `http.keepAlive=false` forces a fresh TCP connection per | ||
| // fetch, which sidesteps the pool entirely. | ||
| // | ||
| // We also set `Connection: close` on the per-request headers | ||
| // below as a belt-and-suspenders signal — the property covers | ||
| // the pool, the header covers the wire. Doing this once via | ||
| // an atomic guard keeps the cost out of the hot path on | ||
| // repeat fetches (this is a synchronous V8 module-loader | ||
| // hot path that can fire dozens of times per cold boot). | ||
| static std::atomic<bool> sKeepAliveDisabled{false}; | ||
| if (!sKeepAliveDisabled.exchange(true)) { | ||
| 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); | ||
| // Don't care about the previous value or about exceptions | ||
| // here — `System.setProperty` only throws SecurityException | ||
| // under a SecurityManager and Android apps don't install one. | ||
| env.ExceptionClear(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win
Do not disable HTTP keep-alive process-wide.
System.setProperty("http.keepAlive", "false") persists across the process, so one module fetch can disable connection reuse for unrelated application networking. Keep the workaround scoped to loader requests using the existing Connection: close header and retry path.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/HMRSupport.cpp` around lines 697 - 729, Remove
the process-wide keep-alive workaround guarded by sKeepAliveDisabled, including
the System.setProperty("http.keepAlive", "false") JNI calls. Preserve the
existing per-request Connection: close header and retry path so the workaround
remains scoped to loader requests.
| void KickstartScheduleUrls(std::shared_ptr<KickstartContext> ctx, | ||
| std::vector<std::string> urls) { | ||
| for (const std::string& urlRef : urls) { | ||
| if (urlRef.empty()) continue; | ||
| if (!StartsWith(urlRef, "http://") && !StartsWith(urlRef, "https://")) continue; | ||
| if (!LooksLikeJsSourceUrl(urlRef)) continue; | ||
| if (!IsRemoteUrlAllowed(urlRef)) continue; | ||
|
|
||
| bool fresh; | ||
| { | ||
| std::lock_guard<std::mutex> lock(ctx->mutex); | ||
| fresh = ctx->visited.insert(urlRef).second; | ||
| } | ||
| if (!fresh) continue; | ||
|
|
||
| // No "already cached" short-circuit here — the caller has explicitly | ||
| // told us "fetch these URLs fresh". Any body sitting in | ||
| // `g_prefetchCache` for one of them is a leftover from a previous | ||
| // wave that V8 didn't consume; honoring it would feed V8 a stale | ||
| // body on the next walk — the "1 cycle behind" symptom for `.ts` | ||
| // edits with many transitive importers. (`InvalidateModules` | ||
| // pre-clears the cache for the eviction set, so this is | ||
| // defense-in-depth — but the kickstart may also be invoked | ||
| // manually for diagnostics, and we want it to be correct in | ||
| // isolation.) | ||
|
|
||
| ctx->EnterPending(); | ||
| std::string urlCopy = urlRef; | ||
| auto ctxCopy = ctx; | ||
| std::thread([ctxCopy, urlCopy]() { |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Bound thread creation, not only active fetches.
The loop creates one detached OS thread per URL; the semaphore only blocks those threads afterward. Large module closures can exhaust thread limits, and std::thread construction failure occurs after EnterPending(). Use at most maxConcurrent workers consuming a shared queue.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/HMRSupport.cpp` around lines 1015 - 1044,
Update KickstartScheduleUrls so it does not create one detached thread per URL
or call EnterPending before unbounded thread construction. Build a shared URL
queue and start at most maxConcurrent worker threads that consume it, ensuring
thread creation is bounded and construction failures cannot leave pending state
inconsistent.
| static bool IsValidJSIdentifier(const std::string& name) { | ||
| if (name.empty()) return false; | ||
| char first = name[0]; | ||
| if (!((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') || | ||
| first == '_' || first == '$')) | ||
| return false; | ||
| for (size_t i = 1; i < name.size(); i++) { | ||
| char c = name[i]; | ||
| if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || | ||
| (c >= '0' && c <= '9') || c == '_' || c == '$')) | ||
| return false; | ||
| } | ||
| return true; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
rg -n -C4 'IsValidJSIdentifier|export const .*__mod' \
test-app/runtime/src/main/cpp test-app/app/src/main/assets/app/testsRepository: NativeScript/android
Length of output: 4223
🏁 Script executed:
sed -n '576,686p' test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cppRepository: NativeScript/android
Length of output: 4605
🏁 Script executed:
rg -n 'reserved|keyword|IsValidJSIdentifier|exportNames' test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp test-app/runtime/src/main/cppRepository: NativeScript/android
Length of output: 43980
Avoid using export names as declaration identifiers
In ResolveFromVendorRegistry, names like class or await can pass IsValidJSIdentifier but still make export const <name> = ... invalid syntax. Emit a safe local alias and re-export it under the original name.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp` around lines 580 -
592, The declaration generation in ResolveFromVendorRegistry must not use export
names that are JavaScript reserved words, even when IsValidJSIdentifier accepts
them. Detect reserved keywords and emit a safe local alias for the declaration,
then re-export that alias under the original name; retain direct declarations
for non-reserved valid identifiers.
| void RemoveModuleFromRegistry(const std::string& canonicalKey) { | ||
| const std::string registryKey = CanonicalizeRegistryKey(canonicalKey); | ||
| // Defensive: never wipe a sentinel key. | ||
| if (registryKey == "@" || | ||
| registryKey.find("__invalid_at__.mjs") != std::string::npos) { | ||
| if (IsScriptLoadingLogEnabled()) { | ||
| DEBUG_WRITE("[resolver][guard] ignore remove for sentinel %s", registryKey.c_str()); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
|
|
||
| size_t InvalidateModules(const std::vector<std::string>& keys) { | ||
| size_t removed = 0; | ||
| std::vector<std::string> urlsToEvict; | ||
| urlsToEvict.reserve(keys.size()); | ||
| for (const auto& raw : keys) { | ||
| if (raw.empty()) continue; | ||
| const std::string registryKey = CanonicalizeRegistryKey(raw); | ||
| auto it = g_moduleRegistry.find(registryKey); | ||
| if (it != g_moduleRegistry.end()) { | ||
| it->second.Reset(); | ||
| g_moduleRegistry.erase(it); | ||
| ++removed; | ||
| } | ||
| if (StartsWith(registryKey, "http://") || StartsWith(registryKey, "https://")) { | ||
| urlsToEvict.push_back(registryKey); | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Evict the vendor cache alongside the module registry.
Invalidating ns-vendor://<id> removes only g_moduleRegistry; ResolveFromVendorRegistry() then returns the same module from g_vendorModuleCache. Clear the corresponding vendor-cache handle in both removal APIs.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp` around lines 751 -
789, Update RemoveModuleFromRegistry and InvalidateModules to also clear the
corresponding handle in g_vendorModuleCache whenever the canonical key is an
ns-vendor://<id> entry. Keep registry removal and existing URL eviction behavior
unchanged, and ensure both APIs evict the vendor cache entry so
ResolveFromVendorRegistry cannot return the stale module.
| if (blobMod->GetStatus() != v8::Module::kEvaluated) { | ||
| if (blobMod->Evaluate(context).IsEmpty()) { | ||
| resolver->Reject(context, | ||
| v8::Exception::Error(ArgConverter::ConvertToV8String( | ||
| isolate, "Blob module evaluation failed"))).Check(); | ||
| return scope.Escape(resolver->GetPromise()); | ||
| } | ||
| } | ||
| if (blobMod->GetStatus() == v8::Module::kErrored) { | ||
| resolver->Reject(context, blobMod->GetException()).Check(); | ||
| return scope.Escape(resolver->GetPromise()); | ||
| } | ||
| resolver->Resolve(context, blobMod->GetModuleNamespace()).Check(); | ||
| return scope.Escape(resolver->GetPromise()); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
rg -n -C5 'Evaluate\(context\)|GetStatus\(\).*kErrored|GetModuleNamespace' \
test-app/runtime/src/main/cppRepository: NativeScript/android
Length of output: 16959
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect V8 module API docs and surrounding callback implementations.
sed -n '140,260p' test-app/runtime/src/main/cpp/include/v8-script.h
printf '\n--- ModuleInternalCallbacks around the blob/http/import handlers ---\n'
sed -n '1690,1910p' test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cppRepository: NativeScript/android
Length of output: 15322
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Search for any promise chaining around Evaluate() in dynamic import handlers.
rg -n -C3 'Evaluate\(context\)|Then\(|Promise::|MaybeLocal<v8::Value> evalResult' \
test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp \
test-app/runtime/src/main/cpp/ModuleInternal.cppRepository: NativeScript/android
Length of output: 8124
Wait for async module evaluation before resolving import(). Evaluate() returns a promise for top-level-await graphs, but this code ignores it and resolves the namespace immediately. That lets import() settle before evaluation finishes and drops later rejections. Chain the evaluation promise here and in the other dynamic-import branches, and only resolve on fulfillment.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp` around lines 1728
- 1741, Update the dynamic-import evaluation flow around blobMod->Evaluate() to
await its returned promise before resolving the module namespace, propagating
rejected evaluation promises to the import resolver. Apply the same promise
chaining and fulfillment-only namespace resolution to the other dynamic-import
branches, while preserving the existing synchronous error handling.
| sigaltstack(&altStack, nullptr); | ||
|
|
||
| struct sigaction action; | ||
| action.sa_handler = SIG_handler; | ||
| memset(&action, 0, sizeof(action)); | ||
| sigemptyset(&action.sa_mask); | ||
| // SA_SIGINFO enables the 3-arg handler so we get siginfo_t (fault addr | ||
| // and si_code). SA_ONSTACK lets the handler run on an alternate stack | ||
| // — important so we still produce a useful backtrace if the original | ||
| // crash was a stack overflow. | ||
| action.sa_flags = SA_SIGINFO | SA_ONSTACK; | ||
| action.sa_sigaction = SIG_handler; | ||
| sigaction(SIGABRT, &action, NULL); | ||
| sigaction(SIGSEGV, &action, NULL); | ||
| sigaction(SIGBUS, &action, NULL); | ||
| sigaction(SIGFPE, &action, NULL); | ||
| sigaction(SIGILL, &action, NULL); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
file='test-app/runtime/src/main/cpp/Runtime.cpp'
echo '--- file size ---'
wc -l "$file"
echo '--- relevant lines ---'
sed -n '190,270p' "$file"Repository: NativeScript/android
Length of output: 3083
🏁 Script executed:
#!/bin/bash
set -euo pipefail
file='test-app/runtime/src/main/cpp/Runtime.cpp'
echo '--- other sigaltstack/sigaction occurrences ---'
rg -n '\bsigaltstack\b|\bsigaction\b' "$file"
echo '--- nearby helper/error logging patterns ---'
rg -n '__android_log_print|perror|strerror|errno' "$file" | head -n 50Repository: NativeScript/android
Length of output: 1234
Check signal-handler installation failures. sigaltstack() and each sigaction() return value is ignored here, so initialization can silently leave the crash handler inactive. Log or surface an error when registration fails.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-app/runtime/src/main/cpp/Runtime.cpp` around lines 225 - 240, Update the
signal-handler setup around sigaltstack and the sigaction calls to check each
return value and log or otherwise surface registration failures. Ensure failures
for the alternate stack and every signal in this initialization path are
reported, while preserving the existing handler configuration.
Framework-agnostic hot module replacement on Android with native ES modules: the device fetches modules over HTTP from the Vite dev server and applies hot updates without restarting the process.
The runtime's entire dev surface is one namespace object,
globalThis.__NS_DEV__, carrying primitives, each traceable to a V8-embedder or OS constraint:configureRuntime(config)ResolveModuleCallbackinvalidateModules(urls)v8::Modulerecords + prewarm cache + arms a CFNetwork cache-bust noncekickstartPrefetch(urls)getLoadedModuleUrls()setDevBootComplete(bool)Every HMR policy decision, boot orchestration,
import.meta.hot, full reload, CSS apply, the WebSocket protocol is JS in@nativescript/vite. No native dev-session state machine, no reload orchestration, no hot-callback registries exist in the runtime, and on-device tests pin both the present members and the absent globals.Summary by CodeRabbit
New Features
__NS_DEV__loader capabilities.blob:URL support, includingURL.searchParamshandling.HttpFetchUrlLogconfiguration and updated runtime version metadata.Bug Fixes
Documentation
Tests