From f3a1a814d54b3d2df7b55ae37af7874d8401ea6c Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 7 Jun 2026 15:46:56 -0700 Subject: [PATCH 1/3] feat: reloadApplication for JS bundle restart without restarting app process Helpful for programmatic reset of JS isolate for clean restart of JS application as well as OTA (over-the-air) updates without restarting the entire app process. --- .../java/com/tns/NativeScriptRuntime.java | 14 +++++ .../src/main/java/com/tns/RuntimeHelper.java | 53 ++++++++++++++++++- test-app/runtime/src/main/cpp/Runtime.cpp | 9 ++++ .../runtime/src/main/cpp/com_tns_Runtime.cpp | 30 ++++++++++- .../src/main/java/com/tns/Runtime.java | 22 ++++++++ 5 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 test-app/app/src/main/java/com/tns/NativeScriptRuntime.java diff --git a/test-app/app/src/main/java/com/tns/NativeScriptRuntime.java b/test-app/app/src/main/java/com/tns/NativeScriptRuntime.java new file mode 100644 index 000000000..e8eefefe2 --- /dev/null +++ b/test-app/app/src/main/java/com/tns/NativeScriptRuntime.java @@ -0,0 +1,14 @@ +package com.tns; + +public final class NativeScriptRuntime { + private NativeScriptRuntime() { + } + + public static boolean reloadApplication() { + return RuntimeHelper.reloadApplication(); + } + + public static boolean reloadApplication(String baseDir) { + return RuntimeHelper.reloadApplication(); + } +} diff --git a/test-app/app/src/main/java/com/tns/RuntimeHelper.java b/test-app/app/src/main/java/com/tns/RuntimeHelper.java index fa1542966..c6ead3f6b 100644 --- a/test-app/app/src/main/java/com/tns/RuntimeHelper.java +++ b/test-app/app/src/main/java/com/tns/RuntimeHelper.java @@ -8,6 +8,8 @@ import android.content.SharedPreferences; import android.content.pm.PackageManager.NameNotFoundException; import android.os.Build; +import android.os.Handler; +import android.os.Looper; import android.preference.PreferenceManager; import android.util.Log; @@ -24,6 +26,9 @@ private RuntimeHelper() { } private static AndroidJsV8Inspector v8Inspector; + private static Context applicationContext; + private static BroadcastReceiver timezoneChangedReceiver; + private static boolean reloadScheduled; // hasErrorIntent tells you if there was an event (with an uncaught // exception) raised from ErrorReport @@ -59,6 +64,9 @@ private static boolean hasErrorIntent(Context context) { } public static Runtime initRuntime(Context context) { + Context appContext = context.getApplicationContext(); + applicationContext = appContext != null ? appContext : context; + if (Runtime.isInitialized()) { return Runtime.getCurrentRuntime(); } @@ -237,6 +245,40 @@ public static Runtime initRuntime(Context context) { } } + public static synchronized boolean reloadApplication() { + final Context context = applicationContext; + if (context == null || reloadScheduled) { + return false; + } + + reloadScheduled = true; + + new Handler(Looper.getMainLooper()).post(new Runnable() { + @Override + public void run() { + try { + Runtime.destroyMainRuntime(); + + Runtime runtime = initRuntime(context); + if (runtime == null) { + throw new IllegalStateException("NativeScript runtime reload failed to initialize a new runtime."); + } + + runtime.run(); + } catch (Throwable e) { + Log.e(logTag, "NativeScript runtime reload failed.", e); + throw new RuntimeException("NativeScript runtime reload failed.", e); + } finally { + synchronized (RuntimeHelper.class) { + reloadScheduled = false; + } + } + } + }); + + return true; + } + private static void waitForLiveSync(Context context) { boolean needToWait = false; @@ -295,7 +337,16 @@ public void onReceive(Context context, Intent intent) { } }; - context.registerReceiver(timezoneReceiver, timezoneFilter); + if (timezoneChangedReceiver != null) { + try { + context.unregisterReceiver(timezoneChangedReceiver); + } catch (IllegalArgumentException e) { + // Already unregistered. + } + } + + timezoneChangedReceiver = timezoneReceiver; + context.registerReceiver(timezoneChangedReceiver, timezoneFilter); } public static void initLiveSync(Application app) { diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index cd2db0a81..734e827df 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -998,6 +998,15 @@ void Runtime::DestroyRuntime() { if (m_state != nullptr) { m_state->Clear(); } + + // reloadApplication destroys the main isolate and creates a replacement. + // PrepareV8Runtime uses this flag to decide main vs worker shape (global + // `self`, metadata build, s_mainEventLoop). Leave it set and the next + // main isolate is prepared as a worker against a shutdown loop. + if (m_isMainThread) { + s_mainEventLoop.reset(); + s_mainThreadInitialized.store(false, std::memory_order_release); + } } Local Runtime::GetContext() { diff --git a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp index 71800c0d9..5d696959b 100644 --- a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp +++ b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp @@ -2,6 +2,7 @@ #include "Runtime.h" #include "NativeScriptException.h" #include "CallbackHandlers.h" +#include "NativeScriptPlatform.h" #include #include @@ -353,6 +354,33 @@ extern "C" JNIEXPORT jint Java_com_tns_Runtime_getCurrentRuntimeIdLegacy(JNIEnv* return getCurrentRuntimeIdCritical_impl(); } +extern "C" JNIEXPORT void Java_com_tns_Runtime_TerminateRuntimeCallback(JNIEnv* env, jobject obj, jint runtimeId) { + auto runtime = TryGetRuntime(runtimeId); + if (runtime == nullptr) { + // TODO: Pete: Log message informing the developer of the failure + return; + } + + auto isolate = runtime->GetIsolate(); + auto eventLoop = runtime->GetEventLoop(); + + { + v8::Locker locker(isolate); + v8::Isolate::Scope isolate_scope(isolate); + v8::HandleScope handleScope(isolate); + + runtime->DestroyRuntime(); + } + + isolate->Dispose(); + // Dispose freed the isolate's memory, so its address can be reused by + // a concurrent Isolate::New - drop the platform's loop entry now, not + // in ~Runtime (which still runs JNI calls first). + NativeScriptPlatform::Instance()->IsolateDisposed(isolate, eventLoop); + + delete runtime; +} + extern "C" JNIEXPORT void Java_com_tns_Runtime_ResetDateTimeConfigurationCache(JNIEnv* _env, jobject obj, jint runtimeId) { auto runtime = TryGetRuntime(runtimeId); if (runtime == nullptr) { @@ -361,4 +389,4 @@ extern "C" JNIEXPORT void Java_com_tns_Runtime_ResetDateTimeConfigurationCache(J auto isolate = runtime->GetIsolate(); isolate->DateTimeConfigurationChangeNotification(Isolate::TimeZoneDetection::kRedetect); -} \ No newline at end of file +} diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 4a02c22c4..588fdf66c 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -105,6 +105,8 @@ public static void SetManualInstrumentationMode(String mode) { } } + private static native void TerminateRuntimeCallback(int runtimeId); + private static native void ResetDateTimeConfigurationCache(int runtimeId); /** @@ -467,6 +469,26 @@ public static boolean isInitialized() { return (runtime != null) ? runtime.isInitializedImpl() : false; } + static void destroyMainRuntime() { + Runtime runtime = Runtime.getCurrentRuntime(); + if (runtime == null) { + return; + } + + if (runtime.workerId != 0) { + throw new NativeScriptException("Only the main NativeScript runtime can be destroyed with destroyMainRuntime()."); + } + + GcListener.unsubscribe(runtime); + runtimeCache.remove(runtime.runtimeId); + currentRuntime.remove(); + if (mainRuntime == runtime) { + mainRuntime = null; + } + + TerminateRuntimeCallback(runtime.runtimeId); + } + public int getWorkerId() { return workerId; } From ad9b3d9fb5116491392d4f73159de480e5bc00d6 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 25 Jun 2026 13:12:18 -0700 Subject: [PATCH 2/3] chore: pr comments --- .../main/java/com/tns/NativeScriptRuntime.java | 2 +- .../app/src/main/java/com/tns/RuntimeHelper.java | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/test-app/app/src/main/java/com/tns/NativeScriptRuntime.java b/test-app/app/src/main/java/com/tns/NativeScriptRuntime.java index e8eefefe2..be805e72b 100644 --- a/test-app/app/src/main/java/com/tns/NativeScriptRuntime.java +++ b/test-app/app/src/main/java/com/tns/NativeScriptRuntime.java @@ -9,6 +9,6 @@ public static boolean reloadApplication() { } public static boolean reloadApplication(String baseDir) { - return RuntimeHelper.reloadApplication(); + return RuntimeHelper.reloadApplication(baseDir); } } diff --git a/test-app/app/src/main/java/com/tns/RuntimeHelper.java b/test-app/app/src/main/java/com/tns/RuntimeHelper.java index c6ead3f6b..cb82c774c 100644 --- a/test-app/app/src/main/java/com/tns/RuntimeHelper.java +++ b/test-app/app/src/main/java/com/tns/RuntimeHelper.java @@ -245,6 +245,11 @@ public static Runtime initRuntime(Context context) { } } + // The overload is kept for API parity with ios, but not needed with android. + public static synchronized boolean reloadApplication(String baseDir) { + return reloadApplication(); + } + public static synchronized boolean reloadApplication() { final Context context = applicationContext; if (context == null || reloadScheduled) { @@ -307,6 +312,12 @@ private static void waitForLiveSync(Context context) { } private static void registerTimezoneChangedListener(Context context, final Runtime runtime) { + // Register/unregister against the application context so the same Context + // instance is used across initial launch and reload. Using the passed-in + // context (which may be an Activity on first launch but applicationContext + // on reload) would make unregisterReceiver fail and leak the old receiver. + final Context receiverContext = applicationContext != null ? applicationContext : context; + IntentFilter timezoneFilter = new IntentFilter(Intent.ACTION_TIMEZONE_CHANGED); BroadcastReceiver timezoneReceiver = new BroadcastReceiver() { @@ -339,14 +350,14 @@ public void onReceive(Context context, Intent intent) { if (timezoneChangedReceiver != null) { try { - context.unregisterReceiver(timezoneChangedReceiver); + receiverContext.unregisterReceiver(timezoneChangedReceiver); } catch (IllegalArgumentException e) { // Already unregistered. } } timezoneChangedReceiver = timezoneReceiver; - context.registerReceiver(timezoneChangedReceiver, timezoneFilter); + receiverContext.registerReceiver(timezoneChangedReceiver, timezoneFilter); } public static void initLiveSync(Application app) { From 2bb05a858baf8bc74c37de24e3c6b8ca107d9bca Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 25 Jun 2026 13:34:39 -0700 Subject: [PATCH 3/3] chore: main merge --- test-app/runtime/src/main/cpp/com_tns_Runtime.cpp | 6 ++++++ test-app/runtime/src/main/java/com/tns/Runtime.java | 3 +++ 2 files changed, 9 insertions(+) diff --git a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp index 5d696959b..6355d77d1 100644 --- a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp +++ b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp @@ -3,6 +3,7 @@ #include "NativeScriptException.h" #include "CallbackHandlers.h" #include "NativeScriptPlatform.h" +#include "WorkerWrapper.h" #include #include @@ -364,6 +365,11 @@ extern "C" JNIEXPORT void Java_com_tns_Runtime_TerminateRuntimeCallback(JNIEnv* auto isolate = runtime->GetIsolate(); auto eventLoop = runtime->GetEventLoop(); + // Terminate this runtime's child workers before disposing the isolate. Their + // Worker object persistents live in this isolate, so they must be released + // first - mirrors WorkerWrapper::BackgroundLooper's nested-worker teardown. + WorkerWrapper::TerminateChildren(isolate); + { v8::Locker locker(isolate); v8::Isolate::Scope isolate_scope(isolate); diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index 588fdf66c..15686330a 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -486,6 +486,9 @@ static void destroyMainRuntime() { mainRuntime = null; } + // Worker teardown happens natively in TerminateRuntimeCallback, which + // terminates this runtime's child workers (WorkerWrapper registry) before + // destroying the main isolate. TerminateRuntimeCallback(runtime.runtimeId); }