Skip to content

ESM loader: remaining cross-platform decisions and deferred follow-ups from #1965 #2020

Description

@edusperoni

#1965 landed the full loader-overhaul parity with ios#383 plus several hardening rounds. Everything that could be fixed unilaterally was; this issue tracks what deliberately remains: contract questions that need one answer on both platforms, and Android-side follow-ups that were out of the PR's scope. The iOS-side defects the same reviews surfaced are tracked in NativeScript/ios#443 (fixes landed as NativeScript/ios#444).

Cross-platform decisions (one answer, both runtimes)

  • require(esm) facade evaluates under sync-strict (shared bug): createPumpingRequire() of an already-settled TLA module with a default export throws the async-graph refusal naming ns:require-facade:<path>IsGraphAsync() is transitive and the facade requests the TLA target. Violates the contract twice (pumping lifts the TLA refusal; exports-cascade step 3) and the error recommends the API that just failed. Fix shape to agree on: direct Evaluate() + assert-fulfilled for the facade, or a synthetic-module bypass on the evaluation options.
  • Import-map keys carrying queries: Android strips query/fragment before the map consult (query-bearing keys can never match); iOS matches them for static imports. The HTML import-maps spec matches the specifier as written. Pick a side and align.
  • import.meta.url shape: Android exposes the full device path; iOS base-strips to file:///app/.... Portable code matching file:///app/ breaks on Android today. Couples with file:// specifier candidate handling and iOS's dirname inconsistency.
  • Transport identity: Android sends User-Agent: NativeScript-HTTP-ESM and inherits the process CookieHandler; iOS sends no UA and disables cookies. Cookies especially need a joint call.
  • Transport timeouts: Android 15s connect/15s read vs iOS 5s/10s. The docs now state the per-attempt/per-read semantics honestly, but the values should converge (a stalled edge can outlive the graph deadline).
  • HasPendingAsyncModuleGraphWork is process-wide on both platforms: a worker's graph load holds the main boot backstop open. Make it per-isolate.
  • Instantiate-failure registry semantics: iOS evicts the root, Android leaves-and-self-heals; HTML/Node module-map semantics say keep-and-rethrow. Neither does that — pick one.
  • Import attributes are silently ignored (import x from './d.json' with { type: 'json' } loads as JS on both); Node throws. Document or implement, jointly.
  • Import-map validation error-text shape: parse-detail suffix conditionality and multi-offense selection differ slightly between the platforms' JSON parsers. Normalize or document.
  • createRequire's hand-rolled file: parser disagrees with node:url (%2F honored, file://FILE: rejected vs Node); shared. Rebase it on the URL primordial (the comment claiming that's unavailable is wrong on both sides).
  • JSON modules are built textually ("export default " + jsonText), so a "__proto__" key becomes a prototype setter instead of an own property. Shared deviation from JSON-module semantics.
  • require-factory hooks: __requireOverride is Android-only with no producers in-tree; __pauseOnNextRequire is iOS-only (inspector-produced). Converge or delete.
  • The 1s local-entry yield: normative in both docs, but its original purpose was iOS's pre-main() runway. Decide whether to shrink/remove (doc change on both).
  • Waiter-invariant race fixture: the "already-evaluating with no continuation attached" dynamic-import window is documented but unenforced on both platforms — needs a spec that pins it.
  • Pump mechanism shape on iOS (surfaced by porting android#1965's timer specs to iOS): a pumping require started from inside iOS's shared JS-timer CFRunLoopTimer callback can never observe another timer fire — CFRunLoopTimer does not re-enter. Android's pump drains the ordered lane directly and is immune; whether iOS should adopt the same drain shape instead of spinning the runloop is a joint pump-contract call.
  • Frozen-at-mint pumping options: neither suite pins that the options object is validated once and immune to later mutation. Add the spec on both.

Android follow-ups

Reclassified 2026-08-20: the five checked items immediately below turned out to be features present in the merged ios#383 code that the port had missed — implemented in #2021.

  • ESM code cache — landed in feat: close the remaining gaps against the merged iOS loader PR #2021: CompileFileEsModule consumes/produces cache blobs (.mcache keying avoids colliding with the classic .cache for the same file).

  • Fetch concurrency cap — landed in feat: close the remaining gaps against the merged iOS loader PR #2021: 16 concurrent fetches process-wide (iOS: HTTPMaximumConnectionsPerHost = 16), queued jobs drained by finishing threads, spawn failures delivered as transport errors.

  • Canonical-key transport signatures — landed in feat: close the remaining gaps against the merged iOS loader PR #2021: the transport takes keys from callers (iOS's shape); also fixed collapsed-scheme cache-bust marks never matching.

  • HTTP worker entries — landed in feat: close the remaining gaps against the merged iOS loader PR #2021: new Worker("http://…") works end-to-end (security gate, vocabulary copy, settle gate, inspector URL), spec included.

  • ns:module binding beside the loader state — landed in feat: close the remaining gaps against the merged iOS loader PR #2021 (ios 5ca0c43c), including the ParseImportMap/InstallParsedImportMap split that removes the double parse.

  • Lock the process-global interop caches — done in 7c9c3db, though not as scoped here. The three caches this item names were already covered by fix: runtime lifetime — deferred leaks, cross-isolate sharing, startup robustness #2013: Console timers and ArgConverter's type-long cache became per-runtime RuntimeState::For<T>(isolate) (the Isolate*-keyed process-global maps are gone entirely), MetadataNode's name/tree-node caches got s_nodeCacheMutex, MetadataReader got its own StateMutex, and JEnv::s_classCache/s_missingClasses plus MethodCache::s_mthod_ctor_signature_cache each got a shared_mutex — all on the lock-the-find-and-the-emplace-but-never-across-the-build discipline this item asks for. Auditing the runtime for what genuinely remained in that class turned up four more, all fixed in 7c9c3db:

    • File::Buffer — one process-wide 1MB scratch buffer that File::ReadText filled and returned a pointer into. Main and worker runtimes read modules concurrently from their own threads, so one thread's fread overwrote bytes another was still copying out: silent module-source corruption rather than a crash, which is why this one never appeared in a tombstone. It also never saved the allocation it appears to save — every caller goes through the std::string overload, which copied out of it on the next line — so reads now go straight into the returned string and the borrowing overload (no callers) is deleted.
    • JType::EnsureInstance — published *instance before Init had filled clazz/ctor/valueMethodId, so a second thread could find the pointer non-null and call through uninitialised JNI ids, on the hot boxing path. Now std::call_once per Type.
    • CallbackHandlers::Init — runs once per runtime from PrepareV8Runtime, so every worker start rewrote the process-global class and method-id statics (and re-ran MethodCache::Init) while the isolates already running were reading them. Process-global half now under call_once; MetadataNode::Init(isolate) stays per-isolate.
    • MetadataNode::IsJavascriptKeyword — filled a function-local static set<string> behind an empty() check, racing reads from every runtime's thread.

    Audited and deliberately left alone: JsV8InspectorClient::Domains is unguarded but main-isolate only (workers use WorkerInspectorClient, which never registers domain dispatchers); SimpleProfiler::s_frames is racy and stores a pointer into a reallocating vector, but SimpleProfiler::Init has no callers (dead code); PageResource::s_cachedPageResources is write-only, nothing reads it.

  • Runtime::ReadFileText's m_fileWriteMutex is broken three ways (surfaced while removing File::Buffer, which this mutex superficially looked like it guarded — it never did). Its real purpose is LiveSync: NativeScriptSyncServiceSocketImpl.createOrOverrideFile() takes runtime.lock() so the runtime does not compile a half-written hot-synced module. But (1) m_fileWriteMutex is a non-static member (Runtime.h), so LiveSync locking one runtime excludes nothing on a worker's thread — precisely the case that matters; (2) the lock is taken after prepareFile() deletes the file and new FileOutputStream truncates it, so it covers only fos.write and not the window a reader actually hits; (3) the finally calls runtime.unlock() even when lock() was never reached (a throw from mkdirs()/new FileOutputStream), which is UB and on bionic can release a lock the JS thread holds inside ReadFileText. Fix direction: make the write atomic — temp file plus same-directory ATOMIC_MOVE — instead of locking readers. rename(2) within a directory is atomic, so a reader gets the whole old or the whole new file; that also closes the delete/truncate window no reader-side lock can reach, and drops a mutex from every debug module read. m_fileWriteMutex and Runtime::Lock/Unlock then delete cleanly, keeping com.tns.Runtime.lock()/unlock() as deprecated no-ops since they are public and external tooling may call them. Implemented in Make LiveSync writes atomic instead of locking the runtime's reads #2022unmerged: LiveSync has no automated coverage in the runtime test app, so it needs a manual hot-sync check (ns run android, edit a file, confirm reload; also a newly-created file and a deleted one, since prepareFile() is gone) before merging.

  • NS_DEBUG is unreachable on a real device (env var); the docs call it "the only way to trace boot". A debug.nativescript.* system property (pattern already used in Runtime::GetAndroidVersion) would make it real.

  • Remaining K14 leftovers: three resolver/import empty-return-without-exception windows in teardown/OOM paths; catch (std::exception e) by value in ModuleInternal.cpp; IsolateData::RUNTIME is never cleared (the TryGetRuntime guards are defense-in-depth only and one teardown comment overstates the invariant).

  • NS_TIMERS_NESTING_CLAMP is documented opt-in and defined by no build file — confirm that's intended (the HTML 4ms nesting clamp is currently off everywhere, including the test suite).

  • Verify @nativescript/core's setTimeout rides __ns__setTimeout: the pumping-require timer story assumes it (the test app's own polyfill is a Java Handler post that no pump can dispatch, by construction). Confirm against core before advertising the behavior.

  • Dev-server smoke test against @nativescript/vite (the same gate as iOS): validates the canonicalization change, the vocabulary purge, the MIME gate, and the ESM-entry routing — and, if the sync-fetch anomaly guard stays cold, authorizes deleting it.

  • Accepted-and-documented, revisit if it bites: the boot backstop blocks the launching thread (up to 120s, dev-time HTTP entries only) by explicit decision — runtime.run() returning entry-settled is load-bearing for NativeScriptActivity; require.resolve/require.cache/require.main remain absent (feature-detectable).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions