Summary
On release (non debuggable) builds, the first time the runtime generates a proxy class on the main thread, the app crashes with:
java.lang.InternalError: Attempt to register dex file
/data/user/0/<app>/code_cache/secondary-dexes/<proxy>.jar with multiple class loaders
This was introduced by #1951 (commit c9d41e62, currently on main). Debug builds are not affected, so it passes normal development and only appears once an app ships a release build. The runtime reports version 9.0.4 (the commit sits a few commits past the 9.0.4 tag).
What triggers it
Any JavaScript that implements or extends a native type whose proxy is produced at runtime, on the main thread (worker id 0). For example:
// Main thread. JS implements a native interface, so the runtime must
// generate a Java proxy class for it and load it through DexFactory.
const runnable = new java.lang.Runnable({
run() {
console.log("invoked from Java");
}
});
new android.os.Handler(android.os.Looper.getMainLooper()).post(runnable);
Statically bound proxies are not affected, because resolveClass finds the pre-generated class and returns before reaching the new code path. If the static binding generator covers the example above, force the runtime path (for example by building the implementation dynamically) so that DexFactory.resolveClass actually performs generation. You can confirm the path is taken by logging inside injectDexIntoClassLoader.
Background: the class loader hierarchy
Android resolves classes through a chain of class loaders. Each one delegates to its parent before trying itself:
BootClassLoader framework classes (android.*, java.*)
^ parent
PathClassLoader the app's own classes, from the APK dex files
^ parent
DexClassLoader a dex or jar loaded on demand from an arbitrary path
PathClassLoader is the app's primary loader, created by the system when the process starts. DexClassLoader is created by code at runtime to load a dex or jar from a chosen path. NativeScript uses it to load proxy classes it generates at runtime, with the PathClassLoader as the parent so the proxy can resolve the app and framework types it references.
Two runtime rules are relevant:
- A class's identity is the pair (name, defining class loader). The same bytecode loaded by two loaders produces two distinct types.
- A given dex file may be registered with only one class loader. ART enforces this and throws
InternalError: Attempt to register dex file ... with multiple class loaders when a dex is claimed by a second loader.
Root cause
Before #1951, resolveClass loaded each runtime generated proxy through its own DexClassLoader: one dex, one owner.
#1951 added injectIntoParentClassLoader, enabled for the main thread runtime in Runtime.init. When enabled, resolveClass calls injectDexIntoClassLoader, which:
- creates a temporary
DexClassLoader over the proxy jar, then
- splices the dex element into the app's
PathClassLoader and loads the proxy through the PathClassLoader.
Both the temporary DexClassLoader and the app PathClassLoader now reference the same DexFile object (through the spliced element). Registering one DexFile against a second class loader is what ART rejects: the check is keyed on DexFile identity and is unconditional. ClassLinker::RegisterDexFile throws ThrowDexFileAlreadyRegisteredError with no android:debuggable guard. Whether a second registration actually happens depends on the build type, which is why only release crashes (see below).
Why debug builds are not affected
The single-owner rule is enforced identically in both modes; the throw is not gated on android:debuggable. Both loaders reference one DexFile (the spliced element), so the only build-dependent question is whether the temporary loader also registers it:
- Release: the temporary loader ends up registering the proxy
DexFile (a non-debuggable runtime verifies, and may AOT-compile, the dex in-process). The later load through the app PathClassLoader then registers the same DexFile against a second loader, and ART throws.
- Debug: a debuggable runtime is interpret-only and does not use AOT code (
class_linker.cc: "For debuggable runtimes we don't use AOT code"), and the temporary loader never claims the dex, so it is registered exactly once through the app loader and nothing throws.
The exact ART step that registers the dex against the temporary loader in release is not instrumented here; what is certain is that two loaders share one DexFile and that ClassLinker::RegisterDexFile is identity-keyed and unconditional, so the second registration throws. The release-only crash is consistent with the interpret-only vs verify/AOT split. (The odexDir passed as the DexClassLoader optimizedDirectory has no effect since API 26, so it is not the relevant factor.)
Expected behavior
Runtime generated proxies load without crashing in release, as they did before #1951.
Suggested direction
The goal of #1951 is to make generated proxies discoverable through the app's PathClassLoader (for example for Class.forName). The current implementation opens the dex with a separate loader first, which is what creates the second owner. Two options:
- Register the dex with the
PathClassLoader as its only owner, for example by building the dex element through the PathClassLoader's own DexPathList instead of through a temporary DexClassLoader. The dex is then registered exactly once.
- Make the injection opt in and keep the previous isolated
DexClassLoader path as the default, so apps that do not need Class.forName discovery are unaffected.
Environment
Summary
On release (non debuggable) builds, the first time the runtime generates a proxy class on the main thread, the app crashes with:
This was introduced by #1951 (commit
c9d41e62, currently onmain). Debug builds are not affected, so it passes normal development and only appears once an app ships a release build. The runtime reports version 9.0.4 (the commit sits a few commits past the 9.0.4 tag).What triggers it
Any JavaScript that implements or extends a native type whose proxy is produced at runtime, on the main thread (worker id 0). For example:
Statically bound proxies are not affected, because
resolveClassfinds the pre-generated class and returns before reaching the new code path. If the static binding generator covers the example above, force the runtime path (for example by building the implementation dynamically) so thatDexFactory.resolveClassactually performs generation. You can confirm the path is taken by logging insideinjectDexIntoClassLoader.Background: the class loader hierarchy
Android resolves classes through a chain of class loaders. Each one delegates to its parent before trying itself:
PathClassLoaderis the app's primary loader, created by the system when the process starts.DexClassLoaderis created by code at runtime to load a dex or jar from a chosen path. NativeScript uses it to load proxy classes it generates at runtime, with thePathClassLoaderas the parent so the proxy can resolve the app and framework types it references.Two runtime rules are relevant:
InternalError: Attempt to register dex file ... with multiple class loaderswhen a dex is claimed by a second loader.Root cause
Before #1951,
resolveClassloaded each runtime generated proxy through its ownDexClassLoader: one dex, one owner.#1951 added
injectIntoParentClassLoader, enabled for the main thread runtime inRuntime.init. When enabled,resolveClasscallsinjectDexIntoClassLoader, which:DexClassLoaderover the proxy jar, thenPathClassLoaderand loads the proxy through thePathClassLoader.Both the temporary
DexClassLoaderand the appPathClassLoadernow reference the sameDexFileobject (through the spliced element). Registering oneDexFileagainst a second class loader is what ART rejects: the check is keyed onDexFileidentity and is unconditional.ClassLinker::RegisterDexFilethrowsThrowDexFileAlreadyRegisteredErrorwith noandroid:debuggableguard. Whether a second registration actually happens depends on the build type, which is why only release crashes (see below).Why debug builds are not affected
The single-owner rule is enforced identically in both modes; the throw is not gated on
android:debuggable. Both loaders reference oneDexFile(the spliced element), so the only build-dependent question is whether the temporary loader also registers it:DexFile(a non-debuggable runtime verifies, and may AOT-compile, the dex in-process). The later load through the appPathClassLoaderthen registers the sameDexFileagainst a second loader, and ART throws.class_linker.cc: "For debuggable runtimes we don't use AOT code"), and the temporary loader never claims the dex, so it is registered exactly once through the app loader and nothing throws.The exact ART step that registers the dex against the temporary loader in release is not instrumented here; what is certain is that two loaders share one
DexFileand thatClassLinker::RegisterDexFileis identity-keyed and unconditional, so the second registration throws. The release-only crash is consistent with the interpret-only vs verify/AOT split. (TheodexDirpassed as theDexClassLoaderoptimizedDirectoryhas no effect since API 26, so it is not the relevant factor.)Expected behavior
Runtime generated proxies load without crashing in release, as they did before #1951.
Suggested direction
The goal of #1951 is to make generated proxies discoverable through the app's
PathClassLoader(for example forClass.forName). The current implementation opens the dex with a separate loader first, which is what creates the second owner. Two options:PathClassLoaderas its only owner, for example by building the dex element through thePathClassLoader's ownDexPathListinstead of through a temporaryDexClassLoader. The dex is then registered exactly once.DexClassLoaderpath as the default, so apps that do not needClass.forNamediscovery are unaffected.Environment
mainat commitc9d41e62(reports version 9.0.4)android:debuggable=false). Debug builds do not reproduce.