diff --git a/native/src/jni/hook_bridge.cpp b/native/src/jni/hook_bridge.cpp index d9f074cab..95e834b62 100644 --- a/native/src/jni/hook_bridge.cpp +++ b/native/src/jni/hook_bridge.cpp @@ -889,6 +889,10 @@ VECTOR_DEF_NATIVE_METHOD(jobjectArray, HookBridge, callbackSnapshot, jclass call // Lock to ensure a consistent snapshot of the callback lists. lsplant::JNIMonitor monitor(env, backup); + // The existing dispatcher invokes the original when no callback remains. Avoid allocating + // snapshot arrays for an empty hook while retaining the backup used by in-flight calls. + if (hook_item->modern_callbacks.empty() && hook_item->legacy_callbacks.empty()) return nullptr; + // Get the generic Object class jclass obj_class = env->FindClass("java/lang/Object"); diff --git a/tests/hotload/HookCleanupTest.java b/tests/hotload/HookCleanupTest.java new file mode 100644 index 000000000..8ebed1b53 --- /dev/null +++ b/tests/hotload/HookCleanupTest.java @@ -0,0 +1,241 @@ +import io.github.libxposed.api.XposedInterface.ExceptionMode; +import io.github.libxposed.api.XposedInterface.HookHandle; +import io.github.libxposed.api.XposedInterface.Hooker; +import io.github.libxposed.api.XposedModuleInterface.HotReloadedParam; +import io.github.libxposed.api.XposedModuleInterface.HotReloadingParam; +import io.github.libxposed.api.error.HookFailedError; +import java.lang.ref.WeakReference; +import java.lang.reflect.*; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.*; +import java.util.concurrent.atomic.*; +import kotlin.jvm.internal.Ref; +import org.matrix.vector.impl.hooks.*; +import org.matrix.vector.nativebridge.HookBridge; + +/** Exercises compiled production handles/parameters and production native callback operations. + * GC is diagnostic in this host test only; it is never added to Vector's runtime. */ +public final class HookCleanupTest { + private static final String MODULE = "test.cleanup.module"; + private static final Method TARGET; + private static final List retained = new ArrayList<>(); + private static int checks, failures; + static { + try { TARGET = HookCleanupTest.class.getMethod("original", int.class); } + catch (Exception e) { throw new ExceptionInInitializerError(e); } + } + public static String original(int value) { + if (value < 0) throw new IllegalArgumentException("original failure"); + return Integer.toString(value); + } + private static native void install(Method method, Object record, boolean modern, int priority); + private static native int snapshotAllocations(Class recordType, Method method); + private static native void releaseFixture(); + private static void require(boolean value, String message) { + ++checks; + if (!value) throw new AssertionError(message); + } + private interface Case { void run() throws Throwable; } + private static void test(String name, Case body) { + try { body.run(); System.out.println("PASS " + name); } + catch (Throwable t) { ++failures; System.out.println("FAIL " + name + ": " + t); } + finally { + for (HookHandle h : VectorHookRegistry.INSTANCE.liveHandles(MODULE)) h.unhook(); + releaseFixture(); + retained.clear(); + } + } + private static Hooker hooker(Object value) { return chain -> value; } + private static VectorHookHandle register(Hooker hooker, String owner) { + VectorHookRecord record = new VectorHookRecord(hooker, 71, ExceptionMode.PASSTHROUGH, "stable-id"); + install(TARGET, record, true, 71); + VectorHookHandle handle = new VectorHookHandle(TARGET, owner, record); + if (owner != null) { + VectorHookRegistry.INSTANCE.track(owner, handle); + VectorHookRegistry.INSTANCE.claimId(owner, TARGET, "stable-id", handle); + } + return handle; + } + private static Object[][] snapshot() { return HookBridge.callbackSnapshot(VectorHookRecord.class, TARGET); } + private static VectorHookRecord current() { + Object[][] records = snapshot(); + require(records != null && records[0].length == 1 && records[1].length == 0, + "snapshot must contain exactly one modern callback"); + return (VectorHookRecord) records[0][0]; + } + private static final class Generation { + final Hooker hooker; + final WeakReference loader; + Generation(String value) throws Exception { + URLClassLoader child = new URLClassLoader(new URL[0], Hooker.class.getClassLoader()); + loader = new WeakReference<>(child); + hooker = (Hooker) Proxy.newProxyInstance(child, new Class[]{Hooker.class}, + (proxy, method, args) -> value); + child.close(); + } + } + private static void collected(WeakReference reference, String message) throws Exception { + for (int i = 0; i < 30 && reference.get() != null; ++i) { + System.gc(); + Thread.sleep(10); + } + require(reference.get() == null, message); + } + private static void emptySnapshot() throws Throwable { + VectorHookHandle h = register(hooker("unused"), MODULE); + h.unhook(); + require(snapshotAllocations(VectorHookRecord.class, TARGET) == 0, + "empty callbackSnapshot allocated JNI arrays"); + require(snapshot() == null, "empty site should use the existing null fast path"); + } + private static void originalSemantics() throws Throwable { + register(hooker("unused"), MODULE).unhook(); + VectorNativeHooker callback = new VectorNativeHooker<>(TARGET); + require(callback.callback(new Object[]{5}).equals("5"), "callback original result changed"); + try { callback.callback(new Object[]{-1}); throw new AssertionError("expected original exception"); } + catch (IllegalArgumentException expected) { require(expected.getMessage().equals("original failure"), "wrong cause"); } + VectorMethodInvoker invoker = new VectorMethodInvoker(TARGET); + require(invoker.invokeWith(null, new Object[]{9}).equals("9"), "invoker result changed"); + try { invoker.invokeWith(null, new Object[]{-1}); throw new AssertionError("expected reflection wrapper"); } + catch (Throwable expected) { + require(expected instanceof InvocationTargetException && expected.getCause() instanceof IllegalArgumentException, + "invoker exception contract changed"); + } + } + private static WeakReference retireByUnhook(String owner) throws Throwable { + Generation generation = new Generation("old"); + VectorHookHandle h = register(generation.hooker, owner); + retained.add(h); + h.unhook(); h.unhook(); + require(h.getId().equals("stable-id") && h.getExecutable().equals(TARGET), "retired identity changed"); + return generation.loader; + } + private static final class InFlight { + Object[][] records; + WeakReference loader; + HookHandle replacement; + } + private static InFlight replaceWithSnapshot() throws Throwable { + Generation generation = new Generation("old"); + VectorHookHandle h = register(generation.hooker, MODULE); + retained.add(h); + InFlight flight = new InFlight(); + flight.records = snapshot(); + flight.loader = generation.loader; + flight.replacement = h.replaceHook(hooker("new")); + h.unhook(); // An invalid handle must not remove the replacement. + require(current().getHooker().intercept(null).equals("new"), "replacement was removed"); + require(current().getPriority() == 71 && current().getExceptionMode() == ExceptionMode.PASSTHROUGH, + "replacement did not inherit priority/mode"); + require(h.getId().equals(flight.replacement.getId()), "replacement identity changed"); + try { h.replaceHook(hooker("invalid")); throw new AssertionError("retired handle replaced twice"); } + catch (IllegalStateException expected) { ++checks; } + return flight; + } + private static void inFlight() throws Throwable { + InFlight flight = replaceWithSnapshot(); + System.gc(); + require(flight.loader.get() != null, "in-flight callback was prematurely collected"); + require(((VectorHookRecord) flight.records[0][0]).getHooker().intercept(null).equals("old"), + "in-flight callback changed generation"); + flight.records = null; + collected(flight.loader, "retained invalid handle pins old callback/classloader after snapshot finishes"); + require(current().getHooker().intercept(null).equals("new"), "live replacement stopped working"); + flight.replacement.unhook(); + } + private static void failedSwap() throws Throwable { + VectorHookHandle h = register(hooker("old"), MODULE); + // Simulate the native registry no longer containing the record. + HookBridge.unhookMethod(true, TARGET, current()); + try { h.replaceHook(hooker("new")); throw new AssertionError("missing record replaced"); } + catch (HookFailedError expected) { ++checks; } + require(VectorHookRegistry.INSTANCE.liveHandles(MODULE).contains(h), "failed replacement invalidated owner"); + h.unhook(); + require(VectorHookRegistry.INSTANCE.liveHandles(MODULE).isEmpty(), "failed-swap cleanup lost owner"); + } + private static void concurrentSnapshots() throws Throwable { + AtomicReference failure = new AtomicReference<>(); + AtomicBoolean running = new AtomicBoolean(true); + AtomicInteger reads = new AtomicInteger(); + HookHandle h = register(hooker(0), MODULE); + Thread reader = new Thread(() -> { + try { + do { + Object[][] records = snapshot(); + if (records == null || records[0].length != 1 || records[1].length != 0) + throw new AssertionError("replacement exposed zero or two callbacks"); + Object value = ((VectorHookRecord) records[0][0]).getHooker().intercept(null); + if (!(value instanceof Integer)) throw new AssertionError("invalid snapshot payload"); + reads.incrementAndGet(); + } while (running.get()); + } catch (Throwable t) { failure.set(t); } + }); + reader.start(); + try { for (int i = 1; i <= 3000; ++i) h = h.replaceHook(hooker(i)); } + finally { running.set(false); reader.join(); } + require(failure.get() == null, "concurrent reader failed: " + failure.get()); + require(reads.get() > 0 && current().getHooker().intercept(null).equals(3000), "replacement race failed"); + h.unhook(); + } + private static void legacySnapshot() { + VectorHookHandle modern = register(hooker("modern"), MODULE); + Object legacy = new Object(); + install(TARGET, legacy, false, 99); + Object[][] both = snapshot(); + require(both[0].length == 1 && both[1].length == 1 && both[1][0] == legacy, "mixed callbacks lost"); + modern.unhook(); + Object[][] onlyLegacy = snapshot(); + require(onlyLegacy != null && onlyLegacy[0].length == 0 && onlyLegacy[1][0] == legacy, + "empty modern callbacks bypassed legacy callbacks"); + HookBridge.unhookMethod(false, TARGET, legacy); + } + private static Object makeParam(Class type, Object... args) throws Exception { + Constructor constructor = type.getDeclaredConstructors()[0]; + constructor.setAccessible(true); + return constructor.newInstance(args); + } + private static WeakReference retainedParams() throws Throwable { + String prefix = "org.matrix.vector.impl.core.VectorModuleManager$runHotReload$"; + Class beforeType = Class.forName(prefix + "reloadingParam$1"); + Class afterType = Class.forName(prefix + "reloadedParam$1"); + for (Class type : List.of(beforeType, afterType)) { + for (Field field : type.getDeclaredFields()) { + require(!field.getType().getName().endsWith("$Generation"), "callback parameter pins Generation"); + } + } + Generation old = new Generation("old"); + Ref.ObjectRef state = new Ref.ObjectRef<>(); + HotReloadingParam before = (HotReloadingParam) makeParam(beforeType, null, old.loader, state); + try { before.setSavedInstanceState(old.hooker); throw new AssertionError("old loader state accepted"); } + catch (IllegalArgumentException expected) { ++checks; } + before.setSavedInstanceState(List.of("neutral", 7)); + List handles = List.of(); + HotReloadedParam after = (HotReloadedParam) makeParam(afterType, false, "test.process", null, state, handles); + require(!after.isSystemServer() && after.getProcessName().equals("test.process"), "process info changed"); + require(after.getSavedInstanceState() == state.element && after.getOldHookHandles() == handles, + "parameter values changed"); + retained.add(before); retained.add(after); + return old.loader; + } + public static void main(String[] args) throws Throwable { + System.load(args[0]); + test("empty-snapshot-allocations", HookCleanupTest::emptySnapshot); + test("empty-site-original-and-exceptions", HookCleanupTest::originalSemantics); + test("unhook-releases-module-loader", () -> collected(retireByUnhook(MODULE), "unhooked handle pins loader")); + test("unhook-releases-framework-callback", () -> collected(retireByUnhook(null), "framework handle pins loader")); + test("replacement-preserves-in-flight-and-releases-retired", HookCleanupTest::inFlight); + test("failed-replacement-keeps-ownership", HookCleanupTest::failedSwap); + test("atomic-replacement-3000", HookCleanupTest::concurrentSnapshots); + test("legacy-and-modern-snapshots", HookCleanupTest::legacySnapshot); + test("retained-hotload-parameters", () -> { + collected(retainedParams(), "retained hotload parameter pins loader"); + // Retaining a parameter does not introduce a new failure once its loader is gone. + ((HotReloadingParam) retained.get(0)).setSavedInstanceState("later-neutral-state"); + require(((HotReloadedParam) retained.get(1)).getSavedInstanceState().equals("later-neutral-state"), + "saved-state contract changed"); + }); + System.out.println("Checks: " + checks + ", failures: " + failures); + if (failures != 0) throw new AssertionError(failures + " failed cleanup cases"); + } +} diff --git a/tests/hotload/README.md b/tests/hotload/README.md new file mode 100644 index 000000000..3e4eb6440 --- /dev/null +++ b/tests/hotload/README.md @@ -0,0 +1,30 @@ +# Hot-reload resource lifetime tests + +Build `:xposed:compileReleaseKotlin`, then run: + +```sh +python3 tests/hotload/run_tests.py \ + --jdk /path/to/jdk-21 \ + --sdk /path/to/android-sdk \ + --gradle-cache /path/to/gradle-user-home +``` + +Requires Python 3, G++, the Android 37 SDK and the checked-out LSPlant submodule. +The tests load compiled production Kotlin classes and compile the native callback +operations extracted from `hook_bridge.cpp` with LSPlant's actual JNI monitor. + +Coverage includes retained inactive handles, failed and successful replacement, +in-flight callback snapshots, retained reload parameters, empty callback snapshots, +and 3,000 atomic replacements. The executor tests check FIFO order, 8,000 concurrent +submissions, 100 drained batches, worker termination, interrupt isolation, and task, +reporter and thread-start failure recovery. DEX input tests cover ordered mapping, +successful consumption, partial mapping failure and loader/cleanup exceptions. + +`--baseline /path/to/baseline` optionally runs the callback/handle checks against +preserved pre-change classes in `baseline-classes/` and native source in +`baseline/native/src/jni/hook_bridge.cpp`. The expected old failures are checked +explicitly; unrelated failures do not count as a successful negative control. + +These host fixtures do not install ART hooks or prove ART-owned DEX reclamation. +`SharedMemory` is a controlled ownership/mapping double. Repeated on-device +hot reloads are needed to verify actual hook continuity and Android resources. diff --git a/tests/hotload/ResourceCleanupTest.java b/tests/hotload/ResourceCleanupTest.java new file mode 100644 index 000000000..bd20a3a2d --- /dev/null +++ b/tests/hotload/ResourceCleanupTest.java @@ -0,0 +1,152 @@ +import android.os.SharedMemory; +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import kotlin.Unit; +import org.matrix.vector.impl.core.OnDemandSerialExecutor; +import org.matrix.vector.impl.utils.DexMemoryScopeKt; + +/** Executes the compiled production queue and DEX ownership helpers. */ +public class ResourceCleanupTest { + private static int checks; + private static void check(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + checks++; + } + private static void await(CountDownLatch latch) throws InterruptedException { + check(latch.await(15, TimeUnit.SECONDS), "worker did not finish"); + } + private static void retired(OnDemandSerialExecutor executor, List workers) throws Exception { + for (Thread worker : workers) { + worker.join(15000); + check(!worker.isAlive(), "idle worker remains alive"); + } + Field queue = OnDemandSerialExecutor.class.getDeclaredField("queue"); + queue.setAccessible(true); + check(queue.get(executor) == null, "finished queue retains storage"); + } + private static void queue() throws Exception { + List workers = Collections.synchronizedList(new ArrayList<>()); + ThreadFactory factory = runnable -> { + Thread thread = new Thread(runnable); + workers.add(thread); + return thread; + }; + AtomicInteger failures = new AtomicInteger(); + OnDemandSerialExecutor executor = new OnDemandSerialExecutor(factory, failure -> { + failures.incrementAndGet(); + throw new IllegalStateException("reporting failed"); + }); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + executor.execute(() -> { + entered.countDown(); + try { release.await(); } catch (InterruptedException e) { throw new AssertionError(e); } + }); + await(entered); + List order = Collections.synchronizedList(new ArrayList<>()); + CountDownLatch done = new CountDownLatch(2000); + for (int i = 0; i < 2000; i++) { + final int id = i; + executor.execute(() -> { order.add(id); done.countDown(); }); + } + release.countDown(); await(done); retired(executor, workers); + check(order.equals(java.util.stream.IntStream.range(0, 2000).boxed().toList()), "queue is not FIFO"); + check(workers.size() == 1, "one batch starts multiple workers"); + + int count = 8000; + AtomicInteger active = new AtomicInteger(), max = new AtomicInteger(); + Set seen = ConcurrentHashMap.newKeySet(); + CountDownLatch parallelDone = new CountDownLatch(count); + ExecutorService producers = Executors.newFixedThreadPool(8); + for (int i = 0; i < count; i++) { + final int id = i; + producers.execute(() -> executor.execute(() -> { + int running = active.incrementAndGet(); + max.accumulateAndGet(running, Math::max); + seen.add(id); active.decrementAndGet(); parallelDone.countDown(); + })); + } + producers.shutdown(); + check(producers.awaitTermination(15, TimeUnit.SECONDS), "producers stalled"); + await(parallelDone); retired(executor, workers); + check(seen.size() == count && max.get() == 1, "concurrent submissions lost or overlap callbacks"); + + CountDownLatch recovered = new CountDownLatch(1); + executor.execute(() -> { Thread.currentThread().interrupt(); throw new IllegalStateException("task failed"); }); + AtomicBoolean inheritedInterrupt = new AtomicBoolean(true); + executor.execute(() -> { inheritedInterrupt.set(Thread.currentThread().isInterrupted()); recovered.countDown(); }); + await(recovered); retired(executor, workers); + check(failures.get() == 1 && !inheritedInterrupt.get(), "task/reporting failure strands queue or interrupt leaks"); + for (int i = 0; i < 100; i++) { + CountDownLatch cycle = new CountDownLatch(1); + executor.execute(cycle::countDown); + await(cycle); retired(executor, workers); + } + + AtomicBoolean failStart = new AtomicBoolean(true); + OnDemandSerialExecutor retry = new OnDemandSerialExecutor(runnable -> { + if (failStart.getAndSet(false)) return new Thread(runnable) { + @Override public synchronized void start() { throw new IllegalStateException("start failed"); } + }; + return factory.newThread(runnable); + }, failure -> Unit.INSTANCE); + AtomicBoolean rejectedRan = new AtomicBoolean(); + try { retry.execute(() -> rejectedRan.set(true)); throw new AssertionError("expected start failure"); } + catch (IllegalStateException expected) {} + CountDownLatch retryDone = new CountDownLatch(1); + retry.execute(retryDone::countDown); await(retryDone); retired(retry, workers); + check(!rejectedRan.get(), "failed submission later executes"); + System.out.println("PASS queue: FIFO, 8000 submissions, 100 drained batches, failure/reporter/interrupt/start recovery"); + } + + private static void dex() { + SharedMemory first = new SharedMemory(1), second = new SharedMemory(2); + Object token = new Object(); + Object result = DexMemoryScopeKt.withConsumedDexBuffers(List.of(first, second), buffers -> { + check(buffers.length == 2 && buffers[0].getInt(0) == 1 && buffers[1].getInt(0) == 2, + "DEX order changed"); + return token; + }); + check(result == token && first.closes == 1 && second.closes == 1 + && first.unmaps == 1 && second.unmaps == 1, "successful load did not release inputs"); + check(SharedMemory.mapped.isEmpty(), "successful mapping retained"); + + first = new SharedMemory(1); second = new SharedMemory(2); + SharedMemory third = new SharedMemory(3); + RuntimeException primary = new IllegalStateException("map failed"); + second.mapFailure = primary; + AtomicBoolean loaderRan = new AtomicBoolean(); + try { + DexMemoryScopeKt.withConsumedDexBuffers(List.of(first, second, third), buffers -> { + loaderRan.set(true); return null; + }); + throw new AssertionError("expected mapping failure"); + } catch (RuntimeException expected) { check(expected == primary, "mapping exception replaced"); } + check(!loaderRan.get() && first.unmaps == 1 && second.maps == 0 && third.maps == 0, + "partial DEX mapping reached the loader"); + check(first.closes == 1 && second.closes == 1 && third.closes == 1, "unused handles not closed"); + + first = new SharedMemory(1); second = new SharedMemory(2); + first.unmapFailure = new IllegalStateException("unmap failed"); + second.closeFailure = new IllegalStateException("close failed"); + RuntimeException loadFailure = new IllegalStateException("load failed"); + try { + DexMemoryScopeKt.withConsumedDexBuffers(List.of(first, second), buffers -> { throw loadFailure; }); + throw new AssertionError("expected loader failure"); + } catch (RuntimeException expected) { check(expected == loadFailure, "loader exception replaced by cleanup"); } + check(loadFailure.getSuppressed().length == 1 + && loadFailure.getSuppressed()[0] == first.unmapFailure + && first.unmapFailure.getSuppressed()[0] == second.closeFailure, + "cleanup exceptions not retained as suppressed"); + check(first.unmaps == 1 && second.unmaps == 1 && first.closes == 1 && second.closes == 1, + "cleanup failure prevents later cleanup"); + check(SharedMemory.mapped.isEmpty(), "failed load retains mappings"); + System.out.println("PASS DEX inputs: ordering, success, partial-map failure, loader failure, all cleanup attempted"); + } + + public static void main(String[] args) throws Exception { + queue(); dex(); + System.out.println("Checks: " + checks + "; transport/mapping are controlled host fixtures, not ART"); + } +} diff --git a/tests/hotload/cleanup_fixture.cpp b/tests/hotload/cleanup_fixture.cpp new file mode 100644 index 000000000..abc0581d5 --- /dev/null +++ b/tests/hotload/cleanup_fixture.cpp @@ -0,0 +1,86 @@ +// Host fixture only: install callback records without modifying any ART methods. +// HookItem, the callback operations and JNIMonitor are extracted verbatim by run_tests.py. +#include +#include +#include +#include +#include +#include +#include + +#define DISALLOW_COPY_AND_ASSIGN(Type) \ + Type(const Type&) = delete; Type& operator=(const Type&) = delete +namespace lsplant { +// INSERT_PRODUCTION_MONITOR +} + +#define JNI_START [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz +#define VECTOR_DEF_NATIVE_METHOD(ret, owner, name, ...) \ + extern "C" JNIEXPORT ret JNICALL Java_org_matrix_vector_nativebridge_##owner##_##name( \ + JNI_START, ##__VA_ARGS__) + +// INSERT_PRODUCTION_REGISTRY +// INSERT_PRODUCTION_OPERATIONS + +extern "C" JNIEXPORT void JNICALL Java_HookCleanupTest_install( + JNIEnv* env, jclass, jobject method, jobject record, jboolean modern, jint priority) { + HookItem* item = nullptr; + const auto id = env->FromReflectedMethod(method); + hooked_methods.lazy_emplace_l(id, + [&](auto& it) { item = it.second.get(); }, + [&](const auto& ctor) { + auto created = std::make_unique(); + created->SetBackup(env->NewGlobalRef(method)); + item = created.get(); + ctor(id, std::move(created)); + }); + lsplant::JNIMonitor lock(env, item->GetBackup()); + auto& callbacks = modern ? item->modern_callbacks : item->legacy_callbacks; + callbacks.emplace(priority, env->NewGlobalRef(record)); +} + +thread_local const JNINativeInterface_* original_jni; +thread_local int array_allocations; +jobjectArray counted_array(JNIEnv* env, jsize size, jclass type, jobject init) { + ++array_allocations; + return original_jni->NewObjectArray(env, size, type, init); +} +extern "C" JNIEXPORT jint JNICALL Java_HookCleanupTest_snapshotAllocations( + JNIEnv* env, jclass, jclass record_type, jobject method) { + original_jni = env->functions; + auto counted = *original_jni; + counted.NewObjectArray = counted_array; + array_allocations = 0; + env->functions = &counted; + auto result = Java_org_matrix_vector_nativebridge_HookBridge_callbackSnapshot( + env, nullptr, record_type, method); + env->functions = original_jni; + if (result) env->DeleteLocalRef(result); + return array_allocations; +} + +// These two originals use host reflection: the fixture never installs an ART trampoline. +VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, invokeOriginalMethod, + jobject method, jobject receiver, jobjectArray args) { + auto type = env->FindClass("java/lang/reflect/Method"); + auto invoke = env->GetMethodID(type, "invoke", + "(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;"); + return env->CallObjectMethod(method, invoke, receiver, args); +} +VECTOR_DEF_NATIVE_METHOD(jobject, HookBridge, invokeOriginal, + jobject method, jcharArray, jobjectArray, jclass, jboolean, jboolean, + jobject receiver, jobjectArray args) { + return Java_org_matrix_vector_nativebridge_HookBridge_invokeOriginalMethod( + env, clazz, method, receiver, args); +} + +extern "C" JNIEXPORT void JNICALL Java_HookCleanupTest_releaseFixture(JNIEnv* env, jclass) { + // Tests have joined every reader before releasing fixture-owned JNI references. + for (auto& entry : hooked_methods) { + auto& item = *entry.second; + for (auto& callback : item.modern_callbacks) env->DeleteGlobalRef(callback.second); + for (auto& callback : item.legacy_callbacks) env->DeleteGlobalRef(callback.second); + env->DeleteGlobalRef(item.GetBackup()); + } + hooked_methods.clear(); +} diff --git a/tests/hotload/run_tests.py b/tests/hotload/run_tests.py new file mode 100644 index 000000000..e0d948aea --- /dev/null +++ b/tests/hotload/run_tests.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Host regression test using compiled Vector classes and verbatim native callback functions. + +Build :xposed:compileReleaseKotlin first. This is not an ART/LSPlant installation test, +a measurement of DEX unmapping, or an Android detector comparison. No device is used. +""" +import argparse +import hashlib +import json +import os +import re +from pathlib import Path +import subprocess + +ROOT = Path(__file__).resolve().parents[2] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--jdk', type=Path, required=True) + parser.add_argument('--sdk', type=Path, required=True) + parser.add_argument('--gradle-cache', type=Path, default=Path('/tmp/vector-gradle')) + parser.add_argument('--output', type=Path, default=ROOT / 'build/hotload/cleanup-tests') + parser.add_argument('--baseline', type=Path, help='Optional preserved pre-change sources and classes') + args = parser.parse_args() + out = args.output.resolve() + out.mkdir(parents=True, exist_ok=True) + records = [] + + def run(name, command, expect_failure=False): + result = subprocess.run([str(x) for x in command], cwd=ROOT, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + (out / f'{name}.log').write_text(result.stdout) + ok = result.returncode != 0 if expect_failure else result.returncode == 0 + records.append({'name': name, 'command': [str(x) for x in command], 'exit': result.returncode, + 'expected_failure': expect_failure, 'pass': ok}) + (out / 'results.json').write_text(json.dumps(records, indent=2) + '\n') + print(('PASS ' if ok else 'FAIL ') + name, flush=True) + if name.startswith('run-') or not ok: + print(result.stdout, end='', flush=True) + if not ok: + raise RuntimeError(f'{name} exited {result.returncode}') + return result.stdout + + kotlin_version = re.search(r'^kotlin = "([^"]+)"', (ROOT / 'gradle/libs.versions.toml').read_text(), re.M).group(1) + stdlib = next((args.gradle_cache / 'caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-stdlib' / kotlin_version).rglob('*.jar')) + kotlin = ROOT / 'xposed/build/intermediates/built_in_kotlinc/release/compileReleaseKotlin/classes' + java = ROOT / 'xposed/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes' + services = ROOT / 'services/daemon-service/build/intermediates/javac/release/compileReleaseJavaWithJavac/classes' + android = args.sdk / 'platforms/android-37.0/android.jar' + classes = out / 'classes' + classes.mkdir(exist_ok=True) + cp = os.pathsep.join(map(str, [classes, kotlin, java, services, stdlib, android])) + run('compile-test', [args.jdk / 'bin/javac', '-cp', cp, '-d', classes, + ROOT / 'tests/hotload/HookCleanupTest.java', + ROOT / 'tests/hotload/ResourceCleanupTest.java', + ROOT / 'tests/hotload/stubs/android/os/SharedMemory.java']) + run('run-resource-cleanup', [args.jdk / 'bin/java', '-ea', '-cp', cp, 'ResourceCleanupTest']) + + monitor_file = ROOT / 'external/lsplant/lsplant/src/main/jni/include/utils/jni_helper.hpp' + monitor_text = monitor_file.read_text() + start = monitor_text.index('class JNIMonitor {') + monitor = monitor_text[start:monitor_text.index('\n};', start) + 3] + fixture = (ROOT / 'tests/hotload/cleanup_fixture.cpp').read_text() + phmap = ROOT / 'external/lsplant/lsplant/src/main/jni/external/dex_builder/external/parallel_hashmap' + sources = {} + variants = [('current', ROOT / 'native/src/jni/hook_bridge.cpp', kotlin)] + if args.baseline: + variants.insert(0, ('baseline', args.baseline / 'baseline/native/src/jni/hook_bridge.cpp', + args.baseline / 'baseline-classes')) + for name, native, bytecode in variants: + code = native.read_text() + registry_start = code.index('struct HookItem {') + registry = code[registry_start:code.index('// Cached JNI method', registry_start)] + operations = [] + for result_type, function in [('jboolean', 'unhookMethod'), ('jboolean', 'replaceCallback'), + ('jobjectArray', 'callbackSnapshot')]: + start = code.index(f'VECTOR_DEF_NATIVE_METHOD({result_type}, HookBridge, {function},') + operations.append(code[start:code.index('\n}', start) + 2]) + generated = fixture.replace('// INSERT_PRODUCTION_MONITOR', monitor) + generated = generated.replace('// INSERT_PRODUCTION_REGISTRY', registry) + generated = generated.replace('// INSERT_PRODUCTION_OPERATIONS', '\n\n'.join(operations)) + cpp = out / f'{name}.cpp' + cpp.write_text(generated) + lib = out / f'lib{name}.so' + run('compile-native-' + name, ['g++', '-std=c++20', '-O2', '-fPIC', '-shared', '-pthread', + '-Wall', '-Wextra', '-Werror', '-I' + str(args.jdk / 'include'), + '-I' + str(args.jdk / 'include/linux'), '-I' + str(phmap), cpp, '-o', lib]) + runtime_cp = os.pathsep.join(map(str, [classes, bytecode, java, services, stdlib, android])) + output = run('run-' + name, [args.jdk / 'bin/java', '-Xint', '-Xmx96m', '-XX:+UseSerialGC', + '-cp', runtime_cp, 'HookCleanupTest', lib], expect_failure=name == 'baseline') + if name == 'baseline': + expected = {'empty-snapshot-allocations', 'unhook-releases-module-loader', + 'unhook-releases-framework-callback', + 'replacement-preserves-in-flight-and-releases-retired', 'retained-hotload-parameters'} + failed = {line.split(':', 1)[0][5:] for line in output.splitlines() if line.startswith('FAIL ')} + if failed != expected: + raise AssertionError(f'Baseline failures differ from diagnosed defects: {failed}') + sources[name] = {'native_sha256': hashlib.sha256(native.read_bytes()).hexdigest(), + 'generated_fixture_sha256': hashlib.sha256(cpp.read_bytes()).hexdigest(), + 'classes': {str(p.relative_to(bytecode)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(bytecode.rglob('*.class'))}} + (out / 'sources.json').write_text(json.dumps(sources, indent=2) + '\n') + print('LIMIT: host JVM and native callback operations; no ART hooks, Android apps or DEX reclamation tested.') + + +if __name__ == '__main__': + main() diff --git a/tests/hotload/stubs/android/os/SharedMemory.java b/tests/hotload/stubs/android/os/SharedMemory.java new file mode 100644 index 000000000..53cf140c1 --- /dev/null +++ b/tests/hotload/stubs/android/os/SharedMemory.java @@ -0,0 +1,32 @@ +package android.os; + +import java.nio.ByteBuffer; +import java.util.IdentityHashMap; + +/** Models input ownership and cleanup failures, not Android ashmem or ART mappings. */ +public class SharedMemory implements AutoCloseable { + public static final IdentityHashMap mapped = new IdentityHashMap<>(); + public final int id; + public int maps, unmaps, closes; + public RuntimeException mapFailure, unmapFailure, closeFailure; + public SharedMemory(int id) { this.id = id; } + public ByteBuffer mapReadOnly() { + if (mapFailure != null) throw mapFailure; + maps++; + ByteBuffer buffer = ByteBuffer.allocate(4); + buffer.putInt(0, id); + buffer = buffer.asReadOnlyBuffer(); + mapped.put(buffer, this); + return buffer; + } + public static void unmap(ByteBuffer buffer) { + SharedMemory owner = mapped.remove(buffer); + if (owner == null) throw new AssertionError("unknown mapping"); + owner.unmaps++; + if (owner.unmapFailure != null) throw owner.unmapFailure; + } + public void close() { + closes++; + if (closeFailure != null) throw closeFailure; + } +} diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/OnDemandSerialExecutor.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/OnDemandSerialExecutor.kt new file mode 100644 index 000000000..017903dc8 --- /dev/null +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/OnDemandSerialExecutor.kt @@ -0,0 +1,66 @@ +package org.matrix.vector.impl.core + +import java.util.ArrayDeque +import java.util.concurrent.Executor +import java.util.concurrent.ThreadFactory + +/** One finite worker per non-empty batch; no idle thread, timer, or shared pool. */ +internal class OnDemandSerialExecutor( + private val threadFactory: ThreadFactory, + private val onFailure: (Throwable) -> Unit, +) : Executor { + private val lock = Any() + private var queue: ArrayDeque? = null + private var running = false + + override fun execute(command: Runnable) { + synchronized(lock) { + val pending = queue ?: ArrayDeque().also { queue = it } + pending.addLast(command) + if (running) return + + // Keep the idle -> running transition and start failure atomic with submissions. + running = true + try { + val thread = + threadFactory.newThread(Runnable { drain() }) + ?: throw IllegalStateException("Hot reload thread factory returned null") + thread.start() + } catch (failure: Throwable) { + // There was no previous worker or pending batch when running was false. + pending.removeLast() + queue = null + running = false + throw failure + } + } + } + + private fun drain() { + while (true) { + val next = + synchronized(lock) { + val pending = queue + if (pending == null || pending.isEmpty()) { + // A later submission starts a new worker; this worker runs no more tasks. + // Release the backing array as well as its already-removed request objects. + queue = null + running = false + return + } + pending.removeFirst() + } + // Module code may set its worker's interrupt flag. Do not pass that to the next task. + Thread.interrupted() + try { + next.run() + } catch (failure: Throwable) { + try { + onFailure(failure) + } catch (_: Throwable) { + // Even a failing reporter must not strand already-accepted requests. + } + } + } + } +} diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt index ae03bf278..1d0123cad 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorModuleManager.kt @@ -264,13 +264,19 @@ object VectorModuleManager { // only: module code never runs inside it. synchronized(VectorHookBuilder.lockOf(packageName)) { old.context.freeze() } + // Modules may retain callback parameters. Copy values instead of capturing the old + // generation; the private monitor below keeps it alive for the callback itself. + val retiredLoader = WeakReference(old.classLoader) + val callbackIsSystemServer = old.isSystemServer + val callbackProcessName = old.processName var savedState: Any? = null val reloadingParam = object : HotReloadingParam { override fun getExtras(): Bundle? = extras override fun setSavedInstanceState(outState: Any?) { - rejectOldGenerationState(outState, old.classLoader) + // A live value defined by this loader would itself keep the loader alive. + retiredLoader.get()?.let { rejectOldGenerationState(outState, it) } savedState = outState } } @@ -299,9 +305,9 @@ object VectorModuleManager { val reloadedParam = object : HotReloadedParam { - override fun isSystemServer(): Boolean = old.isSystemServer + override fun isSystemServer(): Boolean = callbackIsSystemServer - override fun getProcessName(): String = old.processName + override fun getProcessName(): String = callbackProcessName override fun getExtras(): Bundle? = extras @@ -319,12 +325,17 @@ object VectorModuleManager { var failure: Throwable? = null // The default onHotReloaded already unhooks these; doing both would double-unhook. - newEntries - .filter { VectorLifecycleManager.isActive(it) } - .forEach { - if (failure != null) return@forEach - runCatching { it.onHotReloaded(reloadedParam) }.onFailure { t -> failure = t } - } + // API 102 retains the old generation through onHotReloaded. This private monitor + // provides that lifetime on API 26+, without a lasting root or a registry lock held + // across module code. No other code acquires the Generation monitor. + synchronized(old) { + newEntries + .filter { VectorLifecycleManager.isActive(it) } + .forEach { + if (failure != null) return@forEach + runCatching { it.onHotReloaded(reloadedParam) }.onFailure { t -> failure = t } + } + } // The last framework-owned reference to the old generation goes with this frame: its map // entry is gone, its entries are out of activeModules, and oldEntries dies on return. diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorProcessChannel.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorProcessChannel.kt index deab0e888..0da9f9fae 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorProcessChannel.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/core/VectorProcessChannel.kt @@ -3,30 +3,24 @@ package org.matrix.vector.impl.core import android.os.Binder import android.os.Bundle import android.os.Process -import java.util.concurrent.Executors import org.matrix.vector.ipc.LoadedModule import org.matrix.vector.ipc.IHotReloadOutcomeReceiver import org.matrix.vector.ipc.IProcessChannel +import org.matrix.vector.ipc.HotReloadOutcome import org.matrix.vector.util.Log +import io.github.libxposed.service.IXposedService +import java.util.concurrent.ThreadFactory +import org.matrix.vector.impl.utils.closeDexHandles private const val TAG = "VectorProcessChannel" -/** - * This process's end of the only channel the daemon has for calling in. - * - * Handed over once while the framework bootstraps, before any module is loaded and carrying no - * module identity - which is what lets system_server have one too, since its modules load before - * the daemon's module cache exists. - */ +/** The daemon's process channel. Its worker exists only while there are reloads to perform. */ object VectorProcessChannel : IProcessChannel.Stub() { - - /** - * One thread, so reloads in this process are serialised even across modules, and so the - * incoming oneway transaction returns at once. Running the cycle on the binder thread that - * delivered it would hold one of this app's binder threads for as long as the module's - * onHotReloading cares to take. - */ - private val worker = Executors.newSingleThreadExecutor { Thread(it, "vector-hot-reload") } + private val worker = + OnDemandSerialExecutor( + ThreadFactory { Thread(it, "vector-hot-reload") }, + { Log.e(TAG, "Unexpected hot reload dispatch failure", it) }, + ) override fun hotReload( modulePackageName: String?, @@ -34,19 +28,78 @@ object VectorProcessChannel : IProcessChannel.Stub() { module: LoadedModule?, receiver: IHotReloadOutcomeReceiver?, ) { - // The daemon is the only caller this binder was ever handed to, but it runs as the system - // uid rather than as root, and this object lives in an app process - so the check is worth - // stating rather than assuming. Nothing else may drive a module's lifecycle. val caller = Binder.getCallingUid() if (caller != Process.SYSTEM_UID && caller != 0) { + closeDexHandles(module?.code?.preLoadedDexes)?.let { + Log.w(TAG, "Cannot close rejected DEX handles", it) + } Log.w(TAG, "Refusing a hot reload request from uid $caller") return } - worker.execute { - val outcome = VectorModuleManager.hotReload(modulePackageName, extras, module) - runCatching { receiver?.onOutcome(outcome) } - .onFailure { Log.w(TAG, "Cannot report the hot reload outcome", it) } + try { + worker.execute { performReload(modulePackageName, extras, module, receiver) } + } catch (failure: Throwable) { + // execute throws only before accepting the task, so no generation changed here. + val outcome = failed(failure, generationChanged = false) + finish(module, receiver, outcome) } } + + private fun performReload( + packageName: String?, + extras: Bundle?, + module: LoadedModule?, + receiver: IHotReloadOutcomeReceiver?, + ) { + var before: ClassLoader? = null + var entered = false + val outcome = + try { + before = packageName?.let { VectorModuleManager.getModuleClassLoader(it) } + entered = true + VectorModuleManager.hotReload(packageName, extras, module) + } catch (failure: Throwable) { + // VectorModuleManager normally returns its own outcome. Keep the outer dispatch + // failure honest too, including an exception after a generation was committed. + val after = packageName?.let { VectorModuleManager.getModuleClassLoader(it) } + failed(failure, generationChanged = entered && before !== after) + } + finish(module, receiver, outcome) + } + + private fun finish( + module: LoadedModule?, + receiver: IHotReloadOutcomeReceiver?, + outcome: HotReloadOutcome, + ) { + // The loader already closes consumed SharedMemory objects. close() is idempotent on + // those objects; this also covers unsupported, refused and never-started requests. + // These are receiver-owned Binder copies, never the daemon's cached originals. + closeDexHandles(module?.code?.preLoadedDexes)?.let { failure -> + outcome.status = IXposedService.HOT_RELOAD_FAILED + outcome.refused = false + outcome.message = "DEX handle cleanup failed: ${describe(failure)}" + // Preserve generationChanged: a cleanup failure does not undo installed hooks. + Log.w(TAG, "Cannot close hot reload DEX handles", failure) + } + try { + receiver?.onOutcome(outcome) + } catch (failure: Throwable) { + Log.w(TAG, "Cannot report the hot reload outcome", failure) + } + // No task/receiver/module is retained in a completed queue slot. Once the batch ends, + // the thread returns normally; do not interrupt callbacks or shut down shared workers. + } + + private fun failed(failure: Throwable, generationChanged: Boolean) = + HotReloadOutcome().apply { + status = IXposedService.HOT_RELOAD_FAILED + message = describe(failure) + refused = false + this.generationChanged = generationChanged + } + + private fun describe(failure: Throwable) = + "${failure.javaClass.name}: ${failure.message ?: "no message"}" } diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorHookHandle.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorHookHandle.kt index 7bcbbcfd2..58d15451c 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorHookHandle.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/hooks/VectorHookHandle.kt @@ -47,7 +47,9 @@ internal object VectorHookRegistry { byModule[moduleId]?.remove(handle) } - /** The hooks of [moduleId] that are still installed, for `HotReloadedParam#getOldHookHandles`. */ + /** + * The hooks of [moduleId] that are still installed, for `HotReloadedParam#getOldHookHandles`. + */ fun liveHandles(moduleId: String): List = byModule[moduleId]?.filter { it.isLive } ?: emptyList() } @@ -70,35 +72,35 @@ internal constructor( initialRecord: VectorHookRecord, ) : HookHandle { - @Volatile - internal var record: VectorHookRecord = initialRecord - private set + // An invalid handle can still be retained by a module (including through HotReloadedParam). + // Keep its public identity, but release the callback and its generation when ownership ends. + private val id = initialRecord.id + @Volatile private var record: VectorHookRecord? = initialRecord - @Volatile - internal var isLive: Boolean = true - private set + internal val isLive: Boolean + get() = record != null override fun getExecutable(): Executable = origin - override fun getId(): String? = record.id + override fun getId(): String? = id override fun unhook() { if (moduleId == null) { synchronized(this) { - if (!isLive) return - isLive = false + val record = record ?: return + HookBridge.unhookMethod(true, origin, record) + this.record = null } - HookBridge.unhookMethod(true, origin, record) return } synchronized(VectorHookRegistry.lockOf(moduleId)) { // Idempotent, as the interface requires - and a handle that has already been superseded // has to stay quiet here rather than tear down its own replacement. - if (!isLive) return - isLive = false - record.id?.let { VectorHookRegistry.releaseId(moduleId, origin, it, this) } - VectorHookRegistry.forget(moduleId, this) + val record = record ?: return HookBridge.unhookMethod(true, origin, record) + this.record = null + id?.let { VectorHookRegistry.releaseId(moduleId, origin, it, this) } + VectorHookRegistry.forget(moduleId, this) } } @@ -110,7 +112,8 @@ internal constructor( this.moduleId ?: throw IllegalStateException("This hook does not belong to a module") synchronized(VectorHookRegistry.lockOf(moduleId)) { - if (!isLive) throw IllegalStateException("This hook handle is no longer valid") + val record = + record ?: throw IllegalStateException("This hook handle is no longer valid") // Everything but the hooker is inherited, which is what distinguishes this from // registering a new hook that happens to carry the same id. return swapLocked( @@ -125,18 +128,19 @@ internal constructor( */ internal fun swapLocked(replacement: VectorHookRecord): VectorHookHandle { val moduleId = checkNotNull(moduleId) - if ( - !HookBridge.replaceCallback(true, origin, record, replacement, replacement.priority) - ) { + val record = record ?: throw IllegalStateException("This hook handle is no longer valid") + val handle = VectorHookHandle(origin, moduleId, replacement) + if (!HookBridge.replaceCallback(true, origin, record, replacement, replacement.priority)) { // The record was not where we left it, and nothing was changed, so whatever hook is // installed now stays installed. throw HookFailedError("Cannot replace the hook on $origin") } - isLive = false + // Only this handle drops the immutable record. Snapshots already handed to running + // invocations still own it, so their callbacks finish on the generation they entered. + this.record = null VectorHookRegistry.forget(moduleId, this) - val handle = VectorHookHandle(origin, moduleId, replacement) VectorHookRegistry.track(moduleId, handle) replacement.id?.let { VectorHookRegistry.claimId(moduleId, origin, it, handle) } return handle diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/utils/DexMemoryScope.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/utils/DexMemoryScope.kt new file mode 100644 index 000000000..81a4724b7 --- /dev/null +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/utils/DexMemoryScope.kt @@ -0,0 +1,69 @@ +package org.matrix.vector.impl.utils + +import android.os.SharedMemory +import java.nio.ByteBuffer + +/** Closes this process's received DEX handles, including handles never mapped by a loader. */ +internal fun closeDexHandles(dexes: List?): Throwable? { + var failure: Throwable? = null + dexes?.forEach { dex -> + try { + dex.close() + } catch (t: Throwable) { + if (failure == null) failure = t else if (failure !== t) failure.addSuppressed(t) + } + } + return failure +} + +/** + * Owns only the input mappings and handles. The loader retains its own ART-owned DEX memory. A + * missing DEX fails the complete load; never construct a silently incomplete generation. + */ +internal fun withConsumedDexBuffers( + dexes: List, + load: (Array) -> T, +): T { + val buffers = ArrayList() + var failure: Throwable? = null + try { + for (dex in dexes) { + val buffer = dex.mapReadOnly() + try { + buffers.add(buffer) + } catch (t: Throwable) { + // Also cover an allocation failure while recording this successful mapping. + try { + SharedMemory.unmap(buffer) + } catch (cleanup: Throwable) { + if (cleanup !== t) t.addSuppressed(cleanup) + } + throw t + } + } + return load(buffers.toTypedArray()) + } catch (t: Throwable) { + failure = t + throw t + } finally { + var cleanupFailure: Throwable? = null + for (buffer in buffers) { + try { + SharedMemory.unmap(buffer) + } catch (t: Throwable) { + if (cleanupFailure == null) cleanupFailure = t + else if (cleanupFailure !== t) cleanupFailure.addSuppressed(t) + } + } + buffers.clear() + val closeFailure = closeDexHandles(dexes) + if (closeFailure != null) { + if (cleanupFailure == null) cleanupFailure = closeFailure + else if (cleanupFailure !== closeFailure) cleanupFailure.addSuppressed(closeFailure) + } + if (cleanupFailure != null) { + if (failure == null) throw cleanupFailure + if (failure !== cleanupFailure) failure.addSuppressed(cleanupFailure) + } + } +} diff --git a/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt b/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt index 7b93af393..abf5e312b 100644 --- a/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt +++ b/xposed/src/main/kotlin/org/matrix/vector/impl/utils/VectorModuleClassLoader.kt @@ -15,7 +15,6 @@ import java.nio.ByteBuffer import java.util.Collections import java.util.Enumeration import java.util.jar.JarFile -import java.util.stream.Collectors import java.util.zip.ZipEntry import org.matrix.vector.nativebridge.HookBridge @@ -184,22 +183,7 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader { parent: ClassLoader?, blockLegacyApi: Boolean = false, ): ClassLoader { - val dexBuffers = - dexes - .parallelStream() - .map { dex -> - try { - dex.mapReadOnly() - } catch (e: ErrnoException) { - Log.w(TAG, "Cannot map $dex", e) - null - } - } - .collect(Collectors.toList()) - .filterNotNull() - .toTypedArray() - - val cl = + return withConsumedDexBuffers(dexes) { dexBuffers -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { VectorModuleClassLoader( dexBuffers, @@ -217,11 +201,7 @@ class VectorModuleClassLoader : ByteBufferDexClassLoader { blockLegacyApi, ) } - - dexBuffers.toList().parallelStream().forEach { SharedMemory.unmap(it) } - dexes.parallelStream().forEach { it.close() } - - return cl + } } } }