From 3d60dfa6dd5b1b4fd8ff4da87c5324da31a9ea30 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 12 Nov 2026 13:07:49 -0300 Subject: [PATCH 1/2] fix(DexFactory): collect before registering a proxy with the app class loader ART's mark-compact GC can lose the store of DexCache::location_ when a collection overlaps ClassLinker::RegisterDexFile. RegisterDexFileLocked then dereferences the null location and the app dies with SIGSEGV (fault addr 0x8). Registering into the app's long-lived PathClassLoader, as DexFactory does for main-runtime proxies since #1968, is the case that reproduces it; a fresh DexClassLoader per jar never crashed. A blocking Runtime.gc() right before the injected dex is registered leaves no collection in flight. In a stress harness and a standalone repro (no NativeScript code) it took the crash rate from 22-73% of runs to 0 when the registering thread is the main allocator. It still crashed once when another thread allocated non-stop, so it narrows the window rather than closing it. Cost is one explicit GC (about 4-14 ms on test-suite heaps) on the first use of each runtime-generated proxy. --- .../runtime/src/main/java/com/tns/DexFactory.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test-app/runtime/src/main/java/com/tns/DexFactory.java b/test-app/runtime/src/main/java/com/tns/DexFactory.java index cd71f13b3..3de6d7d6c 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -177,8 +177,17 @@ public Class resolveClass(String baseClassName, String name, String className Class result = null; String classNameToLoad = isInterface ? fullClassName : desiredDexClassName; - if (injectIntoParentClassLoader && classLoader instanceof BaseDexClassLoader - && injectDexIntoClassLoader((BaseDexClassLoader) classLoader, jarFilePath)) { + boolean injectIntoParent = injectIntoParentClassLoader && classLoader instanceof BaseDexClassLoader; + if (injectIntoParent) { + // ART's mark-compact GC can lose a DexCache field store (SIGSEGV in + // ClassLinker::RegisterDexFileLocked) when a collection overlaps + // registering a dex with this long-lived loader. + // The dex is registered by the first loadClass below, so the blocking + // collection has to stay immediately before it. + java.lang.Runtime.getRuntime().gc(); + } + + if (injectIntoParent && injectDexIntoClassLoader((BaseDexClassLoader) classLoader, jarFilePath)) { try { result = classLoader.loadClass(classNameToLoad); } catch (ClassNotFoundException e) { From 1fe902beeddf582189a5476fa28e49041673b291 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 12 Nov 2026 13:57:14 -0300 Subject: [PATCH 2/2] fix(DexFactory): only collect when the collector may be mark-compact The collection before registering an injected proxy only matters under ART's concurrent mark-compact collector. ProxyRegistrationGc skips it when the process's heap mappings positively identify the concurrent-copying collector ("concurrent copying" / "(region space)" names, no mark-compact names) and below API 31, where ART can't have been updated to a version with mark-compact. Anything it can't identify, including renamed mappings in future ART builds or kernels without anonymous VMA names, still collects. The decision is made once per process, on the first injection. On the API 33 emulator (concurrent copying) the suite's explicit GCs drop from 134 to the tests' own 20; on API 36 (mark-compact) they stay at 134. --- test-app/app/src/main/assets/app/mainpage.js | 1 + .../app/tests/testProxyRegistrationGc.js | 82 +++++++++++++++++++ .../src/main/java/com/tns/DexFactory.java | 5 +- .../java/com/tns/ProxyRegistrationGc.java | 77 +++++++++++++++++ 4 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 test-app/app/src/main/assets/app/tests/testProxyRegistrationGc.js create mode 100644 test-app/runtime/src/main/java/com/tns/ProxyRegistrationGc.java diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index 7f96d0a8c..2349e5cc8 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -71,6 +71,7 @@ require("./tests/testTypedArrayArgs"); require("./tests/dex-interface-implementation"); require("./tests/testClassForNameDiscovery"); require("./tests/testPrebuiltExtendClasses"); +require("./tests/testProxyRegistrationGc"); require("./tests/testInterfaceImplementation"); require("./tests/testPrimitiveCallbackReturns"); require("./tests/testRuntimeImplementedAPIs"); diff --git a/test-app/app/src/main/assets/app/tests/testProxyRegistrationGc.js b/test-app/app/src/main/assets/app/tests/testProxyRegistrationGc.js new file mode 100644 index 000000000..3fcab8ef7 --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testProxyRegistrationGc.js @@ -0,0 +1,82 @@ +describe("Proxy registration GC guard", function () { + var Guard = com.tns.ProxyRegistrationGc; + + function maps(names) { + var text = names.map(function (name, i) { + return "7f0000" + i + "000-7f0000" + i + "fff rw-p 00000000 00:00 0 " + name; + }).join("\n"); + return new java.io.BufferedReader(new java.io.StringReader(text)); + } + + var markCompact = [ + "[anon:dalvik-main space]", + "[anon:dalvik-Concurrent mark-compact compaction buffers]", + "[anon:dalvik-Concurrent Mark Compact live words bitmap]", + "/apex/com.android.art/lib64/libart.so" + ]; + var concurrentCopying = [ + "[anon:dalvik-main space (region space)]", + "[anon:dalvik-concurrent copying gc mark stack]", + "[anon:dalvik-region space live bitmap]", + "/apex/com.android.art/lib64/libart.so" + ]; + + it("collects under the mark-compact collector", function () { + expect(Guard.isRequired(36, maps(markCompact))).toBe(true); + }); + + it("skips the collection under the concurrent-copying collector", function () { + expect(Guard.isRequired(33, maps(concurrentCopying))).toBe(false); + }); + + it("never collects before ART became updatable", function () { + expect(Guard.isRequired(30, maps(markCompact))).toBe(false); + expect(Guard.isRequired(21, maps([]))).toBe(false); + }); + + it("collects when the heap mappings are unnamed", function () { + expect(Guard.isRequired(33, maps(["/apex/com.android.art/lib64/libart.so", "[stack]"]))).toBe(true); + }); + + it("collects when no known collector is recognized", function () { + expect(Guard.isRequired(36, maps(["[anon:dalvik-main space]", "[anon:dalvik-card table]"]))).toBe(true); + }); + + it("collects when mark-compact and region space names both appear", function () { + expect(Guard.isRequired(36, maps(concurrentCopying.concat(markCompact)))).toBe(true); + }); + + it("matches the collector this process is running", function () { + var reader = new java.io.BufferedReader(new java.io.FileReader("/proc/self/maps")); + var sawMarkCompact = false; + var sawConcurrentCopying = false; + try { + var line; + while ((line = reader.readLine()) !== null) { + var lower = String(line).toLowerCase(); + if (lower.indexOf("[anon:dalvik-") < 0) { + continue; + } + if (lower.indexOf("mark-compact") >= 0 || lower.indexOf("mark compact") >= 0) { + sawMarkCompact = true; + } + if (lower.indexOf("concurrent copying") >= 0 || lower.indexOf("(region space)") >= 0) { + sawConcurrentCopying = true; + } + } + } finally { + reader.close(); + } + + var sdk = android.os.Build.VERSION.SDK_INT; + if (sdk < 31) { + expect(Guard.isRequired()).toBe(false); + } else if (sawMarkCompact) { + expect(Guard.isRequired()).toBe(true); + } else if (sawConcurrentCopying) { + expect(Guard.isRequired()).toBe(false); + } else { + expect(Guard.isRequired()).toBe(true); + } + }); +}); diff --git a/test-app/runtime/src/main/java/com/tns/DexFactory.java b/test-app/runtime/src/main/java/com/tns/DexFactory.java index 3de6d7d6c..d913a023b 100644 --- a/test-app/runtime/src/main/java/com/tns/DexFactory.java +++ b/test-app/runtime/src/main/java/com/tns/DexFactory.java @@ -178,10 +178,7 @@ public Class resolveClass(String baseClassName, String name, String className String classNameToLoad = isInterface ? fullClassName : desiredDexClassName; boolean injectIntoParent = injectIntoParentClassLoader && classLoader instanceof BaseDexClassLoader; - if (injectIntoParent) { - // ART's mark-compact GC can lose a DexCache field store (SIGSEGV in - // ClassLinker::RegisterDexFileLocked) when a collection overlaps - // registering a dex with this long-lived loader. + if (injectIntoParent && ProxyRegistrationGc.isRequired()) { // The dex is registered by the first loadClass below, so the blocking // collection has to stay immediately before it. java.lang.Runtime.getRuntime().gc(); diff --git a/test-app/runtime/src/main/java/com/tns/ProxyRegistrationGc.java b/test-app/runtime/src/main/java/com/tns/ProxyRegistrationGc.java new file mode 100644 index 000000000..91a7d7e4e --- /dev/null +++ b/test-app/runtime/src/main/java/com/tns/ProxyRegistrationGc.java @@ -0,0 +1,77 @@ +package com.tns; + +import android.os.Build; + +import java.io.BufferedReader; +import java.io.FileReader; +import java.io.IOException; +import java.util.Locale; + +/** + * Whether DexFactory has to run a blocking collection before it registers a + * generated dex with the app's long-lived class loader. + * + * ART's concurrent mark-compact collector can lose the DexCache location store + * when a collection overlaps ClassLinker::RegisterDexFile, and the app then dies + * with a SIGSEGV in RegisterDexFileLocked. That collector only reaches devices + * through the ART module, which is updatable from API 31. + * + * The collection is skipped only when the process's heap mappings positively + * identify the concurrent-copying collector. A collector that can't be + * identified, including future ART builds with renamed mappings, is treated as + * affected. + */ +public final class ProxyRegistrationGc { + private static final int FIRST_UPDATABLE_ART_SDK = 31; + + private static volatile Boolean required; + + private ProxyRegistrationGc() { + } + + public static boolean isRequired() { + Boolean result = required; + if (result == null) { + result = detect(); + required = result; + } + return result; + } + + private static boolean detect() { + if (Build.VERSION.SDK_INT < FIRST_UPDATABLE_ART_SDK) { + return false; + } + try (BufferedReader maps = new BufferedReader(new FileReader("/proc/self/maps"))) { + return isRequired(Build.VERSION.SDK_INT, maps); + } catch (IOException | RuntimeException e) { + return true; + } + } + + /** + * @param maps the contents of /proc/self/maps; ART names its heap mappings + * "[anon:dalvik-...]" on kernels that support anonymous VMA names + */ + public static boolean isRequired(int sdkInt, BufferedReader maps) throws IOException { + if (sdkInt < FIRST_UPDATABLE_ART_SDK) { + return false; + } + boolean concurrentCopying = false; + String line; + while ((line = maps.readLine()) != null) { + int nameStart = line.indexOf("[anon:dalvik-"); + if (nameStart < 0) { + continue; + } + String name = line.substring(nameStart).toLowerCase(Locale.ROOT); + if (name.contains("mark-compact") || name.contains("mark compact")) { + return true; + } + if (name.contains("concurrent copying") || name.contains("(region space)")) { + concurrentCopying = true; + } + } + return !concurrentCopying; + } +}