Skip to content

Commit ada922f

Browse files
committed
refactor(runtime): key module registries by v8::Isolate, not thread_local
The module maps (registry / fallback / fallbackByRelative / vendor) were thread_local, which is only correct because each isolate is currently pinned to a single thread. Key them by v8::Isolate* instead so they follow the owning isolate even if it's ever entered from another thread under v8::Locker per @edusperoni feedback. Per-isolate state now lives in a mutex-guarded side table and is torn down from ~Runtime via DestroyModuleStateForIsolate() while the isolate is still alive. This retires the leaky-pointer hack and also frees worker isolates' fallback/vendor maps, which were previously leaked (only the main isolate's were cleared via CleanupImportMapGlobals). Behavior-preserving: each call site binds a same-named local alias (auto& g_moduleRegistry = ModuleRegistryFor(isolate)), so resolver/loader bodies are unchanged. The two standalone helpers (RemoveModuleFromRegistry, GetLoadedModuleUrls) resolve the current isolate internally.
1 parent 70dc3e6 commit ada922f

4 files changed

Lines changed: 173 additions & 101 deletions

File tree

NativeScript/runtime/ModuleInternal.mm

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,7 @@ ScriptOrigin origin(isolate, urlString,
977977
std::string canonicalPath = CanonicalizeModulePath(path);
978978
std::string requestPath = isHttpModule ? NormalizeHttpModuleUrl(path) : canonicalPath;
979979
auto context = isolate->GetCurrentContext();
980+
auto& g_moduleRegistry = ModuleRegistryFor(isolate);
980981

981982
auto describeModuleStatus = [](Module::Status status) -> const char* {
982983
switch (status) {

NativeScript/runtime/ModuleInternalCallbacks.h

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,22 @@
88

99
namespace tns {
1010

11-
// Export our registry so both LoadESModule and the callback see the same data.
12-
// `thread_local`: each NS isolate (main thread + each Worker thread) gets its
13-
// own per-thread map, because v8::Global<Module> handles are isolate-bound.
14-
// See the long-form comment above the definition in ModuleInternalCallbacks.mm
15-
// for the cross-isolate-handle bug this prevents.
16-
extern thread_local std::unordered_map<std::string, v8::Global<v8::Module>>& g_moduleRegistry;
17-
18-
// Utility to drop modules from the registry when compilation/instantiation fails
11+
// Per-isolate module registry accessor: map canonical keys → compiled
12+
// v8::Module handles for `isolate`. Keyed by v8::Isolate* (not thread) because
13+
// v8::Global<Module> handles are isolate-bound; see the long-form comment above
14+
// the definition in ModuleInternalCallbacks.mm for the cross-isolate-handle bug
15+
// this prevents. Callers bind a local alias, e.g.
16+
// `auto& g_moduleRegistry = tns::ModuleRegistryFor(isolate);`.
17+
std::unordered_map<std::string, v8::Global<v8::Module>>& ModuleRegistryFor(
18+
v8::Isolate* isolate);
19+
20+
// Reset + drop every module handle owned by `isolate`. Must be called while the
21+
// isolate is still alive (the Runtime destructor calls this before disposal).
22+
void DestroyModuleStateForIsolate(v8::Isolate* isolate);
23+
24+
// Utility to drop modules from the registry when compilation/instantiation
25+
// fails. Operates on the *current* isolate's maps (resolved internally); only
26+
// ever called on the isolate's own JS thread during module resolution/loading.
1927
void RemoveModuleFromRegistry(const std::string& canonicalPath);
2028

2129
// Authoritative HTTP URL loader for dev-served ESM. This compiles and registers

NativeScript/runtime/ModuleInternalCallbacks.mm

Lines changed: 133 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
#include <algorithm>
88
#include <cstddef>
99
#include <cstdio>
10+
#include <memory>
11+
#include <mutex>
1012
#include <queue>
1113
#include <string>
1214
#include <unordered_map>
@@ -142,6 +144,7 @@ static inline bool StartsWith(const std::string& s, const char* prefix) {
142144
const std::string& code,
143145
const std::string& urlStr) {
144146
v8::EscapableHandleScope hs(isolate);
147+
auto& g_moduleRegistry = ModuleRegistryFor(isolate);
145148
const std::string registryKey = CanonicalizeRegistryKey(urlStr);
146149
if (IsScriptLoadingLogEnabled() && ShouldTraceRegistryKey(urlStr, registryKey)) {
147150
Log(@"[resolver][register-resolve-only] raw=%s key=%s", urlStr.c_str(),
@@ -207,62 +210,112 @@ static inline bool StartsWith(const std::string& s, const char* prefix) {
207210
return hs.Escape(mod);
208211
}
209212

213+
namespace {
214+
using ModuleHandleMap = std::unordered_map<std::string, v8::Global<v8::Module>>;
215+
216+
// The four module-handle maps for a single isolate. v8::Global<Module> handles
217+
// are bound to the isolate that created them, so each isolate (main + every
218+
// Worker) keeps its own set; they are looked up by v8::Isolate* below.
219+
struct PerIsolateModuleState {
220+
ModuleHandleMap registry; // canonical key -> compiled module
221+
ModuleHandleMap fallbackRegistry; // canonical key -> last good module
222+
ModuleHandleMap fallbackByRelative; // relative path -> last good module
223+
ModuleHandleMap vendorCache; // vendor id -> synthetic module
224+
};
225+
226+
std::mutex& ModuleStateTableMutex() {
227+
// Leaked-on-purpose process singletons (never destructed) so we never run a
228+
// static destructor that would touch v8 handles after isolate disposal.
229+
static std::mutex* mutex = new std::mutex();
230+
return *mutex;
231+
}
232+
233+
std::unordered_map<v8::Isolate*, std::unique_ptr<PerIsolateModuleState>>&
234+
ModuleStateTable() {
235+
static auto* table =
236+
new std::unordered_map<v8::Isolate*, std::unique_ptr<PerIsolateModuleState>>();
237+
return *table;
238+
}
239+
240+
// Fetch (creating on first use) the module maps owned by `isolate`. Keyed by
241+
// the isolate itself rather than the calling thread, so the maps follow the
242+
// isolate even if it is ever entered from another thread under v8::Locker.
243+
PerIsolateModuleState& ModuleStateFor(v8::Isolate* isolate) {
244+
std::lock_guard<std::mutex> lock(ModuleStateTableMutex());
245+
auto& table = ModuleStateTable();
246+
auto it = table.find(isolate);
247+
if (it == table.end()) {
248+
it = table.emplace(isolate, std::make_unique<PerIsolateModuleState>()).first;
249+
}
250+
return *it->second;
251+
}
252+
} // namespace
253+
210254
// ────────────────────────────────────────────────────────────────────────────
211-
// Per-isolate (thread-local) module registries: map absolute file paths /
212-
// canonical URLs → compiled v8::Module handles for the *current* isolate.
255+
// Per-isolate module registries: map absolute file paths / canonical URLs →
256+
// compiled v8::Module handles, keyed by the owning v8::Isolate*.
213257
//
214-
// Why thread_local: NS Worker creates a separate v8::Isolate on its own
215-
// thread (see Worker::ConstructorCallback in Worker.mm). v8::Global<T>
216-
// handles are bound to the isolate that created them; reading their
217-
// internal state from a different isolate is undefined behaviour. A
218-
// previous design held these registries as a single, process-global map,
219-
// which under HMR (where the worker fetches the SAME `/ns/m/` URLs the
220-
// main thread already loaded) caused the worker isolate to receive a
221-
// Module compiled by the main isolate. V8's linker then read the cross-
222-
// isolate Module's export table and emitted bogus errors like:
258+
// Why per-isolate (not process-global, not thread_local): v8::Global<T> handles
259+
// are bound to the isolate that created them; reading their internal state from
260+
// a different isolate is undefined behaviour. NS Workers each run a separate
261+
// v8::Isolate on their own thread (see Worker::ConstructorCallback in
262+
// Worker.mm). A previous design held these as a single process-global map,
263+
// which under HMR (where a worker fetches the SAME `/ns/m/` URLs the main
264+
// thread already loaded) handed the worker isolate a Module the main isolate
265+
// compiled; V8's linker then read the cross-isolate export table and emitted
266+
// bogus errors like:
223267
// SyntaxError: The requested module 'X' does not provide an export named 'Y'
224-
// even though the served source clearly declared `Y`. Making the
225-
// registry thread_local keeps each NS runtime/worker walking its own
226-
// fresh, valid handle graph.
268+
// A later design made the maps thread_local, which fixed the symptom only
269+
// because each isolate happens to be pinned to one thread today. Keying
270+
// explicitly by v8::Isolate* drops that thread==isolate assumption.
227271
//
228-
// Why the leaky-pointer pattern (heap-allocated, never deleted by the
229-
// thread-exit destructor): a `thread_local std::unordered_map<...,
230-
// v8::Global<...>>` would, on thread exit, run the map's destructor —
231-
// which iterates and calls v8::Global::Reset() on each handle. If the
232-
// owning isolate was already torn down, those Resets blow up with the
233-
// `__cxa_finalize_ranges` SIGSEGV/SIGBUS that the original comment
234-
// warned about. By holding a thread_local *pointer* to a heap-allocated
235-
// map, the variable's per-thread destructor is a no-op (it just drops
236-
// a pointer); cleanup of the actual handles is handled explicitly by
237-
// the Runtime destructor (Runtime.mm) and CleanupImportMapGlobals()
238-
// below, which run *before* the isolate is disposed.
272+
// Lifetime: the per-isolate state is created lazily on first access and torn
273+
// down by DestroyModuleStateForIsolate(), which the Runtime destructor
274+
// (Runtime.mm) calls while the isolate is still alive (under v8::Locker) and
275+
// before disposal — so every v8::Global is Reset() at a safe time. That is what
276+
// retires the old leaky-pointer hack (a thread_local *pointer* whose per-thread
277+
// destructor was a deliberate no-op to dodge __cxa_finalize_ranges crashes).
239278
//
240-
// The reference aliases below keep all existing access sites unchanged
241-
// (no `()` or `->` rewrites needed across ~100+ call sites). On each
242-
// thread's first use of e.g. `g_moduleRegistry`, the initializer below
243-
// runs once per thread to bind the reference to that thread's map.
244-
namespace {
245-
using ModuleHandleMap = std::unordered_map<std::string, v8::Global<v8::Module>>;
279+
// Each access site binds a local reference (e.g.
280+
// `auto& g_moduleRegistry = ModuleRegistryFor(isolate);`) so existing bodies
281+
// stay byte-for-byte the same while becoming isolate-correct.
282+
std::unordered_map<std::string, v8::Global<v8::Module>>& ModuleRegistryFor(
283+
v8::Isolate* isolate) {
284+
return ModuleStateFor(isolate).registry;
285+
}
246286

247-
ModuleHandleMap& MakePerIsolateModuleRegistry() {
248-
thread_local auto* p = new ModuleHandleMap();
249-
return *p;
287+
static ModuleHandleMap& ModuleFallbackRegistryFor(v8::Isolate* isolate) {
288+
return ModuleStateFor(isolate).fallbackRegistry;
250289
}
251290

252-
ModuleHandleMap& MakePerIsolateModuleFallbackRegistry() {
253-
thread_local auto* p = new ModuleHandleMap();
254-
return *p;
291+
static ModuleHandleMap& ModuleFallbackByRelativeFor(v8::Isolate* isolate) {
292+
return ModuleStateFor(isolate).fallbackByRelative;
255293
}
256294

257-
ModuleHandleMap& MakePerIsolateModuleFallbackByRelative() {
258-
thread_local auto* p = new ModuleHandleMap();
259-
return *p;
295+
static ModuleHandleMap& VendorModuleCacheFor(v8::Isolate* isolate) {
296+
return ModuleStateFor(isolate).vendorCache;
260297
}
261-
} // namespace
262298

263-
thread_local std::unordered_map<std::string, v8::Global<v8::Module>>& g_moduleRegistry = MakePerIsolateModuleRegistry();
264-
static thread_local std::unordered_map<std::string, v8::Global<v8::Module>>& g_moduleFallbackRegistry = MakePerIsolateModuleFallbackRegistry();
265-
static thread_local std::unordered_map<std::string, v8::Global<v8::Module>>& g_moduleFallbackByRelative = MakePerIsolateModuleFallbackByRelative();
299+
// Reset and drop every module handle owned by `isolate`. Must run while the
300+
// isolate is still alive (the Runtime destructor calls this under v8::Locker,
301+
// before isolate disposal).
302+
void DestroyModuleStateForIsolate(v8::Isolate* isolate) {
303+
std::unique_ptr<PerIsolateModuleState> state;
304+
{
305+
std::lock_guard<std::mutex> lock(ModuleStateTableMutex());
306+
auto& table = ModuleStateTable();
307+
auto it = table.find(isolate);
308+
if (it == table.end()) {
309+
return;
310+
}
311+
state = std::move(it->second);
312+
table.erase(it);
313+
}
314+
for (auto& kv : state->registry) kv.second.Reset();
315+
for (auto& kv : state->fallbackRegistry) kv.second.Reset();
316+
for (auto& kv : state->fallbackByRelative) kv.second.Reset();
317+
for (auto& kv : state->vendorCache) kv.second.Reset();
318+
}
266319

267320
// ────────────────────────────────────────────────────────────────────────────
268321
// Import map: bare specifier → resolved URL (populated by __nsConfigureRuntime)
@@ -278,17 +331,10 @@ static inline bool StartsWith(const std::string& s, const char* prefix) {
278331

279332
// Vendor module registry: maps vendor specifier → evaluated v8::Module.
280333
// Populated when ns-vendor:// modules are first resolved via SyntheticModule.
281-
// Per-isolate (thread_local) for the same reason as g_moduleRegistry above —
282-
// vendor SyntheticModules are isolate-bound and reusing one across isolates
283-
// breaks the linker's export-table check.
284-
namespace {
285-
std::unordered_map<std::string, v8::Global<v8::Module>>& MakePerIsolateVendorModuleCache() {
286-
thread_local auto* p = new std::unordered_map<std::string, v8::Global<v8::Module>>();
287-
return *p;
288-
}
289-
} // namespace
290-
291-
static thread_local std::unordered_map<std::string, v8::Global<v8::Module>>& g_vendorModuleCache = MakePerIsolateVendorModuleCache();
334+
// Lives in PerIsolateModuleState (vendorCache) for the same reason as the
335+
// module registry above — vendor SyntheticModules are isolate-bound and reusing
336+
// one across isolates breaks the linker's export-table check. Access via
337+
// VendorModuleCacheFor(isolate).
292338

293339
static bool ShouldTraceRegistryKey(const std::string& rawKey, const std::string& registryKey) {
294340
if (rawKey != registryKey) {
@@ -344,6 +390,7 @@ static bool ShouldTraceRegistryKey(const std::string& rawKey, const std::string&
344390
v8::MaybeLocal<v8::Module> LoadHttpModuleForUrl(v8::Isolate* isolate,
345391
v8::Local<v8::Context> context,
346392
const std::string& requestedUrl) {
393+
auto& g_moduleRegistry = ModuleRegistryFor(isolate);
347394
const std::string registryKey = CanonicalizeHttpUrlKey(requestedUrl);
348395

349396
if (IsScriptLoadingLogEnabled()) {
@@ -705,6 +752,7 @@ static bool IsValidJSIdentifier(const std::string& name) {
705752
static v8::MaybeLocal<v8::Module> ResolveFromVendorRegistry(v8::Isolate* isolate,
706753
v8::Local<v8::Context> context,
707754
const std::string& vendorId) {
755+
auto& g_vendorModuleCache = VendorModuleCacheFor(isolate);
708756
// Check cache first
709757
auto cached = g_vendorModuleCache.find(vendorId);
710758
if (cached != g_vendorModuleCache.end()) {
@@ -814,16 +862,12 @@ static bool IsValidJSIdentifier(const std::string& name) {
814862
}
815863

816864
void CleanupImportMapGlobals() {
865+
// Process-global import-map state (not isolate-bound). The per-isolate module
866+
// handle maps (registry / fallback / fallbackByRelative / vendor) are torn
867+
// down separately by DestroyModuleStateForIsolate(), which ~Runtime calls for
868+
// every isolate before disposal.
817869
g_importMap.clear();
818870
g_volatilePatterns.clear();
819-
for (auto& kv : g_vendorModuleCache) { kv.second.Reset(); }
820-
g_vendorModuleCache.clear();
821-
// Also clear fallback registries — they hold v8::Global<Module> handles that
822-
// would crash during static destructor cleanup if not cleared before isolate disposal.
823-
for (auto& kv : g_moduleFallbackRegistry) { kv.second.Reset(); }
824-
g_moduleFallbackRegistry.clear();
825-
for (auto& kv : g_moduleFallbackByRelative) { kv.second.Reset(); }
826-
g_moduleFallbackByRelative.clear();
827871
}
828872

829873
// g_modulesInFlight is defined later in this translation unit (thread_local static); no extern needed here.
@@ -856,6 +900,15 @@ static void RejectAndClearInvalidatedModuleState(v8::Isolate* isolate,
856900
}
857901

858902
void RemoveModuleFromRegistry(const std::string& canonicalPath) {
903+
// Only ever called on an isolate's own JS thread during module
904+
// resolution/loading, so the entered isolate owns the maps to mutate.
905+
v8::Isolate* isolate = v8::Isolate::GetCurrent();
906+
if (isolate == nullptr) {
907+
return;
908+
}
909+
auto& g_moduleRegistry = ModuleRegistryFor(isolate);
910+
auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate);
911+
auto& g_moduleFallbackByRelative = ModuleFallbackByRelativeFor(isolate);
859912
const std::string registryKey = CanonicalizeRegistryKey(canonicalPath);
860913
// Defensive: never operate on an anomalous/sentinel key.
861914
// This covers the bare "@" anomaly and the special invalid-at stub module used by the dev HTTP loader.
@@ -939,6 +992,13 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) {
939992

940993
std::vector<std::string> GetLoadedModuleUrls() {
941994
std::vector<std::string> urls;
995+
// Read-only registry introspection for the current isolate (HMR runs this on
996+
// the isolate's own JS thread).
997+
v8::Isolate* isolate = v8::Isolate::GetCurrent();
998+
if (isolate == nullptr) {
999+
return urls;
1000+
}
1001+
auto& g_moduleRegistry = ModuleRegistryFor(isolate);
9421002
urls.reserve(g_moduleRegistry.size());
9431003

9441004
for (const auto& entry : g_moduleRegistry) {
@@ -956,6 +1016,7 @@ void RemoveModuleFromRegistry(const std::string& canonicalPath) {
9561016

9571017
void InvalidateModules(v8::Isolate* isolate, v8::Local<v8::Context> context,
9581018
const std::vector<std::string>& urls) {
1019+
auto& g_moduleRegistry = ModuleRegistryFor(isolate);
9591020
if (urls.empty()) return;
9601021

9611022
std::unordered_set<std::string> seen;
@@ -1013,6 +1074,8 @@ void InvalidateModules(v8::Isolate* isolate, v8::Local<v8::Context> context,
10131074

10141075
void UpdateModuleFallback(v8::Isolate* isolate, const std::string& canonicalPath,
10151076
v8::Local<v8::Module> module) {
1077+
auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate);
1078+
auto& g_moduleFallbackByRelative = ModuleFallbackByRelativeFor(isolate);
10161079
auto fallbackIt = g_moduleFallbackRegistry.find(canonicalPath);
10171080
if (fallbackIt != g_moduleFallbackRegistry.end()) {
10181081
fallbackIt->second.Reset();
@@ -1405,6 +1468,8 @@ static bool IsDocumentsPath(const std::string& path) {
14051468

14061469
~ResolutionStackGuard() {
14071470
if (active_ && !stack_.empty()) {
1471+
auto& g_moduleRegistry = ModuleRegistryFor(isolate_);
1472+
auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate_);
14081473
if (IsScriptLoadingLogEnabled()) {
14091474
Log(@"[resolver][stack] pop (%lu) %s",
14101475
static_cast<unsigned long>(stack_.size()), entry_.c_str());
@@ -1532,6 +1597,7 @@ static bool IsDocumentsPath(const std::string& path) {
15321597
v8::Isolate* isolate, v8::Local<v8::Context> context,
15331598
const std::string& absPath, const std::string& registryAbsPath,
15341599
bool isWorker) {
1600+
auto& g_moduleRegistry = ModuleRegistryFor(isolate);
15351601
// Debug: Log JSON module handling for worker context
15361602
if (isWorker) {
15371603
printf("ResolveModuleCallback: Worker handling JSON module '%s'\n", absPath.c_str());
@@ -1611,6 +1677,9 @@ static bool IsDocumentsPath(const std::string& path) {
16111677
v8::Local<v8::FixedArray> import_assertions,
16121678
v8::Local<v8::Module> referrer) {
16131679
v8::Isolate* isolate = context->GetIsolate();
1680+
auto& g_moduleRegistry = ModuleRegistryFor(isolate);
1681+
auto& g_moduleFallbackRegistry = ModuleFallbackRegistryFor(isolate);
1682+
auto& g_moduleFallbackByRelative = ModuleFallbackByRelativeFor(isolate);
16141683

16151684
// 1) Turn the specifier literal into a std::string:
16161685
v8::String::Utf8Value specUtf8(isolate, specifier);
@@ -2543,6 +2612,7 @@ static bool IsDocumentsPath(const std::string& path) {
25432612
v8::Local<v8::Context> context, v8::Local<v8::ScriptOrModule> referrer,
25442613
v8::Local<v8::String> specifier, v8::Local<v8::FixedArray> import_assertions) {
25452614
v8::Isolate* isolate = context->GetIsolate();
2615+
auto& g_moduleRegistry = ModuleRegistryFor(isolate);
25462616
// Diagnostic: log every dynamic import attempt.
25472617
v8::String::Utf8Value specUtf8(isolate, specifier);
25482618
const char* cSpec = (*specUtf8) ? *specUtf8 : "<invalid>";

0 commit comments

Comments
 (0)