From e0985e1ed988b7eb9cac1439c89a6012afed2143 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 25 Oct 2026 13:32:39 -0700 Subject: [PATCH] fix(android): deliver GC notifications under a per-runtime lock A GC batch could reach a worker runtime while it was being disposed: the GC thread took the runtime from the subscriber map, the worker thread unsubscribed and disposed its native Runtime, and the GC thread then entered notifyGc for it and crashed (SIGSEGV on the GC thread). Each subscriber now carries its own lock. A batch is delivered while holding that lock, and only if the subscriber is still subscribed; unsubscribe() removes the subscriber, then takes its lock to mark it unsubscribed. Once unsubscribe() returns, no GC thread is inside that runtime's notifyGc and none will enter it. The memory monitor's notifyGc() takes the same per-runtime lock instead of the listener-wide one, and unsubscribe() no longer takes the listener-wide lock, so subscribing or unsubscribing one runtime never waits on a GC delivery into another. --- .../src/main/java/com/tns/GcListener.java | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/platforms/android/test-app/runtime/src/main/java/com/tns/GcListener.java b/platforms/android/test-app/runtime/src/main/java/com/tns/GcListener.java index 0d5a69892..8dc8e7c14 100644 --- a/platforms/android/test-app/runtime/src/main/java/com/tns/GcListener.java +++ b/platforms/android/test-app/runtime/src/main/java/com/tns/GcListener.java @@ -5,11 +5,13 @@ import java.lang.ref.PhantomReference; import java.lang.ref.ReferenceQueue; import java.util.ArrayList; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; class GCSubscriber { ConcurrentHashMap, Integer> referencesMap = new ConcurrentHashMap<>(); public final ReferenceQueue referenceQueue = new ReferenceQueue<>(); + boolean unsubscribed; // guarded by this } public class GcListener { @@ -60,7 +62,11 @@ public void run() { for (int i =0; i < objIds.length; i++) { objIds[i] = collectedObjectIds.get(i); } - runtime.notifyGc(objIds); + synchronized (subscriber) { + if (!subscriber.unsubscribed) { + runtime.notifyGc(objIds); + } + } if (runtime.getLogger().isEnabled()) { runtime.getLogger().write("GC", "GC triggered for " + String.valueOf(objIds.length) + " objects"); runtime.getLogger().write("NS.GCListener","Objects referenced after GC: " + subscriber.referencesMap.size()); @@ -152,9 +158,17 @@ public static void subscribe(Runtime runtime) { } } + /** + * Once this returns, no GC thread is inside the runtime's notifyGc and none + * will enter it. Must not be called from inside that runtime's JS scope: + * delivery holds the subscriber's lock while it waits to enter the runtime. + */ public static void unsubscribe(Runtime runtime) { - synchronized (instance) { - instance.subscribers.remove(runtime); + GCSubscriber subscriber = instance.subscribers.remove(runtime); + if (subscriber != null) { + synchronized (subscriber) { + subscriber.unsubscribed = true; + } } } @@ -168,15 +182,18 @@ private void start() { } private void notifyGc() { - synchronized (instance) { -// ManualInstrumentation.Frame frame = ManualInstrumentation.start("GcListener.notifyGc"); - try { - for (Runtime runtime : instance.subscribers.keySet()) { - runtime.notifyGc(); +// ManualInstrumentation.Frame frame = ManualInstrumentation.start("GcListener.notifyGc"); + try { + for (Map.Entry entry : subscribers.entrySet()) { + GCSubscriber subscriber = entry.getValue(); + synchronized (subscriber) { + if (!subscriber.unsubscribed) { + entry.getKey().notifyGc(); + } } - } finally { -// frame.close(); } + } finally { +// frame.close(); } } }