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
293339static 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&
344390v8::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) {
705752static 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
816864void 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
858902void 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
940993std::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
9571017void 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
10141075void 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