From d917bee26bbf49835b42c5cce1f1b94e0f1f024d Mon Sep 17 00:00:00 2001 From: faraz152 <38698072+faraz152@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:23:05 +0500 Subject: [PATCH 1/2] fix(process): dispatch heap analysis events to main process listeners When heap analysis runs in a separate :leakcanary process via RemoteHeapAnalyzerWorker, the HeapAnalysisDone event is only dispatched to listeners in the background process. Custom OnHeapAnalyzedListener callbacks registered in the main process never receive the result. This fix serializes the analysis result to a shared file in the worker, then passes the file path as WorkManager output data. The main process observes work completion via WorkInfo LiveData, reads the event back, and re-dispatches it to all configured event listeners. Fixes #1789 --- .../RemoteWorkManagerHeapAnalyzer.kt | 52 ++++++++++++++++++- .../internal/RemoteHeapAnalyzerWorker.kt | 23 +++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/leakcanary/leakcanary-android-core/src/main/java/leakcanary/RemoteWorkManagerHeapAnalyzer.kt b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/RemoteWorkManagerHeapAnalyzer.kt index 50f534fa43..4f81db9ef9 100644 --- a/leakcanary/leakcanary-android-core/src/main/java/leakcanary/RemoteWorkManagerHeapAnalyzer.kt +++ b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/RemoteWorkManagerHeapAnalyzer.kt @@ -1,20 +1,28 @@ package leakcanary +import androidx.lifecycle.Observer import androidx.work.Data import androidx.work.OneTimeWorkRequest +import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.multiprocess.RemoteListenableWorker.ARGUMENT_CLASS_NAME import androidx.work.multiprocess.RemoteListenableWorker.ARGUMENT_PACKAGE_NAME +import android.os.Handler +import android.os.Looper +import java.io.File import leakcanary.EventListener.Event +import leakcanary.EventListener.Event.HeapAnalysisDone import leakcanary.EventListener.Event.HeapDump import leakcanary.internal.HeapAnalyzerWorker.Companion.asWorkerInputData import leakcanary.internal.InternalLeakCanary import leakcanary.internal.RemoteHeapAnalyzerWorker +import leakcanary.internal.Serializables import shark.SharkLog /** * When receiving a [HeapDump] event, starts a WorkManager worker that performs heap analysis in - * a dedicated :leakcanary process + * a dedicated :leakcanary process. When the analysis completes, the result is sent back to the + * main process and dispatched to all configured event listeners. */ object RemoteWorkManagerHeapAnalyzer : EventListener { @@ -45,6 +53,48 @@ object RemoteWorkManagerHeapAnalyzer : EventListener { SharkLog.d { "Enqueuing heap analysis for ${event.file} on WorkManager remote worker" } val workManager = WorkManager.getInstance(application) workManager.enqueue(heapAnalysisRequest) + + // Observe the remote worker's completion from the main process so we can + // re-dispatch the HeapAnalysisDone event to listeners running here. + val workInfoLiveData = workManager.getWorkInfoByIdLiveData(heapAnalysisRequest.id) + Handler(Looper.getMainLooper()).post { + workInfoLiveData.observeForever(object : Observer { + override fun onChanged(workInfo: WorkInfo) { + if (workInfo.state.isFinished) { + workInfoLiveData.removeObserver(this) + if (workInfo.state == WorkInfo.State.SUCCEEDED) { + dispatchEventFromRemoteWorker(workInfo.outputData) + } + } + } + }) + } + } + } + + private fun dispatchEventFromRemoteWorker(outputData: Data) { + val eventFilePath = outputData.getString(RemoteHeapAnalyzerWorker.EVENT_FILE_KEY) + if (eventFilePath == null) { + SharkLog.d { "Remote worker completed but no event file path in output data" } + return + } + val eventFile = File(eventFilePath) + if (!eventFile.exists()) { + SharkLog.d { "Remote worker event file does not exist: $eventFilePath" } + return + } + try { + val doneEvent = Serializables.fromByteArray>(eventFile.readBytes()) + if (doneEvent != null) { + SharkLog.d { "Dispatching remote heap analysis result to main process listeners" } + InternalLeakCanary.sendEvent(doneEvent) + } else { + SharkLog.d { "Failed to deserialize remote worker event" } + } + } catch (e: Throwable) { + SharkLog.d(e) { "Error reading remote worker event file" } + } finally { + eventFile.delete() } } } diff --git a/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/RemoteHeapAnalyzerWorker.kt b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/RemoteHeapAnalyzerWorker.kt index d94593897a..6dddaa8a67 100644 --- a/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/RemoteHeapAnalyzerWorker.kt +++ b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/RemoteHeapAnalyzerWorker.kt @@ -1,11 +1,13 @@ package leakcanary.internal import android.content.Context +import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import androidx.work.impl.utils.futures.SettableFuture import androidx.work.multiprocess.RemoteListenableWorker import com.google.common.util.concurrent.ListenableFuture +import java.io.File import leakcanary.BackgroundThreadHeapAnalyzer.heapAnalyzerThreadHandler import leakcanary.EventListener.Event.HeapDump import leakcanary.internal.HeapAnalyzerWorker.Companion.asEvent @@ -32,8 +34,22 @@ internal class RemoteHeapAnalyzerWorker( if (result.isCancelled) { SharkLog.d { "Remote heap analysis for ${heapDump.file} was canceled" } } else { + // Dispatch to any listeners configured in the background process. InternalLeakCanary.sendEvent(doneEvent) - result.set(Result.success()) + // Serialize the done event to a file so the main process can re-dispatch it + // to its own listeners. WorkManager output data has a 10 KB limit, so we write + // to a shared file instead and pass the path back. + val outputData = try { + val eventFile = File(applicationContext.filesDir, EVENT_FILE_PREFIX + heapDump.uniqueId) + eventFile.writeBytes(doneEvent.toByteArray()) + Data.Builder() + .putString(EVENT_FILE_KEY, eventFile.absolutePath) + .build() + } catch (e: Throwable) { + SharkLog.d(e) { "Failed to serialize done event for main process" } + Data.EMPTY + } + result.set(Result.success(outputData)) } } return result @@ -44,4 +60,9 @@ internal class RemoteHeapAnalyzerWorker( applicationContext.heapAnalysisForegroundInfo() } } + + companion object { + const val EVENT_FILE_PREFIX = "leakcanary_remote_event_" + const val EVENT_FILE_KEY = "leakcanary.remote.event_file" + } } From 48a4517cd81ecc5da97c67a2e2201f9ef838a650 Mon Sep 17 00:00:00 2001 From: faraz152 <38698072+faraz152@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:08:59 +0500 Subject: [PATCH 2/2] Address review feedback from @pyricau - Remove success log in dispatchEventFromRemoteWorker (redundant, the event dispatch itself is visible) - Use mainHandler from Handlers.kt instead of Handler(Looper.getMainLooper()) - Add dispatchAndCleanupOrphanedEventFiles() to handle the case where the main process dies after scheduling RemoteHeapAnalyzerWorker: on startup, scan filesDir for leftover event files, dispatch any recoverable events, and delete all orphaned files --- .../RemoteWorkManagerHeapAnalyzer.kt | 35 ++++++++++++++++--- .../leakcanary/internal/InternalLeakCanary.kt | 9 +++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/leakcanary/leakcanary-android-core/src/main/java/leakcanary/RemoteWorkManagerHeapAnalyzer.kt b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/RemoteWorkManagerHeapAnalyzer.kt index 4f81db9ef9..787d279095 100644 --- a/leakcanary/leakcanary-android-core/src/main/java/leakcanary/RemoteWorkManagerHeapAnalyzer.kt +++ b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/RemoteWorkManagerHeapAnalyzer.kt @@ -7,8 +7,6 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.multiprocess.RemoteListenableWorker.ARGUMENT_CLASS_NAME import androidx.work.multiprocess.RemoteListenableWorker.ARGUMENT_PACKAGE_NAME -import android.os.Handler -import android.os.Looper import java.io.File import leakcanary.EventListener.Event import leakcanary.EventListener.Event.HeapAnalysisDone @@ -17,6 +15,7 @@ import leakcanary.internal.HeapAnalyzerWorker.Companion.asWorkerInputData import leakcanary.internal.InternalLeakCanary import leakcanary.internal.RemoteHeapAnalyzerWorker import leakcanary.internal.Serializables +import leakcanary.internal.friendly.mainHandler import shark.SharkLog /** @@ -37,6 +36,35 @@ object RemoteWorkManagerHeapAnalyzer : EventListener { } } + /** + * Cleans up orphaned event files from previous remote worker runs. + * If the main process died after a remote worker completed, the result file + * would never be read or deleted. This dispatches any recoverable events + * and deletes all leftover files. + * + * Called from [InternalLeakCanary.invoke] on startup. + */ + internal fun dispatchAndCleanupOrphanedEventFiles() { + val application = InternalLeakCanary.application + val filesDir = application.filesDir + val orphanedFiles = filesDir.listFiles { file -> + file.name.startsWith(RemoteHeapAnalyzerWorker.EVENT_FILE_PREFIX) + } ?: return + for (eventFile in orphanedFiles) { + try { + val doneEvent = Serializables.fromByteArray>(eventFile.readBytes()) + if (doneEvent != null) { + SharkLog.d { "Recovering orphaned remote event from ${eventFile.name}" } + InternalLeakCanary.sendEvent(doneEvent) + } + } catch (e: Throwable) { + SharkLog.d(e) { "Failed to recover orphaned event file ${eventFile.name}" } + } finally { + eventFile.delete() + } + } + } + override fun onEvent(event: Event) { if (event is HeapDump) { val application = InternalLeakCanary.application @@ -57,7 +85,7 @@ object RemoteWorkManagerHeapAnalyzer : EventListener { // Observe the remote worker's completion from the main process so we can // re-dispatch the HeapAnalysisDone event to listeners running here. val workInfoLiveData = workManager.getWorkInfoByIdLiveData(heapAnalysisRequest.id) - Handler(Looper.getMainLooper()).post { + mainHandler.post { workInfoLiveData.observeForever(object : Observer { override fun onChanged(workInfo: WorkInfo) { if (workInfo.state.isFinished) { @@ -86,7 +114,6 @@ object RemoteWorkManagerHeapAnalyzer : EventListener { try { val doneEvent = Serializables.fromByteArray>(eventFile.readBytes()) if (doneEvent != null) { - SharkLog.d { "Dispatching remote heap analysis result to main process listeners" } InternalLeakCanary.sendEvent(doneEvent) } else { SharkLog.d { "Failed to deserialize remote worker event" } diff --git a/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/InternalLeakCanary.kt b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/InternalLeakCanary.kt index 8bfc2ca898..b1c704e602 100644 --- a/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/InternalLeakCanary.kt +++ b/leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/InternalLeakCanary.kt @@ -22,6 +22,7 @@ import leakcanary.GcTrigger import leakcanary.LeakCanary import leakcanary.LeakCanaryAndroidInternalUtils import leakcanary.OnObjectRetainedListener +import leakcanary.RemoteWorkManagerHeapAnalyzer import leakcanary.inProcess import leakcanary.internal.HeapDumpControl.ICanHazHeap.Nope import leakcanary.internal.HeapDumpControl.ICanHazHeap.Yup @@ -142,6 +143,14 @@ internal object InternalLeakCanary : (Application) -> Unit, OnObjectRetainedList registerResumedActivityListener(application) LeakCanaryAndroidInternalUtils.addLeakActivityDynamicShortcut(application) + // Recover any heap analysis results from remote workers that completed + // while the main process was dead, and clean up orphaned event files. + if (RemoteWorkManagerHeapAnalyzer.remoteLeakCanaryServiceInClasspath) { + backgroundHandler.post { + RemoteWorkManagerHeapAnalyzer.dispatchAndCleanupOrphanedEventFiles() + } + } + // We post so that the log happens after Application.onCreate() where // the config could be updated. mainHandler.post {